Merge branch 'main' into fix/ci-singleton-bucket-oom-chunking
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Cargo environment for the workspace.
|
||||
#
|
||||
# audiopus_sys 0.2.2 builds its vendored Opus via the `cmake` crate, and the
|
||||
# bundled opus CMakeLists still declares `cmake_minimum_required(VERSION 3.1)`.
|
||||
# CMake 4.0 removed compatibility with < 3.5, so any dev host with a modern
|
||||
# cmake fails `cargo check`/`cargo clippy` on pi-voice with
|
||||
# "Compatibility with CMake < 3.5 has been removed from CMake." The bazel build
|
||||
# already sets this override for the same crate (see the audiopus_sys
|
||||
# annotation in MODULE.bazel); mirror it here so the cargo path works too.
|
||||
[env]
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
@@ -0,0 +1,59 @@
|
||||
name: OMP Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/nix.yml"
|
||||
- ".cargo/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "patches/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- "packages/**"
|
||||
- "crates/**"
|
||||
- "scripts/**"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/nix.yml"
|
||||
- ".cargo/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "nix/**"
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
- "patches/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- "packages/**"
|
||||
- "crates/**"
|
||||
- "scripts/**"
|
||||
- "docs/**"
|
||||
|
||||
concurrency:
|
||||
group: "${{ github.workflow }}-${{ github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
evaluate:
|
||||
name: Evaluate flake
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
accept-flake-config = true
|
||||
- name: Evaluate every supported system
|
||||
run: nix flake check --all-systems --no-build --show-trace
|
||||
@@ -12,6 +12,8 @@ target/
|
||||
*.tsbuildinfo
|
||||
*.node
|
||||
*.b64.js
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
+56
-61
@@ -1,108 +1,103 @@
|
||||
# Cleanup Command
|
||||
|
||||
One iteration of an autonomous cleanup loop. Each run: discover ONE target, execute it completely, verify, report. Runs are stateless — derive everything from the current tree; assume prior iterations already happened and left the tree consistent.
|
||||
Autonomous cleanup-loop iteration: discover ONE target → complete execution → verify → report. Runs stateless: derive from current tree; assume prior runs left it consistent.
|
||||
|
||||
<critical>
|
||||
- Behavior-preserving ONLY. Observable behavior of the CLI, SDK, RPC surface, and rendered output NEVER changes.
|
||||
- Every iteration MUST deliver a named, concrete quality win (duplicate implementation gone, responsibility extracted, dead cluster removed, guard clutter deleted). Lean toward deletion: net-negative LOC is the expected shape and the tie-breaker between candidates, but justified net-neutral/positive work (a split, a hierarchy fix) is acceptable when the win is real. Report the LOC delta either way.
|
||||
- NEVER commit. NEVER touch generated or vendored code.
|
||||
- Complete the full cutover in this run: every copy migrated, every callsite updated, originals deleted. Half-migrations are worse than nothing.
|
||||
- No target clears the bar? Output exactly `CLEAN: no target above threshold` and stop.
|
||||
- Behavior-preserving ONLY: CLI, SDK, RPC surface, rendered output NEVER change.
|
||||
- Every iteration MUST yield a named concrete quality win: duplicate implementation gone, responsibility extracted, dead cluster removed, guard clutter deleted. Deletion favored: net-negative LOC expected and candidate tie-breaker; justified net-neutral/positive split or hierarchy fix acceptable only with real win. Report LOC delta either way.
|
||||
- NEVER commit; NEVER touch generated or vendored code.
|
||||
- Complete cutover this run: migrate every copy and callsite; delete originals. NEVER half-migrate.
|
||||
- No target above bar → output exactly `CLEAN: no target above threshold` and stop.
|
||||
</critical>
|
||||
|
||||
## Scope
|
||||
|
||||
- TypeScript only. Priority order: `packages/coding-agent`, `packages/ai`, `packages/catalog`, `packages/utils`. Other packages MAY be edited only when callsite migration drags them in.
|
||||
- NEVER touch: `**/*-gen/**`, `**/vendor/**`, generated JSON catalogs, `.d.ts`, test fixtures/snapshots, lockfiles, anything non-TS.
|
||||
TypeScript only. Package priority: `packages/coding-agent`, `packages/ai`, `packages/catalog`, `packages/utils`. Other packages MAY change only when callsite migration requires.
|
||||
|
||||
NEVER touch: `**/*-gen/**`, `**/vendor/**`, generated JSON catalogs, `.d.ts`, test fixtures/snapshots, lockfiles, non-TS.
|
||||
|
||||
## 1. Discover
|
||||
|
||||
Run the scanner first: `bun scripts/cleanup-scan.ts` (add `--json` for machine output, `--pkg=<a,b>|all` to widen). It reports god-object candidates, clone clusters with line ranges, junk drawers, tiered dead-export candidates, deep relative imports, and defensive-check hotspots. Scanner output is EVIDENCE, not verdict — every entry still needs reading before action. Supplement with `lsp references` and targeted grep where the scanner is blind (semantic duplication, wrong-home modules with shallow imports).
|
||||
First run `bun scripts/cleanup-scan.ts`; `--json`: machine output; `--pkg=<a,b>|all`: widen scope. It reports god-object candidates, clone clusters/line ranges, junk drawers, tiered dead-export candidates, deep relative imports, defensive-check hotspots. Output is EVIDENCE, not verdict: read every entry before action. Where scanner misses semantic duplication or wrong-home modules with shallow imports, use `lsp references` and targeted grep.
|
||||
|
||||
Candidate classes:
|
||||
|
||||
**Dead weight** (highest value per risk)
|
||||
- Exported symbols with zero non-test references in the repo (scanner: `dead-exports`). Tiers: `barrel-public` (re-exported through an explicit `exports`-map entry or public barrel) = published surface, PROTECTED — external consumers exist that no tool can see. `wildcard-only` (importable only via a `./*` subpath pattern) = internal-by-default, deletable once proven.
|
||||
- Options/parameters no caller passes; branches no input reaches.
|
||||
- Compatibility shims, deprecated aliases, re-export indirection left by past refactors.
|
||||
- Runtime checks re-verifying what the type system already guarantees.
|
||||
**Dead weight** — highest value/risk
|
||||
- `dead-exports`: exported symbols with zero repo non-test references. `barrel-public`: re-exported via explicit `exports`-map entry or public barrel; published surface, PROTECTED—tools cannot see external consumers. `wildcard-only`: importable only through `./*` subpath pattern; internal-by-default, deletable once proven.
|
||||
- Unpassed options/parameters; unreachable branches.
|
||||
- Compatibility shims, deprecated aliases, re-export indirection from past refactors.
|
||||
- Runtime checks duplicating type-system guarantees.
|
||||
|
||||
**Duplication**
|
||||
- Scanner `clones` clusters give exact line ranges; literal-heavy boilerplate (schema tables, registry descriptors) is repetitive by design — extract only when a helper genuinely simplifies every site.
|
||||
- Same helper reimplemented in 2+ files; copies differing only by a literal or flag.
|
||||
- Inline reimplementations of an existing central utility (path shortening, truncation, spawning, stream reading, caching).
|
||||
- Parallel switch/if-chains that dispatch on the same discriminant in multiple places.
|
||||
- `clones` gives exact ranges. Literal-heavy schema tables/registry descriptors intentionally repeat; extract only if a helper genuinely simplifies every site.
|
||||
- Helper reimplemented in 2+ files; copies differing only by literal/flag.
|
||||
- Inline reimplementation of central path-shortening, truncation, spawning, stream-reading, or caching utility.
|
||||
- Parallel switch/if chains dispatching on one discriminant in multiple locations.
|
||||
|
||||
**God objects**
|
||||
- Files whose size dwarfs their siblings AND mix responsibilities (state + IO + rendering + parsing in one module; classes whose method list spans several domains).
|
||||
- Size alone is not a smell — a large file with one coherent responsibility stays.
|
||||
- File dwarfs siblings AND mixes responsibilities: state + IO + rendering + parsing; or class methods span domains.
|
||||
- Size alone no smell: retain large coherent files.
|
||||
|
||||
**Hierarchy rot**
|
||||
- Junk drawers: modules named after no domain (`utils`, `helpers`, `misc`, `common`) accreting unrelated code.
|
||||
- Deep relative imports (`../../..`) signaling a module living in the wrong place.
|
||||
- Directories grouped by kind (`types/`, `constants/`, `interfaces/`) instead of domain.
|
||||
- Barrels re-exporting things nobody imports through them; single-file directories; module names that no longer describe contents.
|
||||
- Domainless junk drawers: `utils`, `helpers`, `misc`, `common` with unrelated accretions.
|
||||
- `../../..` imports: wrong module home.
|
||||
- Directories grouped by kind (`types/`, `constants/`, `interfaces/`) rather than domain.
|
||||
- Unused barrels; single-file directories; names no longer describing contents.
|
||||
|
||||
## 2. Select
|
||||
|
||||
Score candidates by `(quality win × confidence) / blast radius`. Pick exactly ONE cluster, roughly ≤12 files touched. Tie-break: deletion > dedup > split > move; between equals, prefer the larger LOC reduction.
|
||||
Score `(quality win × confidence) / blast radius`. Pick exactly ONE cluster, roughly ≤12 touched files. Tie-break: deletion > dedup > split > move; equals → larger LOC reduction.
|
||||
|
||||
Bar for "worth doing" — a quality win you can name in one sentence, e.g.:
|
||||
- Removes an entire duplicate implementation or ≥100 duplicated/dead lines.
|
||||
- Splits a file that is both oversized for its package and multi-responsibility.
|
||||
- Eliminates a junk drawer, dead-export cluster, or guard-clutter hotspot entirely.
|
||||
- Moves a module cluster so the tree reads as designed, not accreted.
|
||||
Worth-doing bar: name win in one sentence, e.g. entire duplicate implementation or ≥100 duplicated/dead lines removed; oversized, multi-responsibility package file split; junk drawer, dead-export cluster, or guard-clutter hotspot eliminated; module cluster moved so tree reads designed, not accreted.
|
||||
|
||||
## 3. Execute
|
||||
|
||||
**Dead weight / type checks**
|
||||
- Delete dead exports and the tests that only mirrored them. Two proofs REQUIRED before deleting any export: (1) `lsp references` shows no callsites — missed callsites are bugs; (2) the symbol is `wildcard-only`: not re-exported, directly or transitively, through any explicit `exports`-map entry or public barrel. Fails either proof? It stays.
|
||||
- Narrow once at the IO boundary; internal code takes the narrowed type. Delete downstream `?.` chains on non-nullable values, `?? fallback` on non-optional, `typeof`/`Array.isArray` re-narrowing, `as` casts papering over flow.
|
||||
- Value genuinely sometimes-absent? Fix the TYPE upstream; NEVER sprinkle guards downstream.
|
||||
- try/catch that swallows and limps on → delete it or let the error propagate. Precise catches (e.g. ENOENT) only.
|
||||
- Delete dead exports and tests only mirroring them. Export deletion requires BOTH: (1) `lsp references`: no callsites—missed callsites are bugs; (2) `wildcard-only`: no direct/transitive re-export through explicit `exports`-map entry or public barrel. Either fails → retain.
|
||||
- Narrow once at IO boundary; internal code receives narrowed type. Delete downstream `?.` on non-nullable values, `?? fallback` on non-optional values, `typeof`/`Array.isArray` re-narrowing, and `as` casts papering over flow.
|
||||
- Genuinely sometimes-absent value → fix TYPE upstream; NEVER add downstream guards.
|
||||
- Swallow-and-limp `try/catch` → delete or propagate error. Precise catches only, e.g. ENOENT.
|
||||
|
||||
**Dedup**
|
||||
- 2+ copies → one function in the nearest common domain module; cross-package → the shared utils package. NEVER create a new junk drawer to hold it.
|
||||
- Copies differing by a literal/flag → one function with an options object. NEVER boolean positionals.
|
||||
- Prefer the hardened copy (timeouts, caps, sanitization) as the survivor; the fresh copies lose that hardening.
|
||||
- 2+ copies → one function in nearest common domain module; cross-package → shared utils package. NEVER create a junk drawer.
|
||||
- Literal/flag variants → one function with options object; NEVER boolean positionals.
|
||||
- Keep hardened copy—timeouts, caps, sanitization—not fresh copies that lack hardening.
|
||||
|
||||
**God objects**
|
||||
- Split along existing seams into domain-named modules; one responsibility each.
|
||||
- Extraction is MOVEMENT: code moves verbatim except imports/visibility. Rewriting-while-moving hides regressions.
|
||||
- Update every importer; NEVER leave a re-export shim. A split that introduces an interface, base class, event bus, or DI where a direct call existed is a failed split.
|
||||
- Split on existing seams into domain-named, single-responsibility modules.
|
||||
- Extraction = MOVEMENT: code verbatim except imports/visibility; rewriting while moving hides regressions.
|
||||
- Update every importer; NEVER retain re-export shim. Split introducing interface, base class, event bus, or DI where direct call existed = failed split.
|
||||
|
||||
**Hierarchy**
|
||||
- Move files with `lsp rename_file` so imports rewrite everywhere.
|
||||
- Group by domain, not kind. Collapse single-file directories; delete empty barrels.
|
||||
- After the move, the tree MUST read as if this were always the design.
|
||||
- Use `lsp rename_file` to move files and rewrite imports everywhere.
|
||||
- Group by domain, not kind; collapse single-file directories; delete empty barrels.
|
||||
- Resulting tree MUST read as always designed.
|
||||
|
||||
**Perf** (opportunistic — only inside code already being touched)
|
||||
- Hoist loop invariants; precompile regexes; single pass over chained filter/map on hot paths; drop intermediate arrays/strings/copies.
|
||||
- NEVER trade clarity for micro-perf on cold paths. NEVER add caching layers.
|
||||
**Perf** — opportunistic; only code already touched
|
||||
- Hoist loop invariants; precompile regexes; use one pass rather than chained filter/map on hot paths; remove intermediate arrays/strings/copies.
|
||||
- NEVER trade cold-path clarity for micro-perf; NEVER add caching layers.
|
||||
|
||||
## 4. Prohibitions
|
||||
|
||||
- NEVER add: dependencies, config/options, feature flags, wrapper layers, abstractions with one implementation, "future-proofing".
|
||||
- NEVER rename or alter public surface. Public surface = the CLI, plus every symbol reachable from an explicit (non-wildcard) `exports`-map entry point or public barrel — external consumers exist beyond this repo's references. Wildcard `./*` subpaths expose files mechanically, not contractually; explicit entries and barrels are the contract.
|
||||
- NEVER reformat or restyle code outside the touched cluster.
|
||||
- NEVER do drive-by comment/doc sweeps; comment only new non-obvious code.
|
||||
- NEVER add tests for moved-but-unchanged code; keep existing tests passing, relocating them alongside their subject.
|
||||
- NEVER add dependencies, config/options, feature flags, wrapper layers, one-implementation abstractions, or "future-proofing".
|
||||
- NEVER rename or alter public surface: CLI plus symbols reachable from explicit non-wildcard `exports`-map entry or public barrel. External consumers exceed repo references. Wildcard `./*` exposes files mechanically, not contractually; explicit entries/barrels define contract.
|
||||
- NEVER reformat/restyle outside touched cluster.
|
||||
- NEVER drive-by comment/doc sweep; comment only new non-obvious code.
|
||||
- NEVER add tests for moved-but-unchanged code; retain passing tests, relocating them with subject.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
1. `bun check` — clean.
|
||||
2. Run the touched package's tests scoped to affected areas.
|
||||
3. Renderer/TUI code touched? Confirm sanitization helpers still wrap every render path.
|
||||
1. `bun check`: clean.
|
||||
2. Run touched package tests scoped to affected areas.
|
||||
3. Renderer/TUI touched → confirm sanitization helpers wrap every render path.
|
||||
|
||||
## 6. Report
|
||||
|
||||
- Target: what was chosen and which smell class.
|
||||
- Actions: deleted / merged / split / moved, the named quality win, and the LOC delta.
|
||||
- Verification: exact commands run and results.
|
||||
- Risk: anything a reviewer should eyeball.
|
||||
- Target: choice and smell class.
|
||||
- Actions: deleted/merged/split/moved; named quality win; LOC delta.
|
||||
- Verification: exact commands and results.
|
||||
- Risk: reviewer checks.
|
||||
|
||||
<critical>
|
||||
- One target per run, executed to completion — full callsite migration, originals deleted, `bun check` clean.
|
||||
- A named quality win, behavior identical, no new abstractions, no shims. Deletion-leaning: justify any net-positive delta.
|
||||
- Nothing above the bar → output `CLEAN: no target above threshold`.
|
||||
One target/run; complete migration; originals deleted; `bun check` clean. Named quality win; identical behavior; no new abstractions or shims. Deletion-leaning: justify net-positive delta. Nothing above bar → `CLEAN: no target above threshold`.
|
||||
</critical>
|
||||
|
||||
+47
-59
@@ -1,60 +1,54 @@
|
||||
# Fix Issues Command
|
||||
|
||||
Diagnose, reproduce, and (when reproducible) fix open GitHub issues in parallel — each in its own clean worktree, with build artifacts symlinked so nothing recompiles.
|
||||
Diagnose, reproduce, then fix reproducible open GitHub issues in parallel: one clean worktree/issue; symlink build artifacts to avoid rebuilds.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` — optional. Either:
|
||||
- a space- or comma-separated list of issue numbers / URLs, OR
|
||||
- GitHub-search qualifiers (`is:open`, `label:bug`, `author:foo`, ...) and/or a relative time window like `3d`, `2w`, `12h`.
|
||||
`$ARGUMENTS` optional: space/comma-separated issue numbers/URLs, or GitHub-search qualifiers (`is:open`, `label:bug`, `author:foo`, ...) and/or time window (`3d`, `2w`, `12h`).
|
||||
|
||||
If no issues and no flags are passed, default to **all open issues opened in the last 3 days**.
|
||||
No issues/flags → all issues open and created within last 3 days.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Resolve the issue set
|
||||
## 1. Resolve issues
|
||||
|
||||
Parse `$ARGUMENTS`.
|
||||
|
||||
- If explicit issue numbers/URLs given, use them verbatim.
|
||||
- Otherwise call the `github` tool with `op: search_issues`. Default (no args):
|
||||
- Explicit numbers/URLs: use verbatim.
|
||||
- Otherwise `github` `op: search_issues`. No args:
|
||||
|
||||
```
|
||||
github { op: "search_issues", query: "is:open", since: "3d", limit: 50 }
|
||||
```
|
||||
|
||||
Pass any user-supplied qualifiers verbatim through `query` (combine with `is:open` if not already present). Use `since` for the time window (`3d`, `2w`, `12h`, ISO date — see the `github` tool docs); set `dateField: "updated"` instead of the `created` default only when the user explicitly asks for recently-touched issues.
|
||||
User qualifiers verbatim in `query`; add `is:open` unless present. Time window (`3d`, `2w`, `12h`, ISO date; see `github` docs) → `since`. `dateField` defaults `created`; set `"updated"` only for explicitly requested recently-touched issues.
|
||||
|
||||
Print the resolved set before fanning out so the user can confirm scope.
|
||||
Print resolved set before fan-out for scope confirmation.
|
||||
|
||||
### 2. Fan out one subagent per issue
|
||||
## 2. Parallel subagents
|
||||
|
||||
Use **`task` with parallel subagents** — one task per issue. Pass the issue number, title, body summary, and the workflow below as the assignment. Subagents work in isolation; coordinate via `irc` only when two issues clearly touch the same file.
|
||||
Use parallel `task` subagents: one/issue. Assignment: number, title, body summary, workflow below. Isolated work; `irc` only if issues clearly touch the same file.
|
||||
|
||||
Each subagent **MUST** follow this exact workflow:
|
||||
Each subagent MUST:
|
||||
|
||||
#### a. Read everything
|
||||
### a. Read
|
||||
|
||||
1. Read `issue://<N>` (or `issue://<owner>/<repo>/<N>` for cross-repo) — fetches the issue body plus comments; comments often carry the real repro and fix hints. Append `?comments=0` only if you explicitly want to skip them.
|
||||
2. `gh search prs` for the issue number to see if a fix is already in flight.
|
||||
- If a PR exists and looks reasonable → switch tracks: review that PR per `.omp/commands/review-prs.md` instead, and report back as `existing-pr`. Do **not** open a competing fix.
|
||||
1. Read `issue://<N>`; cross-repo: `issue://<owner>/<repo>/<N>`. Includes body/comments; comments often contain repro/fix hints. Append `?comments=0` only to explicitly skip comments.
|
||||
2. Run `gh search prs` for issue number. Reasonable existing PR → review per `.omp/commands/review-prs.md`, report `existing-pr`; do NOT create competing fix.
|
||||
|
||||
#### b. Diagnose & try to reproduce — **in the current cwd, on `main`**
|
||||
### b. Diagnose/reproduce
|
||||
|
||||
Reproduce **here first**, before touching any worktree. The point is to confirm the bug is real on current main before investing in a fix branch.
|
||||
MUST reproduce in current cwd on `main`, before any worktree.
|
||||
|
||||
1. Read the relevant source paths in this checkout. Form a concrete hypothesis (one or two sentences) about the failure.
|
||||
2. Write a focused test file under the package the bug lives in. Naming: `repro-issue-<N>-<slug>.test.ts` (or `.rs`, etc.) — unique, greppable, deletable.
|
||||
3. Run **only that test file**, not the suite. Confirm it fails for the reason in the issue.
|
||||
1. Read relevant checkout source; state concrete 1–2-sentence failure hypothesis.
|
||||
2. Under affected package, create focused `repro-issue-<N>-<slug>.test.ts` (or `.rs`, etc.): unique, greppable, deletable.
|
||||
3. Run only that file, never suite; confirm expected failure.
|
||||
|
||||
Outcomes:
|
||||
- **Reproduced** → continue to (c).
|
||||
- **Not reproduced** → stop. Delete the test file. Report `unreproduced` with: hypothesis tried, evidence it doesn't fail, and what info would unblock (versions, OS, config, repro snippet from author). Do **not** create a worktree or commit.
|
||||
- **Out of scope / not a bug** (e.g. user config error, intended behavior, dup) → stop. Report `not-a-bug` with the explanation suitable for posting to the issue.
|
||||
- Reproduced → c.
|
||||
- Not reproduced → stop; delete test; report `unreproduced`: hypothesis, non-failure evidence, unblockers (versions, OS, config, author repro snippet). No worktree/commit.
|
||||
- Out-of-scope/not bug (user-config error, intended behavior, dup) → stop; report `not-a-bug` with issue-postable explanation.
|
||||
|
||||
#### c. Create a worktree off main
|
||||
### c. Worktree
|
||||
|
||||
Only after a confirmed local repro:
|
||||
Confirmed local repro required.
|
||||
|
||||
```bash
|
||||
MAIN="$(git rev-parse --show-toplevel)"
|
||||
@@ -65,11 +59,11 @@ git -C "$MAIN" fetch origin main
|
||||
git -C "$MAIN" worktree add -B "fix/issue-<N>" "$WT" origin/main
|
||||
```
|
||||
|
||||
Branch naming: `fix/issue-<N>` (or `fix/issue-<N>-<slug>` if you'll open multiple). Path under `~/.omp/wt/<encoded-main-path>/...` matches the convention `pr_checkout` uses.
|
||||
Branch: `fix/issue-<N>`; `fix/issue-<N>-<slug>` for multiple fixes. Worktree path follows `pr_checkout` convention.
|
||||
|
||||
#### d. Symlink build artifacts
|
||||
### d. Symlink artifacts
|
||||
|
||||
From the new worktree, link build outputs from `$MAIN` so `bun check` / `cargo build` / native loaders skip rebuilds:
|
||||
Before any worktree build/test, use absolute paths:
|
||||
|
||||
```bash
|
||||
cd "$WT"
|
||||
@@ -84,20 +78,20 @@ for f in "$MAIN"/packages/natives/native/*.node; do
|
||||
done
|
||||
```
|
||||
|
||||
Use absolute paths — the worktree lives outside the main checkout.
|
||||
MUST NOT symlink whole `packages/natives/native/`: shadows tracked source.
|
||||
|
||||
#### e. Move the repro test in & fix
|
||||
### e. Fix
|
||||
|
||||
1. Move (don't copy) the failing test file from the main checkout into the same path inside the worktree. Delete it from main so the original cwd is left clean.
|
||||
2. Confirm it still fails inside the worktree on the current branch.
|
||||
3. Implement the fix in source. Match existing patterns (see `AGENTS.md`); fix at the source, not at the symptom; no stubs, no mocks added to product code.
|
||||
4. Re-run the repro test until it passes.
|
||||
5. Add or adjust adjacent unit/contract tests where the fix changes a real contract — not just plumbing. Run **only** the affected test files; no full-suite runs from subagents.
|
||||
6. Run `bun fmt` over the union of files edited.
|
||||
1. Move, never copy, failing test from main into same worktree path; remove it from main.
|
||||
2. Confirm failure in worktree/current branch.
|
||||
3. Fix source, following `AGENTS.md` patterns: root cause, not symptom; no product-code stubs/mocks.
|
||||
4. Re-run repro until passing.
|
||||
5. If real contract changed, add/adjust adjacent unit/contract tests; run only affected files, never full suite.
|
||||
6. `bun fmt` union of edited files.
|
||||
|
||||
#### f. Commit
|
||||
### f. Commit
|
||||
|
||||
Conventional commit, one logical change per commit, with `Fixes #<N>`:
|
||||
One logical conventional commit with `Fixes #<N>`:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
@@ -108,11 +102,9 @@ git commit -m "fix(<scope>): <one-line summary>
|
||||
Fixes #<N>."
|
||||
```
|
||||
|
||||
Do **not** push. The human pushes / opens the PR.
|
||||
Do NOT push; human pushes/opens PR.
|
||||
|
||||
#### g. Report back
|
||||
|
||||
Each subagent returns a short structured report:
|
||||
### g. Report
|
||||
|
||||
```
|
||||
Issue #<N> <title>
|
||||
@@ -124,25 +116,21 @@ Commits: <shas + one-liners> (if any)
|
||||
Notes: <root cause in one sentence; or what info is missing>
|
||||
```
|
||||
|
||||
### 3. Aggregate
|
||||
## 3. Aggregate
|
||||
|
||||
After all subagents finish, print a single summary table:
|
||||
After all subagents, print:
|
||||
|
||||
```
|
||||
| # | Title | Status | Branch / Notes |
|
||||
|---|-------|--------|----------------|
|
||||
```
|
||||
|
||||
Group worktree paths by status (`fixed` first), so the user can `cd` and push the ready ones in one pass.
|
||||
Group worktree paths by status, `fixed` first, for batch `cd`/push.
|
||||
|
||||
## Rules
|
||||
|
||||
- **MUST** reproduce on `main` in the current cwd **before** creating any worktree. No worktree until repro is confirmed.
|
||||
- **MUST** use parallel subagents — one per issue.
|
||||
- **MUST** check for an existing PR first; if one exists and is reasonable, divert to `review-prs` flow instead of duplicating work.
|
||||
- **MUST** symlink `target`, `node_modules`, and the native `*.node` binaries before any build/test runs in the worktree. **MUST NOT** symlink the whole `packages/natives/native/` directory that would shadow tracked source files.
|
||||
- **MUST** use conventional commits with `Fixes #<N>` in the body.
|
||||
- **MUST NOT** push, open PRs, or comment on issues. Human handles delivery.
|
||||
- **MUST NOT** ship stubs, mocks-as-product-code, or "TODO: implement" placeholders as a fix.
|
||||
- **MUST NOT** expand scope: fix the reported bug, not adjacent code smells.
|
||||
- If repro fails, delete the temporary test file from cwd before yielding — leave the original checkout clean.
|
||||
MUST: reproduce on current-cwd `main` before worktree; parallel one-issue subagents; check existing PR first and divert reasonable ones to `review-prs`; symlink `target`, `node_modules`, native `*.node` before worktree builds/tests; conventional commits with body `Fixes #<N>`.
|
||||
|
||||
MUST NOT: symlink entire `packages/natives/native/`; push, open PRs, or comment on issues; ship stubs, product-code mocks, or `TODO: implement` placeholders; expand beyond reported bug into adjacent code smells.
|
||||
|
||||
Failed repro → delete temporary cwd test before yielding; leave original checkout clean.
|
||||
|
||||
+19
-21
@@ -1,37 +1,35 @@
|
||||
# Release Command
|
||||
# Release
|
||||
|
||||
Release all packages with the specified version.
|
||||
Release all packages at specified version.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS`: The version number (semver, e.g., `3.13.0`)
|
||||
`$ARGUMENTS`: semver version, e.g. `3.13.0`.
|
||||
|
||||
## Version Guidance
|
||||
## Version
|
||||
|
||||
- Find the last release version by checking the latest git tag (`vX.Y.Z`) and confirm it matches `packages/*/package.json` versions.
|
||||
- If no version is specified, review commits since the last tag, decide major/minor/patch, then bump accordingly.
|
||||
- If the user specifies `major`, `minor`, or `patch`, bump from the last tag: major -> X+1.0.0, minor -> X.Y+1.0, patch -> X.Y.Z+1.
|
||||
- Last release: latest git tag (`vX.Y.Z`); confirm matches `packages/*/package.json` versions.
|
||||
- No version: review commits since last tag; choose major/minor/patch; bump.
|
||||
- `major`/`minor`/`patch`: bump last tag — major `X+1.0.0`; minor `X.Y+1.0`; patch `X.Y.Z+1`.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the release script:
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun scripts/release.ts $ARGUMENTS
|
||||
```
|
||||
|
||||
The script handles everything automatically:
|
||||
1. Pre-flight checks (clean working dir, on main branch)
|
||||
2. Updates all package.json versions
|
||||
3. Regenerates bun.lock
|
||||
4. Updates CHANGELOGs ([Unreleased] → [version] - date)
|
||||
5. Commits and tags
|
||||
6. Pushes to origin
|
||||
7. Watches CI until all workflows pass
|
||||
Script automatically:
|
||||
1. Pre-flight: clean working dir; main branch.
|
||||
2. Update all `package.json` versions.
|
||||
3. Regenerate `bun.lock`.
|
||||
4. Update CHANGELOGs: `[Unreleased] → [version] - date`.
|
||||
5. Commit and tag.
|
||||
6. Push to origin.
|
||||
7. Watch CI until all workflows pass.
|
||||
|
||||
## Handling CI Failures
|
||||
## CI failures
|
||||
|
||||
If CI fails, the script exits with an error. Fix the issue, then repeat until CI passes:
|
||||
CI failure → script exits with error. Fix, then repeat until CI passes:
|
||||
|
||||
```bash
|
||||
git commit -m "fix: <brief description>"
|
||||
@@ -40,4 +38,4 @@ git tag -f v$ARGUMENTS && git push origin v$ARGUMENTS --force
|
||||
bun scripts/release.ts watch
|
||||
```
|
||||
|
||||
The `watch` subcommand re-watches CI for the current commit until all checks pass.
|
||||
`watch`: re-watches CI for current commit until all checks pass.
|
||||
|
||||
+45
-64
@@ -1,61 +1,54 @@
|
||||
# Review PRs Command
|
||||
# Review PRs
|
||||
|
||||
Triage incoming pull requests in parallel: decide what's worth merging, prep clean rebased worktrees, fix any blockers, and hand them back ready for human merge.
|
||||
Parallel PR triage: decide merge-worthiness, prepare rebased worktrees, fix blockers, return them for human merge.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS` — optional. Either:
|
||||
- a space- or comma-separated list of PR numbers / URLs, OR
|
||||
- GitHub-search qualifiers (`is:open`, `author:foo`, `label:bug`, `draft:false`, ...) and/or a relative time window like `3d`, `2w`, `12h`.
|
||||
`$ARGUMENTS` optional:
|
||||
- space/comma-separated PR numbers/URLs; or
|
||||
- GitHub-search qualifiers (`is:open`, `author:foo`, `label:bug`, `draft:false`, ...) and/or time window (`3d`, `2w`, `12h`).
|
||||
|
||||
If no PRs and no flags are passed, default to **all open PRs opened in the last 3 days**.
|
||||
No PRs or flags: all open PRs opened in last 3 days.
|
||||
|
||||
## Steps
|
||||
## 1. Resolve PRs
|
||||
|
||||
### 1. Resolve the PR set
|
||||
Parse `$ARGUMENTS`. Explicit numbers/URLs: use verbatim. Otherwise `github` `op: search_prs`; no-args default:
|
||||
|
||||
Parse `$ARGUMENTS`.
|
||||
```
|
||||
github { op: "search_prs", query: "is:open", since: "3d", limit: 50 }
|
||||
```
|
||||
|
||||
- If explicit PR numbers/URLs given, use them verbatim.
|
||||
- Otherwise call the `github` tool with `op: search_prs`. Default (no args):
|
||||
Pass supplied qualifiers verbatim in `query`; add `is:open` unless present. Time window (`3d`, `2w`, `12h`, ISO date; see `github` docs): `since`. `dateField` defaults `created`; set `"updated"` only on explicit request for recently-touched PRs. Print resolved set before fan-out for scope confirmation.
|
||||
|
||||
```
|
||||
github { op: "search_prs", query: "is:open", since: "3d", limit: 50 }
|
||||
```
|
||||
## 2. One parallel `task` subagent/PR
|
||||
|
||||
Pass any user-supplied qualifiers verbatim through `query` (combine with `is:open` if not already present). Use `since` for the time window (`3d`, `2w`, `12h`, ISO date — see the `github` tool docs); set `dateField: "updated"` instead of the `created` default only when the user explicitly asks for recently-touched PRs.
|
||||
Assign each PR's number, head ref, author, and workflow. Agents isolate; use `irc` only if a fix on PR A obviously conflicts with PR B.
|
||||
|
||||
Print the resolved set before fanning out so the user can confirm scope.
|
||||
### Required subagent workflow
|
||||
|
||||
### 2. Fan out one subagent per PR
|
||||
#### Read and decide
|
||||
|
||||
Use **`task` with parallel subagents** — one task per PR. Pass the PR number, head ref, author, and the workflow below as the assignment. Each subagent works in isolation; they coordinate via `irc` only if a fix on PR A would obviously conflict with PR B.
|
||||
1. Read `pr://<N>` (comments default; `?comments=0` skips) and `pr://<N>/diff` (changed-file listing). Full unified diff: `pr://<N>/diff/all`; file slice: `pr://<N>/diff/<i>`.
|
||||
2. Check `git log origin/main` and `gh search prs` for an already-landed equivalent.
|
||||
3. Decision:
|
||||
- `slop`: AI-generated noise, broken, off-spec, or net-negative. Drop; 1–2-line justification; no checkout.
|
||||
- `superseded`: fixed/merged in main or newer PR. Drop with pointer.
|
||||
- `worthy`: proceed.
|
||||
|
||||
Each subagent **MUST** follow this exact workflow:
|
||||
Ambiguous: `worthy`; human decides on a real branch.
|
||||
|
||||
#### a. Read & decide
|
||||
|
||||
1. Read `pr://<N>` (with comments by default; append `?comments=0` to skip) and `pr://<N>/diff` for the changed-files listing — use `pr://<N>/diff/all` when you need the full unified diff, or `pr://<N>/diff/<i>` for a single file slice.
|
||||
2. Check `git log origin/main` and `gh search prs` for whether the same change already landed.
|
||||
3. Classify into one of:
|
||||
- **slop** — AI-generated noise, broken, off-spec, or net-negative. Drop, write a 1–2 line justification, do not check out.
|
||||
- **superseded** — already fixed/merged in main or by a newer PR. Drop with a pointer.
|
||||
- **worthy** — proceed.
|
||||
|
||||
Anything ambiguous defaults to `worthy` — let the human decide on a real branch.
|
||||
|
||||
#### b. Check out into a worktree
|
||||
#### Checkout
|
||||
|
||||
```bash
|
||||
gh_PR=<NUMBER>
|
||||
# pr_checkout creates ~/.omp/wt/<encoded-repo>/pr-<N>/ and configures push remote
|
||||
```
|
||||
|
||||
Use the `github pr_checkout` tool, **not** raw `gh pr checkout`. That gives a dedicated worktree wired up for `pr_push` later.
|
||||
MUST use `github pr_checkout`, not raw `gh pr checkout`: it creates a dedicated worktree wired for later `pr_push`.
|
||||
|
||||
#### c. Symlink build artifacts (skip native rebuilds)
|
||||
#### Symlink build artifacts
|
||||
|
||||
From inside the new worktree, link the heavy build outputs from the main checkout so `bun check` / `cargo build` / native loaders do not recompile:
|
||||
Before any worktree build/test, from the worktree symlink main-checkout outputs to avoid `bun check` / `cargo build` / native-loader recompilation:
|
||||
|
||||
```bash
|
||||
MAIN="<absolute path to main worktree, e.g. ~/Projects/pi>"
|
||||
@@ -73,35 +66,26 @@ for f in "$MAIN"/packages/natives/native/*.node; do
|
||||
done
|
||||
```
|
||||
|
||||
Resolve `$MAIN` from the original cwd before `pr_checkout` (`git rev-parse --show-toplevel`). Use absolute paths in symlinks; the worktree lives outside the main repo so relative paths break.
|
||||
Before `pr_checkout`, derive `$MAIN` from original cwd: `git rev-parse --show-toplevel`. Symlinks MUST use absolute paths: worktree is outside main repo; relative paths break. MUST NOT symlink whole `packages/natives/native/`: it shadows tracked PR changes.
|
||||
|
||||
#### d. Rebase onto main
|
||||
#### Rebase
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
If the rebase conflicts:
|
||||
- Resolve trivially mechanical conflicts (formatting, import order, adjacent-line edits) and continue.
|
||||
- Anything semantic → abort the rebase, leave a note in the final report, do not commit.
|
||||
Mechanical conflicts (formatting, import order, adjacent edits): resolve, continue. Semantic conflicts: abort, note final report, do not commit.
|
||||
|
||||
#### e. Review & fix critical issues
|
||||
#### Review and fix
|
||||
|
||||
Inside the worktree, review the diff with the lens of: correctness, security, regressions, breaking-change impact, test coverage of the new path.
|
||||
Review for correctness, security, regressions, breaking-change impact, and new-path test coverage. Fix merge blockers only: build/test failure, obvious PR-introduced bugs, or edge cases required by the PR's goal. Do NOT taste-rewrite, unrelated-refactor, or expand scope.
|
||||
|
||||
Only fix things that **block merge**: build/test breakage, obvious bugs introduced by the PR, missing edge-case handling the PR's own goal demands. Do **not** rewrite for taste, refactor unrelated code, or expand scope.
|
||||
Each fix: read existing patterns; follow `AGENTS.md` conventions; add/update behavior-change tests; run targeted area test files only—no project-wide subagent tests. End with `bun fmt` over union of edited files.
|
||||
|
||||
For every fix:
|
||||
- Read existing patterns first; match repo conventions (see `AGENTS.md`).
|
||||
- Add or update tests for the actual behavior change.
|
||||
- Run only the targeted test file(s) for the area touched. No project-wide test runs from subagents.
|
||||
#### Commit
|
||||
|
||||
Format/lint at the end with `bun fmt` over the union of files you edited.
|
||||
|
||||
#### f. Commit
|
||||
|
||||
One conventional commit per logical fix on top of the rebased PR branch:
|
||||
One conventional commit/logical fix atop rebased PR branch:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
@@ -110,11 +94,11 @@ git commit -m "fix(<scope>): <what & why>
|
||||
Addresses review feedback on #<PR>."
|
||||
```
|
||||
|
||||
Do **not** amend the PR author's commits. Do **not** push — the human merges.
|
||||
Do NOT amend author commits, push, merge, or force-push author history; human reviews/merges.
|
||||
|
||||
#### g. Report back
|
||||
#### Report
|
||||
|
||||
Each subagent returns a short structured report:
|
||||
Return:
|
||||
|
||||
```
|
||||
PR #<N> <title>
|
||||
@@ -125,23 +109,20 @@ Fixes: <commit shas + one-liners> (or: none needed)
|
||||
Blockers: <anything the human must decide>
|
||||
```
|
||||
|
||||
### 3. Aggregate
|
||||
## 3. Aggregate
|
||||
|
||||
After all subagents finish, print a single summary table:
|
||||
After all agents finish, print:
|
||||
|
||||
```
|
||||
| PR | Title | Decision | Rebase | Fixes | Blockers |
|
||||
|----|-------|----------|--------|-------|----------|
|
||||
```
|
||||
|
||||
Followed by the worktree paths grouped by decision, so the user can `cd` and merge in one go.
|
||||
Then worktree paths grouped by decision for `cd` and merge.
|
||||
|
||||
## Rules
|
||||
|
||||
- **MUST** use parallel subagents — one per PR — not a serial loop.
|
||||
- **MUST** use `github pr_checkout` (carries push metadata) — not raw `gh pr checkout`.
|
||||
- **MUST** symlink `target`, `node_modules`, and the native `*.node` binaries before any build/test runs in the worktree. **MUST NOT** symlink the whole `packages/natives/native/` directory that would shadow tracked PR changes.
|
||||
- **MUST NOT** push or merge. Human reviews and merges.
|
||||
- **MUST NOT** expand scope: fixes are limited to merge blockers on this PR's diff.
|
||||
- **MUST NOT** force-push over the PR author's history.
|
||||
- If a PR is `slop`/`superseded`, skip checkout entirely — just record the decision.
|
||||
- MUST use parallel subagents, one/PR; NEVER serial loop.
|
||||
- `slop`/`superseded`: skip checkout; record decision only.
|
||||
- Fixes limited to merge blockers in that PR's diff.
|
||||
- MUST NOT push or merge; human reviews and merges.
|
||||
|
||||
+65
-79
@@ -1,16 +1,16 @@
|
||||
# Triage Command
|
||||
|
||||
Classify and label **newly opened** GitHub issues that are missing labels.
|
||||
Classify/label newly opened GitHub issues missing labels.
|
||||
|
||||
## Arguments
|
||||
|
||||
- `$ARGUMENTS`: Optional window flag `--days <n>` (default: `7`). Only open issues created within this window are triaged.
|
||||
`$ARGUMENTS`: optional `--days <n>`; default `7`. Triage only open issues created within this window.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Fetch Issues
|
||||
### 1. Fetch
|
||||
|
||||
Parse `$ARGUMENTS` to determine the new-issue window (`--days`, default `7`).
|
||||
Parse `$ARGUMENTS` for `--days` (default `7`).
|
||||
|
||||
```bash
|
||||
# Build cutoff date (UTC) for "new" issues
|
||||
@@ -22,104 +22,90 @@ PY
|
||||
|
||||
# Fetch only newly created open issues (default 7-day window)
|
||||
gh issue list --state open --search "created:>=${CUTOFF_DATE}" --json number,title,body,labels,comments,createdAt --limit 50
|
||||
```
|
||||
|
||||
### 2. Filter New Candidates
|
||||
### 2. Candidates
|
||||
|
||||
- Skip any issue older than the cutoff window; this command only triages new issues.
|
||||
- Skip issues with label `triaged` (already handled).
|
||||
- For remaining issues, skip only when all required labels are already present:
|
||||
- Exactly one primary label present (`bug`/`enhancement`/`question`/`proposal`/`documentation`/`invalid`/`duplicate`)
|
||||
- If primary label is `bug`, exactly one `prio:*` label present
|
||||
- At least one functional label present when applicable (`agent`/`tool`/`tui`/`cli`/`prompting`/`sdk`/`auth`/`setup`/`ux`/`providers`)
|
||||
- If provider-specific, at least one matching `provider:*` label present
|
||||
- If platform-specific, at least one matching `platform:*` label present
|
||||
Skip issues older than cutoff or labeled `triaged`. Of the rest, skip only if all applicable requirements hold:
|
||||
- Exactly one primary: `bug`|`enhancement`|`question`|`proposal`|`documentation`|`invalid`|`duplicate`.
|
||||
- `bug` → exactly one `prio:*`.
|
||||
- Applicable functional scope → at least one: `agent`|`tool`|`tui`|`cli`|`prompting`|`sdk`|`auth`|`setup`|`ux`|`providers`.
|
||||
- Provider-specific → matching `provider:*`; platform-specific → matching `platform:*`.
|
||||
|
||||
### 3. Classify Each Issue
|
||||
### 3. Classification
|
||||
|
||||
For each candidate issue, read the title, body, and **all comments** (comments often contain critical context). Apply labels from the categories below. Do not auto-apply provider/platform labels unless explicitly indicated by issue evidence.
|
||||
For every candidate, read title, body, and all comments; comments may contain critical context. Labels below; primary exactly one, priority exactly one only for `bug`, functional all applicable. Provider/platform labels require explicit issue evidence.
|
||||
|
||||
**Primary labels** (pick exactly one):
|
||||
| Label | Signals |
|
||||
|---|---|
|
||||
| `bug` | Existing behavior is broken: crashes, errors, regressions, "doesn't work" |
|
||||
| `enhancement` | Feature request or improvement to existing behavior |
|
||||
| `question` | How-to, clarification, or usage question |
|
||||
| `proposal` | Design/process proposal requiring maintainer decision |
|
||||
| `documentation` | Docs are missing, incorrect, or outdated |
|
||||
| `invalid` | Spam, off-topic, or not actionable |
|
||||
| `duplicate` | Clear duplicate of another issue (reference original in a comment) |
|
||||
**Primary**
|
||||
- `bug`: broken existing behavior—crash, error, regression, "doesn't work".
|
||||
- `enhancement`: feature request/improvement to existing behavior.
|
||||
- `question`: how-to, clarification, usage question.
|
||||
- `proposal`: design/process proposal needing maintainer decision.
|
||||
- `documentation`: missing, incorrect, outdated docs.
|
||||
- `invalid`: spam, off-topic, not actionable.
|
||||
- `duplicate`: clear duplicate; reference original in a comment.
|
||||
|
||||
**Priority labels** (required only for `bug`, pick exactly one):
|
||||
| Label | Signals |
|
||||
|---|---|
|
||||
| `prio:p0` | Critical blocker, data loss/security breakage, unusable workflow |
|
||||
| `prio:p1` | High impact, common workflow broken, should be fixed soon |
|
||||
| `prio:p2` | Medium impact, workaround exists, not blocking most users |
|
||||
| `prio:p3` | Low impact, edge case or minor issue |
|
||||
**Bug priority**
|
||||
- `prio:p0`: critical blocker, data loss/security breakage, unusable workflow.
|
||||
- `prio:p1`: high impact, common workflow broken, fix soon.
|
||||
- `prio:p2`: medium impact, workaround exists, not blocking most users.
|
||||
- `prio:p3`: low impact, edge case/minor issue.
|
||||
|
||||
**Functional labels** (pick all that apply):
|
||||
| Label | Signals |
|
||||
|---|---|
|
||||
| `agent` | Agent planning/execution loops, orchestration, runtime behavior |
|
||||
| `tool` | Tool contracts/behavior, tool call protocol, integration errors |
|
||||
| `tui` | Terminal UI rendering/layout/input/view state |
|
||||
| `cli` | CLI commands, args/flags, command routing |
|
||||
| `prompting` | System prompts/templates/prompt assembly behavior |
|
||||
| `sdk` | SDK or extension integration APIs/surfaces |
|
||||
| `auth` | Login, credentials, API keys, token/account management |
|
||||
| `setup` | Installation/bootstrap/environment setup issues |
|
||||
| `ux` | Workflow/ergonomics/usability improvements (non-rendering) |
|
||||
| `providers` | Provider-related behavior (generic provider scope) |
|
||||
**Functional**
|
||||
- `agent`: planning/execution loops, orchestration, runtime behavior.
|
||||
- `tool`: contracts/behavior, call protocol, integration errors.
|
||||
- `tui`: terminal UI rendering/layout/input/view state.
|
||||
- `cli`: commands, args/flags, routing.
|
||||
- `prompting`: system prompts/templates/assembly behavior.
|
||||
- `sdk`: SDK/extension integration APIs/surfaces.
|
||||
- `auth`: login, credentials, API keys, token/account management.
|
||||
- `setup`: installation/bootstrap/environment setup.
|
||||
- `ux`: non-rendering workflow/ergonomics/usability improvements.
|
||||
- `providers`: generic provider-related behavior.
|
||||
|
||||
**Provider labels** (apply only when a specific provider is explicitly involved):
|
||||
`provider:anthropic`, `provider:bedrock`, `provider:brave`, `provider:cerebras`, `provider:cloudflare`, `provider:codex`, `provider:copilot`, `provider:cursor`, `provider:exa`, `provider:gemini`, `provider:gitlab`, `provider:groq`, `provider:huggingface`, `provider:jina`, `provider:kimi`, `provider:litellm`, `provider:minimax`, `provider:mistral`, `provider:moonshot`, `provider:nanogpt`, `provider:novita`, `provider:nvidia`, `provider:openai`, `provider:opencode`, `provider:openrouter`, `provider:perplexity`, `provider:qianfan`, `provider:qwen`, `provider:synthetic`, `provider:together`, `provider:venice`, `provider:vercel`, `provider:xai`, `provider:xiaomi`, `provider:zai`
|
||||
**Providers** — specific provider explicitly involved only:
|
||||
`provider:anthropic`, `provider:bedrock`, `provider:brave`, `provider:cerebras`, `provider:cloudflare`, `provider:codex`, `provider:copilot`, `provider:cursor`, `provider:exa`, `provider:gemini`, `provider:gitlab`, `provider:groq`, `provider:huggingface`, `provider:jina`, `provider:kimi`, `provider:litellm`, `provider:minimax`, `provider:mistral`, `provider:moonshot`, `provider:nanogpt`, `provider:novita`, `provider:nvidia`, `provider:openai`, `provider:opencode`, `provider:openrouter`, `provider:perplexity`, `provider:qianfan`, `provider:qwen`, `provider:synthetic`, `provider:together`, `provider:venice`, `provider:vercel`, `provider:xai`, `provider:xiaomi`, `provider:zai`.
|
||||
|
||||
**Platform labels** (apply only when platform materially affects reproduction/root cause):
|
||||
| Label | Signals |
|
||||
|---|---|
|
||||
| `platform:linux` | Linux-specific behavior, distro/toolchain differences, Linux-only reproduction |
|
||||
| `platform:macos` | macOS-specific behavior (Homebrew/Darwin-specific) |
|
||||
| `platform:windows` | Native Windows behavior (PowerShell/cmd/Win32 specifics) |
|
||||
| `platform:wsl` | WSL-specific behavior (do not also apply linux/windows unless separately confirmed) |
|
||||
**Platforms** — only if material to reproduction/root cause:
|
||||
- `platform:linux`: Linux-specific behavior, distro/toolchain difference, Linux-only reproduction.
|
||||
- `platform:macos`: macOS-specific, including Homebrew/Darwin-specific.
|
||||
- `platform:windows`: native Windows, including PowerShell/cmd/Win32 specifics.
|
||||
- `platform:wsl`: WSL-specific; do not also apply linux/windows unless separately confirmed.
|
||||
|
||||
**Meta labels** (manual judgment only):
|
||||
| Label | Signals |
|
||||
|---|---|
|
||||
| `good first issue` | Well-scoped, self-contained, good for new contributors |
|
||||
| `help wanted` | Maintainers want community help |
|
||||
| `wontfix` | Intentional behavior or explicitly out of scope |
|
||||
**Meta** — manual judgment only:
|
||||
- `good first issue`: well-scoped, self-contained, suitable for new contributors.
|
||||
- `help wanted`: maintainers want community help.
|
||||
- `wontfix`: intentional behavior or explicitly out of scope.
|
||||
|
||||
### 4. Apply Labels
|
||||
### 4. Apply
|
||||
|
||||
For each issue, apply the chosen labels. **Never remove existing labels.**
|
||||
Do not add provider or platform labels without explicit evidence from issue body/comments.
|
||||
Apply chosen labels; NEVER remove existing labels. Provider/platform labels require explicit evidence from body/comments.
|
||||
|
||||
```bash
|
||||
gh issue edit <number> --add-label "bug,prio:p1,tool,providers,provider:openai"
|
||||
```
|
||||
|
||||
### 5. Print Summary
|
||||
### 5. Summary
|
||||
|
||||
After processing all issues, print a markdown summary table:
|
||||
After all issues, print:
|
||||
|
||||
```
|
||||
## Triage Summary
|
||||
|
||||
| # | Title | Added Labels | Skipped |
|
||||
|---|-------|-------------|---------|
|
||||
| 42 | Tool call stalls after retry | bug, prio:p1, agent, tool | |
|
||||
| 38 | Add provider fallback routing | proposal, providers, provider:exa | |
|
||||
| 35 | How to configure API key rotation | question, auth, providers, provider:minimax | |
|
||||
| 30 | Existing labels complete | | Already labeled |
|
||||
|#|Title|Added Labels|Skipped|
|
||||
|---|---|---|---|
|
||||
|42|Tool call stalls after retry|bug, prio:p1, agent, tool||
|
||||
|38|Add provider fallback routing|proposal, providers, provider:exa||
|
||||
|35|How to configure API key rotation|question, auth, providers, provider:minimax||
|
||||
|30|Existing labels complete||Already labeled|
|
||||
```
|
||||
|
||||
Include counts at the end: `Processed: X | Labeled: Y | Skipped: Z`
|
||||
Then: `Processed: X | Labeled: Y | Skipped: Z`
|
||||
|
||||
## Classification Tips
|
||||
## Rules
|
||||
|
||||
- Do not apply `platform:*` unless platform-specific behavior is explicit or reproduced as platform-bound.
|
||||
- Do not apply `providers` or any `provider:*` label unless provider scope is explicit.
|
||||
- If a specific provider is named, add both `providers` and the matching `provider:*` label.
|
||||
- WSL issues get `platform:wsl` — not `platform:linux` or `platform:windows` unless separately confirmed.
|
||||
- Don't apply `good first issue` or `help wanted` during automated triage — those require maintainer judgment.
|
||||
- If body is sparse, comments decide classification; do not skip before reading them all.
|
||||
- `platform:*`: only explicit platform-specific or platform-bound reproduced behavior.
|
||||
- `providers`/`provider:*`: only explicit provider scope. Named provider → both `providers` and matching `provider:*`.
|
||||
- WSL → `platform:wsl`, not `platform:linux`/`platform:windows` unless separately confirmed.
|
||||
- Automated triage: do not apply `good first issue` or `help wanted`; maintainer judgment required.
|
||||
- Sparse body → classify from all comments; do not skip before reading them.
|
||||
|
||||
@@ -1,66 +1,142 @@
|
||||
---
|
||||
name: semantic-compression
|
||||
description: Aggressively remove grammatical scaffolding LLMs reconstruct while preserving meaning-carrying content. Output may be fragments. Use when compressing text for prompts, reducing token count, preparing context for LLM input, or making documentation more token-efficient. Applies LLM-aware compression rules that delete predictable grammar while preserving semantics.
|
||||
description: Re-encode verbose prose into a dense telegraphic register — punctuation as connectives, label frames, verbless assertions — without losing normativity or precision. Use when compressing system prompts, tool/function descriptions, skill bodies, or agent instructions; reducing token count or context bloat; making documentation token-efficient for LLM input; or rewriting text in compressed notation.
|
||||
---
|
||||
|
||||
# Semantic Compression
|
||||
|
||||
LLMs reconstruct grammar from content words. Remove predictable glue; keep semantic payload. Prefer fragments over sentences.
|
||||
Compression is **re-encoding, not word deletion**. Filtering function words out of an English sentence leaves a damaged English sentence (`System design: efficient process incoming data, multiple sources`). Instead re-frame each claim in a register whose grammar is punctuation and layout — then the function words have no work left and drop out on their own.
|
||||
|
||||
## Aggressive Stance
|
||||
Target texts are load-bearing: tool descriptions, system prompts, skills. A model executes them cold, with no author present to disambiguate. Compression that forces a guess is a bug, not a saving.
|
||||
|
||||
- Output can be noun/verb stacks, list fragments, or label:value phrases.
|
||||
- Default to deletion; keep function words only when loss changes meaning.
|
||||
- Prefer base verb forms; drop tense/aspect unless timeline is critical.
|
||||
## Procedure
|
||||
|
||||
## Deletion Tiers
|
||||
0. **Density gate — check before touching anything.** Two signals, in order: (a) are articles and copulas already near-absent? (b) compress one representative section and measure the token delta. Already in this register (house-style prompt, tool doc, spec) or delta under ~10%? **STOP. Report that it is already dense and keep the original.** Bullet length alone is a weak signal — API literals and enumerations inflate it. Measured on a real house-style tool prompt: 853 → 778 tokens (8.8%), while that pass silently dropped a `NEVER assume …` rule, a throw condition, and a `full-res` detail. On already-dense text the remaining words *are* the payload, and the expected saving is smaller than the expected loss.
|
||||
1. **Split** the source into atomic claims: one definition, obligation, default, or fact each.
|
||||
2. **Inventory the payload first, before deleting anything.** List every load-bearing token: identifiers, error/exception names, throw conditions, defaults with their units, bounds, and every MUST/NEVER/PREFER line. Anything you then drop is a loss you declare deliberately rather than discover later.
|
||||
3. **Cut what the model already knows.** "JSON is a text format", "tests catch regressions" → delete. Keep only what is specific to this tool, repo, or domain.
|
||||
4. **Cut restatements.** Merge every duplicate of one rule into a single canonical line, placed where it is needed. Two statements of one rule with *different scope* are not duplicates.
|
||||
5. **Frame each claim** — definition · obligation · default · condition→consequence · enumeration · verdict. The frame picks the construction.
|
||||
6. **Hoist repeated qualifiers** into one scope line: three mentions of "relative to the repo root" → `All paths repo-relative.` once, up top.
|
||||
7. **Re-encode**, then run Verification.
|
||||
|
||||
**Tier 1 — Always delete (even if fragments):**
|
||||
- Articles: a, an, the
|
||||
- Copulas: is, are, was, were, am, be, been, being
|
||||
- Expletive subjects: "There is/are...", "It is..."
|
||||
- Complementizer: that (as clause marker)
|
||||
- Pure intensifiers: very, quite, rather, really, extremely, somewhat
|
||||
- Filler phrases: "in order to" → to, "due to the fact that" → because, "in terms of" → delete
|
||||
- Infinitive "to" before verbs (unless it prevents noun/verb confusion)
|
||||
- Conjunctions when list/contrast obvious: and, or, but
|
||||
## Frames
|
||||
|
||||
**Tier 2 — Delete unless meaning changes:**
|
||||
- Auxiliary verbs: have/has/had, do/does/did, will/would (keep if tense/aspect matters)
|
||||
- Modal verbs: can/could/may/might/should (keep when obligation/permission/possibility is critical; always keep must/must not)
|
||||
- Pronouns: it/this/that/these/those/he/she/they (drop when referent obvious; replace with noun if ambiguous)
|
||||
- Relative pronouns: which, that, who, whom
|
||||
- Prepositions: of, for, to, in, on, at, by (keep for material, direction, agency, or disambiguation)
|
||||
| frame | English | compressed |
|
||||
|---|---|---|
|
||||
| definition | "The `name` field is the stable launch identifier." | `name: stable launch id.` |
|
||||
| obligation | "You must call open before you can run code." | `MUST open before run.` |
|
||||
| default | "If no value is given, the timeout defaults to 30 seconds." | `Default 30s.` |
|
||||
| condition→consequence | "Because navigation re-renders the page, refs become stale, so you should snapshot again." | `Navigation invalidates refs → re-snapshot.` |
|
||||
| property chain | "z' is an integer because z divides x²+y², and it is positive because x²+y²>0." | `z' integer since z divides x²+y²; positive since x²+y²>0.` |
|
||||
| enumeration | "The action may be open, close, or run." | `action: open, close, run.` |
|
||||
| exclusion | "any triple that is neither (1,1,1) nor (1,1,2)" | `triple ≠ (1,1,1),(1,1,2)` |
|
||||
| verdict | "Claim A is true, and claim B is false as stated." | `A true; B false as stated.` |
|
||||
| precondition | "This requires that the branch has already been checked out." | `Requires prior checkout.` |
|
||||
|
||||
**Tier 3 — Delete only if relation still clear:**
|
||||
- Remaining prepositions: with/without, between/among, within, after/before, over/under, through (drop only if relation obvious)
|
||||
- Redundant adverbs: "shout loudly" → "shout"
|
||||
Constructions behind them:
|
||||
|
||||
## Always Preserve
|
||||
- **Verbless assertion** — `X true` / `X false` / `X required` / `X unsupported`. Copula deleted; the predicate carries.
|
||||
- **Label frame** — `X: value` for "the X is / means / consists of". One colon per line, never nested.
|
||||
- **Subject elision across a run** — name the subject once, chain bare predicates: `Integer since …; positive since …; unique.`
|
||||
- **Asyndeton** — parallel items, no conjunction: `articles, copulas, expletives`.
|
||||
- **Scope declaration** — one line retypes everything after it: `All paths repo-relative.` · `Times in ms.` · `All congruences mod 4.`
|
||||
- **Lazy specification** — state only enough to decide: `3·13·34-1 big` (over the bound; exact value irrelevant). Name the bound somewhere the reader can see it.
|
||||
- **Metonymy** — an object stands for the proposition about it: `y=z implies (1,1,1)`. Only where exactly one reading exists.
|
||||
|
||||
- Nouns, main verbs, meaning-bearing adjectives/adverbs
|
||||
- Numbers, quantifiers: "at least 5", "approximately", "more than"
|
||||
- Uncertainty markers: "appears", "seems", "reportedly", "what sounded like"
|
||||
- Negation: not, no, never, without, none
|
||||
- Temporal markers: dates, frequencies, durations
|
||||
- Causality and conditionals: because, therefore, despite, although, if, unless
|
||||
- Requirements/permissions: must, required, prohibited, allowed
|
||||
- Proper nouns, titles, technical terms
|
||||
- Prepositions encoding relationships: from/to (direction), with/without (inclusion), between/among/within (relation), after/before (temporal), by (agent if passive)
|
||||
## Operators
|
||||
|
||||
## Structural Compression
|
||||
Punctuation carries the connective:
|
||||
|
||||
- Passive → active when agent known: "was eaten by dog" → "dog ate"
|
||||
- Nominalization → verb: "made a decision" → "decided"
|
||||
- Drop implied subject when context allows: "System should log errors" → "Log errors"
|
||||
- Redundant pairs → single: "each and every" → "every"
|
||||
- Clause → modifier: "anomaly that was reported" → "reported anomaly"
|
||||
- `:` — announce, name, define ("is", "means", "the following")
|
||||
- `→` — yields, produces, becomes ("which results in")
|
||||
- `⇒` — therefore, concludes
|
||||
- `—` — gloss, or "therefore"
|
||||
- `/` — equivalently, i.e.
|
||||
- `;` — next step, same topic ("Then,", "After that,")
|
||||
- `,` — inference chain ("and so")
|
||||
- `≠` — neither/nor, distributed over a list
|
||||
- `✓` — verified, obligation discharged
|
||||
- `>` — precedence ("arg > env > default")
|
||||
- `|` — alternatives within an enum ("open | close | run")
|
||||
|
||||
## Examples
|
||||
Ambiguity is the only disqualifier, never unfamiliarity. Where a glyph takes a second reading *in its slot* — `—` as a parenthetical dash, `/` as a path separator or "per", `,` as a list comma — write the word instead.
|
||||
|
||||
| Original | Compressed |
|
||||
|----------|------------|
|
||||
| The system was designed to efficiently process incoming data from multiple sources | System design: efficient process incoming data, multiple sources |
|
||||
| There were at least 20 people who appeared to be waiting | At least 20 people apparent waiting |
|
||||
| It is important to note that the medication should not be taken without food | Medication: should not take without food |
|
||||
| The researcher made a decision to investigate the anomaly that was reported | Researcher decided: investigate reported anomaly |
|
||||
**Symbols do not save tokens; structure does.** Measured (cl100k_base; Claude's tokenizer differs, but BPE arity for rare glyphs is similar): `→` `⇒` `≤` `·` `✓` cost 1 token each, `≡` costs 2, ` -> ` costs 2, and ` gives` costs 1. So a one-for-one word→glyph swap saves nothing and costs clarity. Substitute a glyph only where it eats a *multi-word phrase*. Superscripts do pay: `x²+y²` = 4 tokens, `x^2+y^2` = 6.
|
||||
|
||||
Never invent private glyphs — a bespoke one needs a legend that costs more than it saves.
|
||||
|
||||
## Deletion
|
||||
|
||||
**Always delete:** articles; copulas (is/are/was/be/been); expletive there/it; complementizer `that`; relative pronouns; intensifiers (very, quite, really, extremely); filler ("in order to"→to, "due to the fact that"→because, "it is important to note that"→∅, "in terms of"→∅); politeness ("please", "feel free to"); hedged framing ("you may want to consider").
|
||||
|
||||
**Delete unless load-bearing:** auxiliaries (have/do/will); pronouns with an obvious referent; prepositions of/for/to/in/on/at/by; conjunctions where the list is obvious; adverbs already implied by the verb ("shout loudly").
|
||||
|
||||
**Never delete — this is the payload:**
|
||||
|
||||
- Normative modals: MUST, NEVER, SHOULD, MAY. The RFC 2119 word *is* the instruction.
|
||||
- Negation and exception: not, no, never, without, none, except, unless.
|
||||
- Numbers, units, bounds, quantifiers: "at least 5", "≤100", "max 1 MiB", "1-indexed".
|
||||
- Conditionals and causality: if, unless, because, since, so.
|
||||
- True hedges: "approximately", "usually", "appears" — deleting one asserts certainty the source did not have.
|
||||
- Exact strings: identifiers, API names, flags, paths, regexes, format literals, error text.
|
||||
- Examples that demonstrate a shape. Compressing an example destroys the thing it demonstrates.
|
||||
- Prepositions where the relation flips meaning: "read from X" ≠ "read to X".
|
||||
- Throw/failure conditions, and warnings about silent failure ("never assume it landed because no error appeared"). They read like padding and are behavioral.
|
||||
- Scar tissue: a line that exists because someone already made that mistake. It looks redundant *because* it now prevents the error. `git blame` before cutting anything that looks obvious.
|
||||
|
||||
## Private register — never ship
|
||||
|
||||
The scratchpad style that generates this register carries features that work only while writer and reader are the same person, minutes apart. Strip all of them:
|
||||
|
||||
- **External deixis** — `A`, `B`, `C`, `G`, "the equation", "the claim above". Shipped text is self-contained: name the thing.
|
||||
- **Scratchpad residue** — `Hmm`, `Actually`, `Wait`, `just`, `fine`, `Good`; goals revised mid-line; abandoned clauses.
|
||||
- **Layered corrections** — a wrong value left standing beside its fix. A cold reader cannot tell which pass won. Delete the loser.
|
||||
- **Dead branches** — an abandoned approach left beside the chosen one. A model may execute the abandoned one.
|
||||
- **Ambiguous `...` and `?`** — in notes they mean omitted / abandoned / infinite, and conjecture / check-this. In shipped text they mean nothing. Drop both.
|
||||
- **Nested colons** — `Step: from X: cases: a,b=1: 3-c:` is unparseable cold. One colon per line.
|
||||
- **Unmarked instruction vs data** — a bare line like `Word limit 1200 - write concisely` sitting in content is indistinguishable from content. Keep instructions in a marked channel: heading, tag, or MUST line.
|
||||
- **Revisiting instead of rewriting** — fine while thinking, fatal in a prompt. One canonical statement per rule.
|
||||
|
||||
## Tool and skill descriptions
|
||||
|
||||
The body compresses hard. The trigger does not.
|
||||
|
||||
- A tool's or skill's `description` field is **retrieval surface**, not documentation: it is matched against the user's own phrasing. Keep natural, keyword-redundant alternatives ("compress prompt", "reduce token count", "token-efficient") even though a reader needs only one. Compress the body; NEVER compress the trigger.
|
||||
- Params — drop type, enum, or default from the prose ONLY when the *wire* schema the model actually sees exposes it, and (if you ran the `tool-prompt-optimization` probe) the probe recovered it from schema alone. Otherwise keep it. **Defaults are the trap:** wire schemas frequently omit `default` entirely, and even when present it carries no direction or semantics — `gitignore: true` does not say "respects gitignore" — which is why `tool-prompt-optimization` classes defaults-and-their-direction as content no model recovers. Absent that evidence, preserve the default, its unit, and any precedence rule (arg > env > default). Prose always keeps what no schema can express: interaction, precedence, failure mode.
|
||||
- Imperative for actions (`open before run`); label frames for facts (`Default 30s.`).
|
||||
- Scope split — this skill owns the *re-encoding mechanics* only. What belongs in a tool prompt at all (anatomy, surface-not-machinery, what stays out) → `tool-prompt-optimization`, which also measures schema/prose overlap before you cut. House style (tag vocabulary, RFC 2119 keywords, positioning) → `system-prompts`. Compress after those two have decided *what* ships.
|
||||
|
||||
## Worked example
|
||||
|
||||
Source (55 words, 63 tok):
|
||||
|
||||
> The `timeout` parameter controls how long the tool will wait for the process to become ready. If you do not provide a value, it defaults to 30 seconds. Note that if you have specified both a log pattern and a port, then both of these conditions must be satisfied before the process is considered ready.
|
||||
|
||||
Compressed (14 words, 20 tok):
|
||||
|
||||
> `Readiness timeout: default 30s. Log pattern + port both supplied ⇒ BOTH must pass.`
|
||||
|
||||
Rejected as over-compressed — `timeout 30 log+port both`: loses the unit, loses that 30 is a *default* rather than a fixed value, loses the obligation, and leaves `both` dangling.
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Declare every loss, then judge the draft against that list.** Name each dropped claim, qualifier, default, example, or exact string, and why the text is still correct without it. A declared loss is a decision a reader can audit; an undeclared one is a silent regression. Review with the list in front of you, not from memory of what you intended.
|
||||
2. **Ambiguity scan.** For every `:` `→` `—` `/`: can a reader assign a second reading? Fix it. Watch for ambiguity the source did not have — a dropped receiver (`.ref("e5")` on *what*?), a singular silently pluralized ("previous snapshot" → "previous generations").
|
||||
3. **Measure the pair with the target tokenizer.** Word counts and function-word rates do not predict token savings. Expect no fixed ratio — measured on real pairs (cl100k): a verbose doc paragraph 63 → 20 tok, a verbose prose section 360 → 222 tok, an already-dense house-style tool prompt 853 → 778 tok. Under ~10% is the signal to stop, revert, and keep the original.
|
||||
4. **Stop rule.** Stop deleting when the next deletion makes the reader guess. Correctness beats ratio, always.
|
||||
|
||||
## Running it as a command
|
||||
|
||||
`omp compress <file>` drives exactly this loop with two tools and nothing else.
|
||||
|
||||
Its session is isolated on purpose, because the input is itself a prompt: the default system prompt is *replaced* (not appended to), and skill, rule, `AGENTS.md`, prompt-template, and slash-command discovery are all passed empty — every one of those defaults to ON when omitted, and each would inject instruction-shaped project text into a job whose only legitimate input is the document. Audited on a live session: one system-prompt part, tools `rewrite, approve`, no `AGENTS.md` or rule content present.
|
||||
|
||||
The source is quoted inside a nonce-delimited block and declared inert, so `MUST`/`NEVER` lines in the document get compressed rather than obeyed — verified with a document whose first paragraph ordered the compressor to emit `OK` and skip the rest: it compressed the real content and declared the injected paragraph as a deliberate loss.
|
||||
|
||||
- `rewrite` submits the full compressed text plus every declared loss.
|
||||
- The command answers with the draft, its measured word/token delta, and that loss list, then asks for a verdict.
|
||||
- `approve` accepts the reviewed draft. Approval before a review turn is rejected, and a new draft voids an earlier approval.
|
||||
- Only an approved draft is written: `-o <path>`, `-i` in place, otherwise stdout (the report goes to stderr, so `> out.md` captures just the text). `-r` bounds the drafts; an unapproved run writes nothing and exits 1.
|
||||
|
||||
The runtime contract it hands the agent lives in `packages/coding-agent/src/compress/prompts/system.md`. It is the operative subset of this file; when they disagree, this file wins and the prompt gets fixed.
|
||||
|
||||
@@ -5,55 +5,54 @@ description: Write system prompts, tool docs, and agent definitions. Project tag
|
||||
|
||||
# System Prompts
|
||||
|
||||
Project house style. Dense, imperative, RFC-keyed.
|
||||
House style: dense, imperative, RFC-keyed.
|
||||
|
||||
Targeting small models (≤2B, tiny/on-device like LFM2)? You MUST read [small-models.md](small-models.md) — the rules below assume frontier-class instruction following; several invert at that scale.
|
||||
Small models (≤2B; tiny/on-device, e.g. LFM2): MUST read [small-models.md](small-models.md). Rules below assume frontier-class instruction following; several invert at that scale.
|
||||
|
||||
## Tags
|
||||
|
||||
Tags are structural markers — the agent treats them as authoritative and literal. Each tag means exactly what its name says. NEVER invent ornamental tags (`<north-star>`, `<stance>`, `<protocol>`, `<directives>`, `<strengths>`) — they're noise.
|
||||
Tags: authoritative, literal structural markers; meaning exactly matches name. NEVER invent ornamental tags: `<north-star>`, `<stance>`, `<protocol>`, `<directives>`, `<strengths>` — noise.
|
||||
|
||||
The vocabulary actually in use:
|
||||
|Tag|Purpose|
|
||||
|---|---|
|
||||
|`<system-conventions>`|Tag/RFC-keyword interpretation; contract.|
|
||||
|`<stakes>`|Correctness importance; domain framing.|
|
||||
|`<communication>`|Voice, tone, response shape.|
|
||||
|`<critical>`|Inviolable rules; place at START and END.|
|
||||
|`<completeness>`|Done definition; anti-shrink rules.|
|
||||
|`<yielding>`|Pre-yield checklist; block conditions.|
|
||||
|`<workflow>`|Numbered phases: scope → edit → decompose → work → verify.|
|
||||
|
||||
| Tag | Purpose |
|
||||
| --- | --- |
|
||||
| `<system-conventions>` | How to interpret tags + RFC keywords themselves. Defines the contract. |
|
||||
| `<stakes>` | Why correctness matters here. Domain framing. |
|
||||
| `<communication>` | Voice, tone, response shape. |
|
||||
| `<critical>` | Inviolable rules. Place at START and END. |
|
||||
| `<completeness>` | What "done" means. Anti-shrink rules. |
|
||||
| `<yielding>` | Pre-yield checklist. Block conditions. |
|
||||
| `<workflow>` | Numbered phases (scope → edit → decompose → work → verify). |
|
||||
## Normative Language
|
||||
|
||||
RFC 2119 in full caps, no bold. The all-caps form IS the marker.
|
||||
RFC 2119: full caps, no bold; all-caps form is the marker.
|
||||
|
||||
| Keyword | Meaning | Replaces |
|
||||
| --- | --- | --- |
|
||||
| MUST / REQUIRED | Absolute requirement | "always", "make sure", "ensure" |
|
||||
| NEVER (= MUST NOT) | Absolute prohibition | "do not", "don't" |
|
||||
| SHOULD / RECOMMENDED | Strong preference; deviation allowed with known tradeoffs | "prefer", "it's best to" |
|
||||
| AVOID (= SHOULD NOT) | Strong discouragement | "try not to" |
|
||||
| MAY / OPTIONAL | Truly optional | "can", "you could" |
|
||||
|Keyword|Meaning|Replaces|
|
||||
|---|---|---|
|
||||
|MUST / REQUIRED|Absolute requirement|"always", "make sure", "ensure"|
|
||||
|NEVER (= MUST NOT)|Absolute prohibition|"do not", "don't"|
|
||||
|SHOULD / RECOMMENDED|Strong preference; known-tradeoff deviation allowed|"prefer", "it's best to"|
|
||||
|AVOID (= SHOULD NOT)|Strong discouragement|"try not to"|
|
||||
|MAY / OPTIONAL|Truly optional|"can", "you could"|
|
||||
|
||||
**Project aliases**: prefer `NEVER` over `MUST NOT` and `AVOID` over `SHOULD NOT`. Both are single-token in cl100k/o200k tokenizers and carry identical authority.
|
||||
Aliases: prefer `NEVER` to `MUST NOT`; `AVOID` to `SHOULD NOT`. Both: single-token in cl100k/o200k; identical authority.
|
||||
|
||||
State the alias contract once, near the top, inside `<system-conventions>`:
|
||||
Near top, inside `<system-conventions>`, state once:
|
||||
|
||||
> RFC 2119 applies to MUST, REQUIRED, SHOULD, RECOMMENDED, MAY, OPTIONAL. `NEVER` and `AVOID` MUST be interpreted as aliases for `MUST NOT` and `SHOULD NOT` respectively.
|
||||
|
||||
NEVER convert: factual descriptions (what a tool returns, what a parameter does), code blocks, examples, schema, Handlebars template syntax.
|
||||
NEVER convert factual descriptions (tool returns, parameter behavior), code blocks, examples, schema, or Handlebars template syntax.
|
||||
|
||||
## Density
|
||||
|
||||
Strip prose to load-bearing tokens. A bullet earns its words by saying something the prior bullet didn't.
|
||||
Load-bearing tokens only; every bullet adds a claim.
|
||||
|
||||
- One claim per bullet. Sub-clauses that don't change behavior get cut.
|
||||
- Replace "If X, then Y" with `X? Y.` when X is a quick check.
|
||||
- Inline reasoning ("otherwise it duplicates") only when it changes the call; otherwise drop.
|
||||
- The bolded lead names the rule — NEVER restate it in the body.
|
||||
- Symbols beat words: `→`, `=`, `+`/`<`/`-`, `B+1`, `A..B`.
|
||||
- Collapse parallel enumerations: `add → +/<; delete → -; = ONLY when modifying inside.`
|
||||
- One claim/bullet; cut behavior-neutral subclauses.
|
||||
- Quick check `X? Y.` replaces “If X, then Y.”
|
||||
- Reasoning ONLY when it changes the call.
|
||||
- Bold lead names rule; NEVER restate in body.
|
||||
- Prefer `→`, `=`, `+`/`<`/`-`, `B+1`, `A..B`.
|
||||
- Parallel edits: `add → +/<; delete → -; = ONLY when modifying inside.`
|
||||
|
||||
```
|
||||
Bad: - **Never fabricate anchor hashes.** Hashes are 2-letter content fingerprints, not arbitrary suffixes. You cannot increment them, guess the "next" one, or compute them locally. If a needed anchor is not in your last `read` output, issue another `read`.
|
||||
@@ -63,13 +62,13 @@ Bad: - **Do not replay the line past your range.** For `= A..B`, never end the
|
||||
Good: - **NEVER replay past your range.** Stop before B+1; extend B if it must go.
|
||||
```
|
||||
|
||||
Target: **5–12 words per tactical bullet.** Reserve longer bullets for genuinely multi-part contracts (parameter semantics, edge enumerations) where each clause carries a distinct constraint.
|
||||
Tactical bullets: 5–12 words. Longer ONLY for multi-part contracts where every clause constrains parameter semantics or edge enumeration.
|
||||
|
||||
AVOID compressing: factual reference (operator definitions, return formats, schema), worked examples (the example IS the explanation), the first occurrence of a non-obvious term.
|
||||
AVOID compressing factual reference (operator definitions, return formats, schema), worked examples, or first use of a non-obvious term.
|
||||
|
||||
## Voice
|
||||
|
||||
Direct, imperative, second-person. "You MUST", "You NEVER", "You SHOULD". No hedging, no apology, no ceremony.
|
||||
Direct, imperative, second-person: “You MUST/NEVER/SHOULD.” No hedging, apology, ceremony, closing summaries, or time estimates.
|
||||
|
||||
```
|
||||
Bad: "You might want to consider using X..."
|
||||
@@ -82,29 +81,27 @@ Bad: "Make sure to run lsp references before modifying a symbol"
|
||||
Good: "You MUST run `lsp references` before modifying any exported symbol."
|
||||
```
|
||||
|
||||
Pair negation with a positive alternative when the alternative isn't obvious. Otherwise `NEVER X.` stands alone.
|
||||
Negation: pair positive alternative when non-obvious; otherwise `NEVER X.` alone.
|
||||
|
||||
## Positioning
|
||||
|
||||
"Lost in the Middle": start and end retain; middle degrades ~20%. Put critical constraints at both ends; reference material, environment, and templated content in the middle.
|
||||
“Lost in the Middle”: start/end retain; middle degrades ~20%. Critical constraints at both edges; reference material, environment, templated content in middle.
|
||||
|
||||
Front matter, in order:
|
||||
|
||||
1. Role + agency one-liner ("You are THE staff engineer…")
|
||||
2. `<system-conventions>` — RFC contract, tag semantics
|
||||
3. `<stakes>` — why this matters
|
||||
4. `<communication>` — style
|
||||
5. `<critical>` — top-priority rules
|
||||
|
||||
Back matter, in order:
|
||||
Front matter:
|
||||
1. Role + agency one-liner (`You are THE staff engineer…`).
|
||||
2. `<system-conventions>` — RFC contract, tag semantics.
|
||||
3. `<stakes>` — importance.
|
||||
4. `<communication>` — style.
|
||||
5. `<critical>` — top-priority rules.
|
||||
|
||||
Back matter:
|
||||
1. Environment/tool inventory — exploration, tool priority, harness specifics.
|
||||
2. Contract — completeness, yielding, workflow.
|
||||
3. Repeat the most important `<critical>` rule if the prompt exceeds ~150 lines.
|
||||
3. Prompt >~150 lines: repeat most important `<critical>` rule.
|
||||
|
||||
## Tone Patterns That Work
|
||||
|
||||
From the live system prompt:
|
||||
Live-system-prompt patterns:
|
||||
|
||||
- **Agency**: "You have agency and taste: you delete code that isn't pulling its weight, refuse abstractions that are unnecessary, and prefer boring when it's called for."
|
||||
- **Stakes anchoring**: "Tests you didn't write: bugs shipped. Assumptions you didn't validate: incidents to debug."
|
||||
@@ -114,72 +111,72 @@ From the live system prompt:
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Pattern | Problem |
|
||||
| --- | --- |
|
||||
| Politeness padding ("Would you be so kind…") | +perplexity, −accuracy |
|
||||
| Bribes ("I'll tip $2000") | No improvement, sometimes worse |
|
||||
| Few-shot on advanced models + clear task | Introduces noise/bias |
|
||||
| Explicit CoT on reasoning models (o1/o3) | Conflicts with internal reasoning |
|
||||
| "Be efficient with tokens" | Triggers premature task abandonment |
|
||||
| "Don't do X" with no alternative | "Always do Y" processes better |
|
||||
| Self-critique without external feedback | Detection is the bottleneck, not correction |
|
||||
| Critical instructions only in the middle | 20%+ degradation vs edges |
|
||||
| Restating the bolded lead in the body | Wastes tokens, signals AI padding |
|
||||
| Inventing tags for emphasis | Tags carry semantics; ornament dilutes them |
|
||||
| Lowercase rfc keywords | The all-caps form IS the marker; lowercase reads as ordinary prose |
|
||||
|Pattern|Problem|
|
||||
|---|---|
|
||||
|Politeness padding (`"Would you be so kind…"`)|+perplexity, −accuracy|
|
||||
|Bribes (`"I'll tip $2000"`)|No improvement; sometimes worse|
|
||||
|Few-shot on advanced models + clear task|Noise/bias|
|
||||
|Explicit CoT on reasoning models (o1/o3)|Conflicts with internal reasoning|
|
||||
|`"Be efficient with tokens"`|Premature task abandonment|
|
||||
|`"Don't do X"` without alternative|`"Always do Y"` processes better|
|
||||
|Self-critique without external feedback|Detection bottleneck, not correction|
|
||||
|Critical instructions only in middle|20%+ degradation vs edges|
|
||||
|Restating bold lead in body|Token waste; AI-padding signal|
|
||||
|Inventing emphasis tags|Tags have semantics; ornament dilutes|
|
||||
|Lowercase RFC keywords|All-caps is marker; lowercase ordinary prose|
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Tags match real content semantics; no ornamental tags.
|
||||
- [ ] `<system-conventions>` defines the RFC alias contract (NEVER, AVOID).
|
||||
- [ ] Critical rules appear at START and END.
|
||||
- [ ] All prescriptive prose uses RFC 2119 keywords in caps.
|
||||
- [ ] Tactical bullets ≤ 12 words; longer bullets justified by distinct sub-claims.
|
||||
- [ ] Bolded leads not restated in body.
|
||||
- [ ] Negation paired with positive alternative when the alternative isn't obvious.
|
||||
- [ ] Verification path named (tests, lint, typecheck) — never "review your work".
|
||||
- [ ] Persistence framing for complex tasks ("keep going until complete").
|
||||
- [ ] No hedging, no ceremony, no closing summaries, no time estimates.
|
||||
- Tags match content; no ornamental tags.
|
||||
- `<system-conventions>` defines `NEVER`/`AVOID` aliases.
|
||||
- Critical rules at START and END.
|
||||
- Prescriptive prose: uppercase RFC 2119 keywords.
|
||||
- Tactical bullets ≤12 words unless distinct subclaims justify more.
|
||||
- NEVER restate bold lead in body.
|
||||
- Non-obvious negation gets positive alternative.
|
||||
- Name verification path (tests, lint, typecheck); NEVER “review your work”.
|
||||
- Complex tasks: persist until complete.
|
||||
- No hedging, ceremony, closing summaries, time estimates.
|
||||
|
||||
## Tool Prompt Authoring
|
||||
|
||||
Tool prompts are not API docs. They teach the agent **when to reach for the tool, what shape its inputs take, and which failure modes are the agent's responsibility**. Everything else — engine internals, recovery heuristics, fallback chains, performance tuning — stays in code.
|
||||
Tool prompts teach when to use the tool, input shape, and agent-owned failures — not API docs. Engine internals, recovery heuristics, fallback chains, performance tuning: code.
|
||||
|
||||
### Describe surface, not machinery
|
||||
### Surface, not machinery
|
||||
|
||||
The agent picks tools from prose, not source. Tell it WHEN and WHY; NEVER HOW the tool works internally.
|
||||
Agents choose tools from prose: state WHEN/WHY; NEVER internal HOW.
|
||||
|
||||
- `read.md` enumerates every source it covers (file/dir/archive/sqlite/PDF/URL) so the agent stops reaching for `cat`/`curl`/`tar`. It does NOT mention the chunker, the binary sniffer, or the cache layer.
|
||||
- `lsp.md`: "You MUST use `lsp` whenever a language server is available — safer than text-based alternatives." No mention of the LSP wire protocol, server lifecycle, or capability negotiation.
|
||||
- `ast_edit`: teaches metavariable syntax + workflow ("Loosest existence check: `pat: 'executeBash'` with narrow paths"). Does NOT explain the AST engine, query compilation, or tree-sitter grammar selection.
|
||||
- `hashline.md` (this repo): teaches the **patch grammar** (anchors, ops, payloads, ranges) and the **edit shapes** that succeed. Hides `tryRecoverHashlineWithCache`, the fuzz factor, the bigram tables, `findUniqueSuffixMatch`, `untilAborted`, `formatGroupedFiles`. The agent never learns those names — it just sees "the tool resolved your typo" or "the anchor was stale, re-read".
|
||||
- `read.md`: enumerate every covered source — file/dir/archive/sqlite/PDF/URL — so agent avoids `cat`/`curl`/`tar`; omit chunker, binary sniffer, cache layer.
|
||||
- `lsp.md`: "You MUST use `lsp` whenever a language server is available — safer than text-based alternatives." Omit LSP wire protocol, server lifecycle, capability negotiation.
|
||||
- `ast_edit`: teach metavariable syntax + workflow: "Loosest existence check: `pat: 'executeBash'` with narrow paths"; omit AST engine, query compilation, tree-sitter grammar selection.
|
||||
- `hashline.md` (this repo): teach **patch grammar** — anchors, ops, payloads, ranges — and successful **edit shapes**. NEVER expose `tryRecoverHashlineWithCache`, fuzz factor, bigram tables, `findUniqueSuffixMatch`, `untilAborted`, `formatGroupedFiles`; agent sees only "the tool resolved your typo" or "the anchor was stale, re-read".
|
||||
|
||||
If the agent's behavior shouldn't change based on a detail, the detail does NOT belong in the prompt. Each sentence MUST shift a decision the agent makes.
|
||||
Behavior-invariant detail: exclude. Every sentence MUST shift an agent decision.
|
||||
|
||||
### Anatomy of a good tool prompt
|
||||
### Good tool-prompt anatomy
|
||||
|
||||
1. **One-line purpose.** What problem it solves, in the agent's vocabulary. Not "wraps libfoo with X" — instead "compact, line-anchored edit format".
|
||||
2. **Input grammar / surface.** Operators, parameters, selectors. Concrete syntax the agent will emit verbatim.
|
||||
3. **Worked examples.** 3–8 patterns covering the common shapes. Each example IS the explanation — don't narrate it twice.
|
||||
4. **Failure shapes the agent owns.** Things the agent can fix by changing its input (stale anchors, missing payload prefix, fabricated hash). Skip failures the engine recovers from silently.
|
||||
5. **Anti-patterns.** WRONG/RIGHT pairs for the mistakes that cost retries. Drawn from real failures, not imagined ones.
|
||||
6. **`<critical>` recap.** 3–6 lines of the load-bearing rules, in case the agent skips the body.
|
||||
1. **One-line purpose** — agent-vocabulary problem; e.g. “compact, line-anchored edit format”, not “wraps libfoo with X”.
|
||||
2. **Input grammar / surface** — operators, parameters, selectors; verbatim emitted syntax.
|
||||
3. **Worked examples** — 3–8 common shapes; each explains itself, no duplicate narration.
|
||||
4. **Agent-owned failure shapes** — input-fixable stale anchors, missing payload prefix, fabricated hash; skip silently recovered failures.
|
||||
5. **Anti-patterns** — real-failure WRONG/RIGHT pairs that cost retries; not imagined failures.
|
||||
6. **`<critical>` recap** — 3–6 load-bearing lines, for body-skipping agents.
|
||||
|
||||
### What stays out
|
||||
### Exclude
|
||||
|
||||
- Implementation file names, function names, module layout.
|
||||
- Implementation file/function names; module layout.
|
||||
- Recovery, retry, normalization, caching, fuzz matching.
|
||||
- Performance characteristics ("this is O(n)") unless they change the agent's strategy.
|
||||
- Telemetry, logging, debug flags, env vars the agent cannot set.
|
||||
- Version history, deprecated parameters, "previously this worked differently".
|
||||
- Cross-tool plumbing ("this calls `read` under the hood") unless the agent must coordinate them.
|
||||
- Performance (`O(n)`) unless strategy-changing.
|
||||
- Telemetry, logging, debug flags, unsettable env vars.
|
||||
- Version history, deprecated parameters, “previously this worked differently”.
|
||||
- Cross-tool plumbing (`this calls \`read\` under the hood`) unless coordination required.
|
||||
|
||||
### Examples drive the contract
|
||||
|
||||
Tool prompts lean on examples harder than agent prompts do. Reasons:
|
||||
Tool prompts rely on examples more than agent prompts:
|
||||
|
||||
- Syntax is mechanical — one correct example beats three paragraphs of grammar.
|
||||
- The model anchors output formatting on the most recent example it saw. Put the canonical shape last.
|
||||
- Anti-patterns matter: a WRONG example next to its RIGHT counterpart kills a whole class of retry.
|
||||
- Mechanical syntax: one correct example beats three grammar paragraphs.
|
||||
- Model anchors output format on latest example: canonical shape last.
|
||||
- Adjacent WRONG/RIGHT eliminates a retry class.
|
||||
|
||||
Examples MUST be runnable shape, not pseudo-code. If the tool takes JSON, the example is JSON. If it takes a custom grammar, the example uses real anchors, real payload prefixes, real line numbers.
|
||||
Examples MUST be runnable, not pseudo-code. JSON tool → JSON example; custom grammar → real anchors, payload prefixes, line numbers.
|
||||
|
||||
@@ -19,12 +19,12 @@ Shared prompts MUST be written for the smallest model that consumes them — big
|
||||
|
||||
The strongest format control never enters the prompt:
|
||||
|
||||
| Lever | Effect |
|
||||
| --- | --- |
|
||||
| Assistant prefill (`<title>`, `{"name": `) | Commits the model into the format; kills preamble failures |
|
||||
| Stop strings + token caps | Bound runaway output better than "be brief" |
|
||||
| Greedy decoding / temp ≤0.3 | Removes the format lottery (LFM2: temp 0.3, min_p 0.15, rep. penalty 1.05) |
|
||||
| Post-processing in code | Strips quotes/punctuation/stray tags regardless of what the model emits |
|
||||
|Lever|Effect|
|
||||
|---|---|
|
||||
|Assistant prefill (`<title>`, `{"name": `)|Commits the model into the format; kills preamble failures|
|
||||
|Stop strings + token caps|Bound runaway output better than "be brief"|
|
||||
|Greedy decoding / temp ≤0.3|Removes the format lottery (LFM2: temp 0.3, min_p 0.15, rep. penalty 1.05)|
|
||||
|Post-processing in code|Strips quotes/punctuation/stray tags regardless of what the model emits|
|
||||
|
||||
Code already neutralizes a failure mode? DELETE its rule. Each dropped rule buys headroom for the rules that matter.
|
||||
|
||||
|
||||
@@ -5,49 +5,47 @@ description: Optimize the description prompts an AI agent reads to learn its bui
|
||||
|
||||
# Tool Prompt Optimization
|
||||
|
||||
A tool's description prompt and its parameter schema overlap. Whatever a model can reconstruct from the **schema + tool name + a blank outline** is a *prune candidate* — the schema may already teach it. This skill measures that overlap so you prune with evidence, not vibes. A candidate is never an automatic delete (see caveats — history first).
|
||||
Prompt/schema overlap: content reconstructible from `(name, JSON schema, blank outline)` is a *prune candidate*, never an automatic delete. Probe this overlap for evidence, not vibes: predict the prompt body from those inputs. Reliably recovered lines: candidates; no-model recovery: load-bearing — keep.
|
||||
|
||||
Core move: give a model only `(name, JSON schema, outline)` and have it predict the prompt body. Lines it predicts reliably are *prune candidates*. Lines it never recovers are *load-bearing* — keep them.
|
||||
## Run probe
|
||||
|
||||
## Run the probe
|
||||
|
||||
`scripts/probe.ts` routes through `@oh-my-pi/pi-ai` (`completeSimple`) so model/auth/provider behavior matches production.
|
||||
`scripts/probe.ts`: `@oh-my-pi/pi-ai` `completeSimple`; production-matching model/auth/provider behavior.
|
||||
|
||||
```bash
|
||||
bun .omp/skills/tool-prompt-optimization/scripts/probe.ts \
|
||||
--schema <file|json> --template <file|text> --name <tool_name>
|
||||
```
|
||||
|
||||
- `--schema` and `--template` are the only required inputs (file path or inline value).
|
||||
- No `--model` → 3-model panel (`fireworks/kimi-k2.7-code`, `anthropic/claude-opus-4-8`, `openai/gpt-5.5`) × `--samples` (default 3). Needs `FIREWORKS_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`.
|
||||
- `--model p/id,p/id` overrides the panel; `--samples N`, `--max-tokens`, `--json` tune it.
|
||||
- Required: `--schema`, `--template` — file path or inline value.
|
||||
- No `--model`: panel `fireworks/kimi-k2.7-code`, `anthropic/claude-opus-4-8`, `openai/gpt-5.5` × `--samples` (default 3); requires `FIREWORKS_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`.
|
||||
- `--model p/id,p/id`: override panel. Tune: `--samples N`, `--max-tokens`, `--json`.
|
||||
- Programmatic: `import { probe } from "./scripts/probe.ts"` → `{ prompt, results: [{ model, samples: [{ text, stopReason, usage, error }] }] }`.
|
||||
|
||||
### Builtin shortcut (preferred for this repo's tools)
|
||||
### Builtin shortcut — preferred for this repo
|
||||
|
||||
Skip building the two inputs by hand — `scripts/probe-builtin.ts` instantiates the live tool, pulls the EXACT wire schema (`toolWireSchema`) and rendered prompt (`tool.description`), and derives the outline for you:
|
||||
`scripts/probe-builtin.ts` instantiates the live tool; gets exact `toolWireSchema`, `tool.description`, and derived outline:
|
||||
|
||||
```bash
|
||||
bun .omp/skills/tool-prompt-optimization/scripts/probe-builtin.ts --tool <name> [--no-summary] [--show]
|
||||
```
|
||||
|
||||
- `--show` prints the resolved schema + derived outline + real prompt and exits (no API calls) — use it to eyeball inputs before spending tokens.
|
||||
- `--no-summary` runs the ablation (blank the summary line) directly.
|
||||
- `--samples` / `--model` / `--max-tokens` / `--json` forward to the panel; output ends with the REAL prompt so you can diff in place.
|
||||
- It bypasses the settings allowlist via the factory map, so gated tools (`irc`, `github`, …) resolve. If a tool refuses to construct (an availability gate like a missing `gh` CLI), fall back to the manual inputs below.
|
||||
- `--show`: resolved schema, derived outline, real prompt; exits without API calls. Inspect before spending tokens.
|
||||
- `--no-summary`: direct summary-line-blank ablation.
|
||||
- `--samples` / `--model` / `--max-tokens` / `--json`: panel passthrough. Output ends with real prompt for in-place diff.
|
||||
- Factory-map bypasses settings allowlist: gated `irc`, `github`, … resolve. Construction availability gate (e.g. missing `gh` CLI) → manual inputs.
|
||||
|
||||
## Build the two inputs
|
||||
## Inputs
|
||||
|
||||
**Schema** — use the *wire* schema the model actually sees, not a hand-sketch. For this repo's arktype tool schemas:
|
||||
**Schema:** wire schema the model sees, never hand-sketch. Arktype:
|
||||
|
||||
```ts
|
||||
import { arkToWireSchema } from "@oh-my-pi/pi-ai"; // or toolWireSchema(tool)
|
||||
JSON.stringify(arkToWireSchema(toolSchema), null, 2);
|
||||
```
|
||||
|
||||
Include `required` and `additionalProperties: false` — omitting them makes the model infer looser usage than the real tool.
|
||||
Include `required`, `additionalProperties: false`; omission makes usage appear looser than reality.
|
||||
|
||||
**Template (outline)** — the real `.md`'s structure with bodies blanked: the one-line summary, then each section tag with `...` inside.
|
||||
**Template:** actual `.md` structure with bodies blanked — one-line summary, then each section tag containing `...`.
|
||||
|
||||
```
|
||||
Structural code search via native ast-grep AST matching.
|
||||
@@ -65,54 +63,54 @@ Structural code search via native ast-grep AST matching.
|
||||
</critical>
|
||||
```
|
||||
|
||||
## Interpret results
|
||||
## Interpret
|
||||
|
||||
Bucket every line of the real prompt against the predictions:
|
||||
Bucket each real-prompt line:
|
||||
|
||||
- **Prune candidate** — content that is STABLE across samples AND agrees across models AND restates the schema (param names, types, "required", value examples already in a field `description`, clamp ranges already stated). The schema teaches it; the prompt repeats it.
|
||||
- **Keep** — content no model recovers: defaults and their direction (`gitignore` default true), cross-tool routing/escalation ("NEVER shell out to `find`/`fd` → use this tool", "broad exploration → Task subagent"), exact output format (mtime sort, grouping, `artifact://` truncation), worked anti-patterns, and hard constraints invisible to a type (the AST metavariable grammar, C++ trailing `;`).
|
||||
- **Prune candidate:** stable across samples **and** models; schema restatement — parameter names/types, `required`, field-description value examples, stated clamp ranges.
|
||||
- **Keep:** no model recovers it — defaults/direction (`gitignore` default true); routing/escalation (`NEVER` shell out to `find`/`fd` → use this tool; broad exploration → `Task` subagent); exact output shape (mtime sort, grouping, `artifact://` truncation); worked anti-patterns; type-invisible constraints (AST metavariable grammar, C++ trailing `;`).
|
||||
|
||||
A single sample is noise. Only treat overlap that is **stable across samples and models** as a prune *candidate* — and a candidate is not a verdict until its history clears (see caveats). You MUST NOT delete a line on inferability alone.
|
||||
One sample: noise. Stable cross-sample/model overlap is only a candidate; history must clear it. MUST NOT delete on inferability alone.
|
||||
|
||||
## Caveats — read before deleting anything
|
||||
## Caveats — before every deletion
|
||||
|
||||
- **`git blame` before cutting — MUST, not SHOULD.** Many prompt lines were added on purpose after a real failure: a model that hallucinated a flag, shelled out, scanned the repo root, fabricated an anchor. They look redundant precisely because they now prevent the mistake. You MUST `git blame` (and read the commit/issue) every line you intend to cut; the history tells you whether it restates the schema or is scar tissue from an incident. Keep scar tissue. Inferability is necessary for pruning, NEVER sufficient.
|
||||
- **Memorization ≠ inference.** Public repos (this one included) may be in training data, so a model can *recite* `ast-grep.md` it never *inferred*. Tell: predictions naming repo-specific details absent from the schema (exact tool names, internal URI schemes, the `Task` subagent) are memorized, not derived — discount them.
|
||||
- **The outline leaks.** The summary line and section names are themselves hints. To isolate *schema-alone* inferability, run an ablation: a second pass with no summary line and generic section tags. Content that survives only with the summary present is "summary-inferable", not "schema-inferable".
|
||||
- **MUST `git blame` each cut line; read its commit/issue.** Many lines are incident scar tissue: hallucinated flag, shell-out, repo-root scan, fabricated anchor. Keep scar tissue. History distinguishes schema restatement from incident prevention. Inferability necessary, NEVER sufficient.
|
||||
- **Memorization ≠ inference:** public repos, including this one, may be training data. Repo-specific prediction absent from schema — exact tool names, internal URI schemes, `Task` subagent — is recitation; discount it.
|
||||
- **Outline leaks:** summary and section names hint. For schema-alone inference, second pass: no summary, generic section tags. Content surviving only the summary is summary-inferable, not schema-inferable.
|
||||
|
||||
## Verdict pattern
|
||||
## Verdict
|
||||
|
||||
Per tool: predictions reproduce parameter mechanics and generic usage (already in the schema) but miss defaults, output shape, cross-tool routing, anti-patterns, and domain grammar. Prune the first set (after `git blame` clears each line); keep the second. Self-documenting flag tools (e.g. `find`) prune heavily; DSL/capability tools (e.g. `read`, `ast_grep`) barely at all.
|
||||
Predictions usually recover schema-covered parameter mechanics/generic usage, not defaults, output shape, routing, anti-patterns, domain grammar. Prune the former only after per-line `git blame`; keep the latter. Self-documenting flag tools (`find`) prune heavily; DSL/capability tools (`read`, `ast_grep`) barely.
|
||||
|
||||
## Tool Prompt Authoring
|
||||
|
||||
Tool prompts are not API docs. They teach the agent **when to reach for the tool, what shape its inputs take, and which failure modes are the agent's responsibility**. Everything else — engine internals, recovery heuristics, fallback chains, performance tuning — stays in code.
|
||||
Tool prompts are not API docs: teach when to choose a tool, input shape, and agent-owned failures. Engine internals, recovery heuristics, fallback chains, performance tuning: code.
|
||||
|
||||
### Describe surface, not machinery
|
||||
### Surface, not machinery
|
||||
|
||||
The agent picks tools from prose, not source. Tell it WHEN and WHY; NEVER HOW the tool works internally.
|
||||
Agents choose from prose, not source: tell WHEN/WHY, NEVER internal HOW.
|
||||
|
||||
- `read.md` enumerates every source it covers (file/dir/archive/sqlite/PDF/URL) so the agent stops reaching for `cat`/`curl`/`tar`. It does NOT mention the chunker, the binary sniffer, or the cache layer.
|
||||
- `lsp.md`: "You MUST use `lsp` whenever a language server is available — safer than text-based alternatives." No mention of the LSP wire protocol, server lifecycle, or capability negotiation.
|
||||
- `ast_edit`: teaches metavariable syntax + workflow ("Loosest existence check: `pat: 'executeBash'` with narrow paths"). Does NOT explain the AST engine, query compilation, or tree-sitter grammar selection.
|
||||
- `hashline.md` (this repo): teaches the **patch grammar** (anchors, ops, payloads, ranges) and the **edit shapes** that succeed. Hides `tryRecoverHashlineWithCache`, the fuzz factor, the bigram tables, `findUniqueSuffixMatch`, `untilAborted`, `formatGroupedFiles`. The agent never learns those names — it just sees "the tool resolved your typo" or "the anchor was stale, re-read".
|
||||
- `read.md`: every covered source — file/dir/archive/sqlite/PDF/URL — prevents `cat`/`curl`/`tar`; omit chunker, binary sniffer, cache layer.
|
||||
- `lsp.md`: "You MUST use `lsp` whenever a language server is available — safer than text-based alternatives." Omit LSP wire protocol, server lifecycle, capability negotiation.
|
||||
- `ast_edit`: metavariable syntax/workflow: "Loosest existence check: `pat: 'executeBash'` with narrow paths"; omit AST engine, query compilation, tree-sitter grammar selection.
|
||||
- `hashline.md` (this repo): patch grammar — anchors, ops, payloads, ranges — and successful edit shapes. Hide `tryRecoverHashlineWithCache`, fuzz factor, bigram tables, `findUniqueSuffixMatch`, `untilAborted`, `formatGroupedFiles`; agent sees only "the tool resolved your typo" or "the anchor was stale, re-read".
|
||||
|
||||
If the agent's behavior shouldn't change based on a detail, the detail does NOT belong in the prompt. Each sentence MUST shift a decision the agent makes.
|
||||
If a detail cannot change agent behavior, it does NOT belong. Each sentence MUST shift an agent decision.
|
||||
|
||||
### Anatomy of a good tool prompt
|
||||
### Good prompt anatomy
|
||||
|
||||
1. **One-line purpose.** What problem it solves, in the agent's vocabulary. Not "wraps libfoo with X" — instead "compact, line-anchored edit format".
|
||||
2. **Input grammar / surface.** Operators, parameters, selectors. Concrete syntax the agent will emit verbatim.
|
||||
3. **Worked examples.** 3–8 patterns covering the common shapes. Each example IS the explanation — don't narrate it twice.
|
||||
4. **Failure shapes the agent owns.** Things the agent can fix by changing its input (stale anchors, missing payload prefix, fabricated hash). Skip failures the engine recovers from silently.
|
||||
5. **Anti-patterns.** WRONG/RIGHT pairs for the mistakes that cost retries. Drawn from real failures, not imagined ones.
|
||||
6. **`<critical>` recap.** 3–6 lines of the load-bearing rules, in case the agent skips the body.
|
||||
1. **One-line purpose:** agent-vocabulary problem; not "wraps libfoo with X", but "compact, line-anchored edit format".
|
||||
2. **Input grammar/surface:** operators, parameters, selectors; concrete emitted syntax.
|
||||
3. **Worked examples:** 3–8 common shapes. Example IS explanation; do not narrate twice.
|
||||
4. **Agent-owned failure shapes:** input-fixable stale anchors, missing payload prefix, fabricated hash; omit silently recovered failures.
|
||||
5. **Anti-patterns:** real-failure WRONG/RIGHT pairs for retry-causing mistakes, never imagined ones.
|
||||
6. **`<critical>` recap:** 3–6 load-bearing lines for agents skipping body.
|
||||
|
||||
### What stays out
|
||||
### Exclude
|
||||
|
||||
- Implementation file names, function names, module layout.
|
||||
- Implementation file/function names, module layout.
|
||||
- Recovery, retry, normalization, caching, fuzz matching.
|
||||
- Performance characteristics ("this is O(n)") unless they change the agent's strategy.
|
||||
- Telemetry, logging, debug flags, env vars the agent cannot set.
|
||||
- Performance characteristics such as "this is O(n)", unless strategy-changing.
|
||||
- Telemetry, logging, debug flags, unsettable env vars.
|
||||
- Version history, deprecated parameters, "previously this worked differently".
|
||||
- Cross-tool plumbing ("this calls `read` under the hood") unless the agent must coordinate them.
|
||||
- Cross-tool plumbing such as "this calls `read` under the hood", unless coordination required.
|
||||
|
||||
@@ -238,6 +238,21 @@ For the bash tool specifically:
|
||||
Test the contract the system exposes — not the easiest internal detail to assert.
|
||||
|
||||
- Every new test must defend one **concrete, externally observable contract**: behavior, output shape, state transition, error mapping, or a regression-prone parsing boundary. If you cannot name the contract, do not add the test.
|
||||
|
||||
### Good vs. bad test filter
|
||||
|
||||
- **Name the failure mode.** Every test MUST state what a consumer observes if it regresses. Cannot name one? NEVER add it.
|
||||
- **Good: transformation.** One fixture MAY prove parse/render/normalize/encode/resolve behavior when output is computed, not echoed.
|
||||
- **Good: branch or boundary.** Distinct inputs, empty values, malformed input, version/provider routing, and state transitions MUST prove distinct outcomes.
|
||||
- **Good: external contract.** Exact bytes/shape MAY be asserted when a provider, parser, protocol, or persisted consumer reads them.
|
||||
- **Good: precedence or negative contract.** Keep explicit `false`/override-wins assertions and required absence only when they prevent a documented leak, downgrade, 400, or incompatible wire field.
|
||||
- **Good: regression.** A repro MUST trigger the prior real failure path and assert the corrected observable result.
|
||||
- **Bad: static echo.** NEVER test a constructor/builder merely copied a fixture or baked constant into an in-memory config/metadata field.
|
||||
- **Bad: success passthrough.** NEVER assert `fn(x) === x` when `x` was already supplied/declared valid; assert a transform, rejection, or downstream effect instead.
|
||||
- **Bad: wording/defaults.** NEVER assert prompt/UI boilerplate, a default literal, object existence, non-empty output, or length growth without a consumer contract.
|
||||
- **Bad: duplicate rows.** Parameterized/loop rows MUST each cover a distinct branch, provider/model path, or consumer contract; delete same-path duplicates.
|
||||
- **Metadata exception.** Exact metadata, identity, ordering, or `undefined` MAY remain only when a downstream consumer depends on it and the test establishes branch, precedence, negative-contract, wire, or regression evidence.
|
||||
- **Termination exception.** For cyclic/large inputs, assert a bounded output, surfaced error, or state change; bare `not.toThrow()` is insufficient.
|
||||
- No placeholder tests, tautologies, or "the code ran" assertions (`expect(true).toBe(true)`, bare `not.toThrow()`, non-empty string checks, length-grew checks, "prompt exists" checks without semantic assertion).
|
||||
- Prefer contract-level tests over implementation details. Avoid asserting internal helper wiring, field assignment, singleton identity, incidental ordering, prompt boilerplate, or passthrough option forwarding unless another component depends on that exact detail.
|
||||
- Don't duplicate coverage across abstraction levels. If an integration test already proves the behavior, drop the narrower unit test that restates it through mocks.
|
||||
|
||||
Generated
+102
-117
@@ -188,9 +188,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archery"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d"
|
||||
checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f"
|
||||
dependencies = [
|
||||
"triomphe",
|
||||
]
|
||||
@@ -802,9 +802,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.0"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
|
||||
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
@@ -1476,9 +1476,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.12"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f"
|
||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
@@ -2288,9 +2288,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -2303,9 +2303,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -2313,15 +2313,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -2330,9 +2330,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
@@ -2349,32 +2349,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -2662,11 +2662,6 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2729,9 +2724,9 @@ checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
|
||||
|
||||
[[package]]
|
||||
name = "html-to-markdown-rs"
|
||||
version = "3.10.6"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0736be7417aaf65e02124703aa8db88ad42ceff95df99783d0d48bbed4da3a8"
|
||||
checksum = "8f3e479289c322f5982b930575658df32d1161d6fab80b4078ce3e692f491df5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"astral-tl",
|
||||
@@ -2739,7 +2734,6 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"html-escape",
|
||||
"html5ever",
|
||||
"lru",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"phf 0.14.0",
|
||||
@@ -3057,9 +3051,9 @@ checksum = "2e0ee79a0bb29772465234bfbad6ea04fbc5220bef9fa4f318cc53eb8897070e"
|
||||
|
||||
[[package]]
|
||||
name = "icy_sixel"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85518b9086bf01117761b90e7691c0ef3236fa8adfb1fb44dd248fe5f87215d5"
|
||||
checksum = "4bfb5a63225620b59df34a235d1fb56ff7b766909c3212a8ff927511a22b181d"
|
||||
dependencies = [
|
||||
"quantette",
|
||||
"thiserror 2.0.20",
|
||||
@@ -3177,7 +3171,7 @@ dependencies = [
|
||||
"log",
|
||||
"num-format",
|
||||
"once_cell",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"rgb",
|
||||
"str_stack",
|
||||
]
|
||||
@@ -3625,15 +3619,6 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lscolors"
|
||||
version = "0.21.0"
|
||||
@@ -3767,13 +3752,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.12.0"
|
||||
version = "3.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f71d6bc097c4a6eb853c3f24991ab8c9f50f57d1f719e305175541482217e36"
|
||||
checksum = "459197f1592f4c3dbbf9c1b13f5a4599a343e4ef66b96bc340e2a518b36a6662"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"ctor",
|
||||
"futures",
|
||||
"libc",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
@@ -3783,15 +3769,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.4.0"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5282704fbe8d49b0cf8b08e3f33233416a528658f205c7e5ace63b582de0b11c"
|
||||
checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.6.2"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9002b2940f0184444754546e0fcd15182f56948e6f381968b019d549387c42"
|
||||
checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af"
|
||||
dependencies = [
|
||||
"convert_case 0.11.0",
|
||||
"ctor",
|
||||
@@ -3803,9 +3789,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "6.1.1"
|
||||
version = "6.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd"
|
||||
checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae"
|
||||
dependencies = [
|
||||
"convert_case 0.11.0",
|
||||
"proc-macro2",
|
||||
@@ -3984,9 +3970,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.46"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
@@ -4586,9 +4572,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pest"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
|
||||
checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"ucd-trie",
|
||||
@@ -4596,9 +4582,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_derive"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
|
||||
checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_generator",
|
||||
@@ -4606,9 +4592,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_generator"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
|
||||
checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a"
|
||||
dependencies = [
|
||||
"pest",
|
||||
"pest_meta",
|
||||
@@ -4619,9 +4605,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pest_meta"
|
||||
version = "2.8.8"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
|
||||
checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496"
|
||||
dependencies = [
|
||||
"pest",
|
||||
]
|
||||
@@ -4773,7 +4759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-ast"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast-grep-core",
|
||||
@@ -4842,7 +4828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-builtins"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"ansi-width",
|
||||
"anyhow",
|
||||
@@ -4927,7 +4913,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-iso"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
@@ -4939,7 +4925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-natives"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -5010,7 +4996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-shell"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"brush-core",
|
||||
@@ -5038,7 +5024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-voice"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"audiopus_sys",
|
||||
"bytes",
|
||||
@@ -5053,7 +5039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pi-walker"
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"globset",
|
||||
@@ -5193,9 +5179,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
@@ -5358,20 +5344,18 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quantette"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c98fecda8b16396ff9adac67644a523dd1778c42b58606a29df5c31ca925d174"
|
||||
checksum = "ba5d37e94c17b8870a5b936001d2845a6782a22ebdb1706c84294eca777f6729"
|
||||
dependencies = [
|
||||
"bitvec",
|
||||
"bytemuck",
|
||||
"image",
|
||||
"libm",
|
||||
"num-traits",
|
||||
"ordered-float",
|
||||
"palette",
|
||||
"rand 0.9.5",
|
||||
"rand 0.10.2",
|
||||
"rand_xoshiro",
|
||||
"rayon",
|
||||
"ref-cast",
|
||||
"wide",
|
||||
]
|
||||
@@ -5382,15 +5366,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.30.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -5502,11 +5477,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xoshiro"
|
||||
version = "0.7.0"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41"
|
||||
checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5817,9 +5792,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5834,9 +5809,9 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "safe_arch"
|
||||
version = "0.9.3"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "629516c85c29fe757770fa03f2074cf1eac43d44c02a3de9fc2ef7b0e207dfdd"
|
||||
checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -7740,7 +7715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
@@ -7792,9 +7767,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen 0.13.1",
|
||||
@@ -7979,9 +7954,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.2"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
@@ -7992,9 +7967,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wide"
|
||||
version = "0.8.3"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13ca908d26e4786149c48efcf6c0ea09ab0e06d1fe3c17dc1b4b0f1ca4a7e788"
|
||||
checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"safe_arch",
|
||||
@@ -8661,13 +8636,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xcb"
|
||||
version = "1.7.0"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6"
|
||||
checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags 2.13.1",
|
||||
"libc",
|
||||
"quick-xml 0.30.0",
|
||||
"quick-xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8754,9 +8729,9 @@ checksum = "c6e61e59a957b7ccee15d2049f86e8bfd6f66968fcd88f018950662d9b86e675"
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.18.0"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
@@ -8814,14 +8789,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.18.0"
|
||||
version = "5.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
@@ -8850,6 +8825,15 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zcheapstr"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
@@ -8972,41 +8956,42 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||
checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.4",
|
||||
"zcheapstr",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.13.1"
|
||||
version = "5.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||
checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.5.0"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||
checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/pi-*", "crates/vendor/*"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "17.2.12"
|
||||
version = "17.3.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Can Boluk"]
|
||||
|
||||
@@ -54,6 +54,31 @@ brew install can1357/tap/omp
|
||||
bun install -g @oh-my-pi/pi-coding-agent
|
||||
```
|
||||
|
||||
**Nix**
|
||||
|
||||
```sh
|
||||
# Run without installing
|
||||
nix run github:can1357/oh-my-pi
|
||||
|
||||
# Or install into the active profile
|
||||
nix profile install github:can1357/oh-my-pi
|
||||
```
|
||||
|
||||
Flake consumers can use `packages.<system>.omp`, `overlays.default`, `nixosModules.default`, or `homeManagerModules.default`. A Home Manager configuration can install OMP and own its settings declaratively:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.omp.url = "github:can1357/oh-my-pi";
|
||||
|
||||
# In your Home Manager module:
|
||||
imports = [ inputs.omp.homeManagerModules.default ];
|
||||
programs.omp = {
|
||||
enable = true;
|
||||
settings.startup.quiet = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
@@ -581,6 +606,22 @@ bun dev
|
||||
|
||||
`bun setup` installs Bun workspaces and builds `@oh-my-pi/pi-natives`. Re-run `bun run build:native` after changing Rust crates or `packages/natives`.
|
||||
|
||||
Nix users get the pinned Bun and Rust toolchains plus all native build dependencies:
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
bun setup
|
||||
bun dev
|
||||
```
|
||||
|
||||
Build and smoke-test the distributable Nix package with `nix build .#omp`. Wayland screencast support is off by default (linking libpipewire adds ~750 MB of runtime closure); enable it with `omp.override { withWaylandScreencast = true; }`. `nix/bun.nix` is generated only when `bun.lock` changes; releases regenerate it automatically. For dependency changes, run:
|
||||
|
||||
```sh
|
||||
bun run gen:nix
|
||||
```
|
||||
|
||||
The command uses `bun2nix` from `nix develop` when available, otherwise enters the development shell through Nix, then falls back to the pinned `bunx bun2nix@2.1.2`. Do not edit `nix/bun.nix` manually.
|
||||
|
||||
For a non-interactive smoke check:
|
||||
|
||||
```sh
|
||||
|
||||
+14
-1
@@ -16,19 +16,32 @@ _ADDON_RUSTC_FLAGS = [
|
||||
]
|
||||
|
||||
def _addon_transition_impl(settings, attr):
|
||||
# Statically link the MSVC CRT for the shipped win32 addon: rustc gets
|
||||
# +crt-static via the crate's rustc_flags select, and the C dependencies
|
||||
# (opus/cmake, tree-sitter, blake3, ring) must move to /MT in lock-step so
|
||||
# the final .node imports no VCRUNTIME140.dll from the Visual C++
|
||||
# Redistributable (absent on a clean Windows install -> dlopen error 126).
|
||||
# The static_link_msvcrt cc feature flips the toolchain compile flags that
|
||||
# rules_rust forwards to cc-rs/cmake as CFLAGS/CXXFLAGS; it is inert for the
|
||||
# zig/darwin toolchains, so scoping it to win32 is belt-and-suspenders.
|
||||
features = list(settings["//command_line_option:features"])
|
||||
if "win32" in str(attr.platform):
|
||||
features = features + ["static_link_msvcrt"]
|
||||
return {
|
||||
"//command_line_option:platforms": str(attr.platform),
|
||||
"//command_line_option:compilation_mode": "opt",
|
||||
"//command_line_option:features": features,
|
||||
"@rules_rust//rust/settings:lto": "thin",
|
||||
"@rules_rust//rust/settings:extra_rustc_flags": _ADDON_RUSTC_FLAGS,
|
||||
}
|
||||
|
||||
_addon_transition = transition(
|
||||
implementation = _addon_transition_impl,
|
||||
inputs = [],
|
||||
inputs = ["//command_line_option:features"],
|
||||
outputs = [
|
||||
"//command_line_option:platforms",
|
||||
"//command_line_option:compilation_mode",
|
||||
"//command_line_option:features",
|
||||
"@rules_rust//rust/settings:lto",
|
||||
"@rules_rust//rust/settings:extra_rustc_flags",
|
||||
],
|
||||
|
||||
@@ -22,9 +22,16 @@ exec hosts. Replaces cargo-xwin.
|
||||
work from Bazel actions (cwd = execroot) *and* from build scripts, where
|
||||
rules_rust `${pwd}`-expands `CC`/`AR` to absolute paths and cc-rs/cmake spawn
|
||||
tools from other cwds.
|
||||
- **CRT: dynamic `/MD`** (rules_cc's msvc branch default outside `dbg` without
|
||||
the `static_link_msvcrt` feature) — matches what napi/cc-rs produced under
|
||||
cargo-xwin (rust msvc targets default to dynamic CRT without `+crt-static`).
|
||||
- **CRT: static `/MT` for the shipped addon.** The toolchain *default* is
|
||||
dynamic `/MD` (rules_cc's msvc branch default outside `dbg` without the
|
||||
`static_link_msvcrt` feature), matching what napi/cc-rs produced under
|
||||
cargo-xwin. But `//:natives-win32-x64-baseline` overrides to static CRT:
|
||||
`-Ctarget-feature=+crt-static` for rustc (crate BUILD select) plus the
|
||||
`static_link_msvcrt` cc feature (enabled for win32 in the `native_addon`
|
||||
transition, `bazel/defs.bzl`) so the C deps compile `/MT` in lock-step.
|
||||
Without this the `.node` imports `VCRUNTIME140.dll` from the Visual C++
|
||||
Redistributable, which is absent on a clean Windows install and makes the
|
||||
loader's dlopen fail with error 126 (issue #8439).
|
||||
- **SSE floor in the wrapper, not annotations**: `-msse4.1 -msse4.2` live in the
|
||||
clang-cl wrapper, which only ever targets win32-x64 (baseline = x86-64-v2 ⊇
|
||||
SSE4.2). This is the old build-native.ts CFLAGS hack, windows-only by
|
||||
@@ -95,7 +102,8 @@ exec hosts. Replaces cargo-xwin.
|
||||
defaults to the Debug config → `/MDd` → `msvcrtd.lib`, which the lean splat
|
||||
(like cargo-xwin's) does not carry; toolchain.cmake pins
|
||||
`CMAKE_TRY_COMPILE_CONFIGURATION=Release`, `CMAKE_POLICY_DEFAULT_CMP0091=NEW`
|
||||
and `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL` (/MD everywhere).
|
||||
and `CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded` (static release `/MT`
|
||||
everywhere, matching the addon's static-CRT policy — issue #8439).
|
||||
Verified on darwin: scratch `project(C)` + `add_executable` configures with
|
||||
"Clang 20.1.7 with MSVC-like command-line" and links a valid PE32+ exe
|
||||
through vs_link_exe with the wrapper rc/mt/linker.
|
||||
@@ -103,8 +111,9 @@ exec hosts. Replaces cargo-xwin.
|
||||
## What to verify on can.internal (linux-x64)
|
||||
|
||||
1. `bazel build //:natives-win32-x64-baseline` end-to-end link; check the
|
||||
produced `pi_natives.win32-x64-baseline.node` imports (dumpbin/llvm-readobj:
|
||||
expect VCRUNTIME140/api-ms-win-crt-* → `/MD`, no static CRT).
|
||||
produced `pi_natives.win32-x64-baseline.node` imports (dumpbin/llvm-readobj):
|
||||
expect **no** `VCRUNTIME140.dll` and **no** `api-ms-win-crt-*` (static CRT);
|
||||
only core Windows system DLLs (kernel32, ntdll, advapi32, …) should remain.
|
||||
2. LLVM 20.1.7 Linux-X64 binaries are built on a newish Ubuntu: confirm the
|
||||
kata runner image's glibc is ≥ 2.35-ish and has `libtinfo6`/`libstdc++6`
|
||||
(usual LLVM release-binary runtime deps).
|
||||
|
||||
@@ -124,15 +124,20 @@ set(CMAKE_CXX_COMPILER "${CMAKE_CURRENT_LIST_DIR}/bin/clang-cl")
|
||||
set(CMAKE_LINKER "${CMAKE_CURRENT_LIST_DIR}/bin/lld-link")
|
||||
set(CMAKE_RC_COMPILER "${CMAKE_CURRENT_LIST_DIR}/bin/llvm-rc")
|
||||
set(CMAKE_MT "${CMAKE_CURRENT_LIST_DIR}/bin/llvm-mt")
|
||||
# The xwin splat carries release CRT import libs only (msvcrt.lib, no
|
||||
# msvcrtd.lib — same as cargo-xwin). try_compile defaults to the Debug
|
||||
# configuration, whose /MDd would demand the debug CRT; pin try_compile to
|
||||
# Release and the runtime library to dynamic release /MD for every config
|
||||
# (CMP0091 NEW makes CMAKE_MSVC_RUNTIME_LIBRARY authoritative even for
|
||||
# projects with ancient cmake_minimum_required, e.g. bundled opus).
|
||||
# The xwin splat carries release CRT import + static libs only (msvcrt.lib /
|
||||
# libcmt.lib, no debug msvcrtd.lib / libcmtd.lib — same as cargo-xwin).
|
||||
# try_compile defaults to the Debug configuration, whose debug CRT the splat
|
||||
# lacks; pin try_compile to Release. The shipped win32 addon statically links
|
||||
# the CRT (rustc +crt-static + the static_link_msvcrt cc feature; see
|
||||
# bazel/defs.bzl and crates/pi-natives/BUILD.bazel), so pin the runtime library
|
||||
# to the static release CRT /MT for every config as well — otherwise CMake's
|
||||
# authoritative CMAKE_MSVC_RUNTIME_LIBRARY (CMP0091 NEW) would emit /MD for the
|
||||
# bundled opus objects, which then import VCRUNTIME140.dll and conflict with the
|
||||
# static CRT the rest of the addon links (issue #8439). Keep this in lock-step
|
||||
# with the static_link_msvcrt feature: both must select the static CRT together.
|
||||
set(CMAKE_TRY_COMPILE_CONFIGURATION Release)
|
||||
set(CMAKE_POLICY_DEFAULT_CMP0091 NEW)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreadedDLL)
|
||||
set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded)
|
||||
"""
|
||||
|
||||
_BUILD = """\
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
@@ -40,7 +40,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/omptype": "catalog:",
|
||||
@@ -63,7 +63,7 @@
|
||||
},
|
||||
"packages/catalog": {
|
||||
"name": "@oh-my-pi/pi-catalog",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "catalog:",
|
||||
"@oh-my-pi/omptype": "catalog:",
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@oh-my-pi/pi-coding-agent",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"bin": {
|
||||
"omp": "src/cli.ts",
|
||||
},
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/hashline": {
|
||||
"name": "@oh-my-pi/hashline",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -178,7 +178,7 @@
|
||||
},
|
||||
"packages/mnemopi": {
|
||||
"name": "@oh-my-pi/pi-mnemopi",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"bin": {
|
||||
"mnemopi": "src/cli.ts",
|
||||
},
|
||||
@@ -204,7 +204,7 @@
|
||||
},
|
||||
"packages/natives": {
|
||||
"name": "@oh-my-pi/pi-natives",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -212,7 +212,7 @@
|
||||
},
|
||||
"packages/omptype": {
|
||||
"name": "@oh-my-pi/omptype",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"devDependencies": {
|
||||
"@ark/attest": "0.56.3",
|
||||
"@ark/schema": "0.56.2",
|
||||
@@ -225,9 +225,10 @@
|
||||
},
|
||||
"packages/snapcompact": {
|
||||
"name": "@oh-my-pi/snapcompact",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-ai": "catalog:",
|
||||
"@oh-my-pi/pi-catalog": "catalog:",
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
"@oh-my-pi/pi-wire": "catalog:",
|
||||
@@ -238,7 +239,7 @@
|
||||
},
|
||||
"packages/stats": {
|
||||
"name": "@oh-my-pi/omp-stats",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"bin": {
|
||||
"omp-stats": "./src/index.ts",
|
||||
},
|
||||
@@ -263,7 +264,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@oh-my-pi/pi-tui",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
"@oh-my-pi/pi-utils": "catalog:",
|
||||
@@ -299,7 +300,7 @@
|
||||
},
|
||||
"packages/utils": {
|
||||
"name": "@oh-my-pi/pi-utils",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"dependencies": {
|
||||
"@oh-my-pi/pi-natives": "catalog:",
|
||||
},
|
||||
@@ -309,7 +310,7 @@
|
||||
},
|
||||
"packages/wire": {
|
||||
"name": "@oh-my-pi/pi-wire",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
@@ -347,19 +348,19 @@
|
||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@napi-rs/cli": "3.7.2",
|
||||
"@oh-my-pi/hashline": "17.2.12",
|
||||
"@oh-my-pi/omp-stats": "17.2.12",
|
||||
"@oh-my-pi/omptype": "17.2.12",
|
||||
"@oh-my-pi/pi-agent-core": "17.2.12",
|
||||
"@oh-my-pi/pi-ai": "17.2.12",
|
||||
"@oh-my-pi/pi-catalog": "17.2.12",
|
||||
"@oh-my-pi/pi-coding-agent": "17.2.12",
|
||||
"@oh-my-pi/pi-mnemopi": "17.2.12",
|
||||
"@oh-my-pi/pi-natives": "17.2.12",
|
||||
"@oh-my-pi/pi-tui": "17.2.12",
|
||||
"@oh-my-pi/pi-utils": "17.2.12",
|
||||
"@oh-my-pi/pi-wire": "17.2.12",
|
||||
"@oh-my-pi/snapcompact": "17.2.12",
|
||||
"@oh-my-pi/hashline": "17.3.1",
|
||||
"@oh-my-pi/omp-stats": "17.3.1",
|
||||
"@oh-my-pi/omptype": "17.3.1",
|
||||
"@oh-my-pi/pi-agent-core": "17.3.1",
|
||||
"@oh-my-pi/pi-ai": "17.3.1",
|
||||
"@oh-my-pi/pi-catalog": "17.3.1",
|
||||
"@oh-my-pi/pi-coding-agent": "17.3.1",
|
||||
"@oh-my-pi/pi-mnemopi": "17.3.1",
|
||||
"@oh-my-pi/pi-natives": "17.3.1",
|
||||
"@oh-my-pi/pi-tui": "17.3.1",
|
||||
"@oh-my-pi/pi-utils": "17.3.1",
|
||||
"@oh-my-pi/pi-wire": "17.3.1",
|
||||
"@oh-my-pi/snapcompact": "17.3.1",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.220.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.9.0",
|
||||
@@ -486,7 +487,7 @@
|
||||
|
||||
"@huggingface/blake3-jit": ["@huggingface/blake3-jit@0.0.2", "", {}, "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w=="],
|
||||
|
||||
"@huggingface/hub": ["@huggingface/hub@2.14.6", "", { "dependencies": { "@huggingface/tasks": "^0.21.32", "@huggingface/xetchunk-wasm": "^0.1.0" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-z7VwKvuHoOLMVpazbXsrCqS5gLu9a3/YwrZMBOsOUwcWnQv04IGOc/oTf3YAEX2aFNU6PpKRBnA9543Fg1oNog=="],
|
||||
"@huggingface/hub": ["@huggingface/hub@2.15.0", "", { "dependencies": { "@huggingface/tasks": "^0.21.33", "@huggingface/xetchunk-wasm": "^0.1.0" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-+sHWNz0YpqTvwuIYDxpyrhyk1o2j9lIJuPzbhUT0kxU8IHP9ZOzeajk/O6RRbZyvAM9h6m219FghDLi0Ayblww=="],
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||
|
||||
@@ -898,7 +899,7 @@
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||
"@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||
|
||||
@@ -982,7 +983,7 @@
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="],
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
@@ -990,11 +991,11 @@
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
|
||||
"browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
@@ -1062,7 +1063,7 @@
|
||||
|
||||
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.399", "", {}, "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="],
|
||||
|
||||
"emnapi": ["emnapi@1.11.3", "", { "peerDependencies": { "node-addon-api": ">= 6.1.0" }, "optionalPeers": ["node-addon-api"] }, "sha512-+/ZS90YK/rYfVOHtGLHkGffVsnmD/MAKaBHio+Y4XAtg75RLr4cveV/w0jTkUdLM1CcAlaRgG76mpIemWAlk0A=="],
|
||||
|
||||
@@ -1188,7 +1189,7 @@
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="],
|
||||
"lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
@@ -1222,9 +1223,9 @@
|
||||
|
||||
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
|
||||
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.52", "", {}, "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A=="],
|
||||
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
@@ -1248,7 +1249,7 @@
|
||||
|
||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="],
|
||||
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||
|
||||
@@ -1366,11 +1367,11 @@
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@8.2.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.23", "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ=="],
|
||||
"vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
|
||||
|
||||
"vite-plugin-solid": ["vite-plugin-solid@2.11.14", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.0.0 || ^7.0.0", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-7ZVBt8rpoyqmlwin2kRIUveaHoF6/kulY7gsnD+qFh4nS29V4OPAnw+ojoAspXIjObiL9o1xh9a/nTuYHm02Rw=="],
|
||||
|
||||
@@ -1380,7 +1381,7 @@
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
|
||||
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
|
||||
@@ -101,6 +101,14 @@ pub(crate) struct Host {
|
||||
stdin_is_search_input: bool,
|
||||
}
|
||||
|
||||
struct CancelOnDrop(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for CancelOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// The name the utility was invoked as. Differs from [`Utility::NAME`] when
|
||||
/// one implementation backs several builtins (`grep` and `rg`).
|
||||
@@ -119,7 +127,8 @@ impl Host {
|
||||
/// filesystem: the host process's current directory is unrelated to the
|
||||
/// shell's.
|
||||
pub fn resolve(&self, path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
let normalized_path = brush_core::sys::fs::normalize_shell_path(path.as_ref());
|
||||
let path = normalized_path.as_ref();
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
@@ -578,6 +587,7 @@ async fn run_utility<U: Utility, SE: ShellExtensions>(
|
||||
let mut host = build_host(&context, U::NAME)?;
|
||||
let cancel = context.cancel_token();
|
||||
let cancel_flag = host.cancel_flag();
|
||||
let _cancel_on_drop = CancelOnDrop(Arc::clone(&cancel_flag));
|
||||
drop(context);
|
||||
|
||||
let mut handle = tokio::task::spawn_blocking(move || {
|
||||
@@ -869,6 +879,14 @@ mod testing {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolves_msys_drive_aliases_to_native_drive() {
|
||||
let (host, _) = Host::for_test("test", "", r"C:\workspace");
|
||||
|
||||
assert_eq!(host.resolve("/c/Users/Adam/file.txt"), PathBuf::from(r"C:\Users\Adam\file.txt"));
|
||||
}
|
||||
|
||||
/// Parses `argv` and runs `U` against an in-memory host, mirroring what the
|
||||
/// registered builtin does: `argv[0]` is the command name, clap failures are
|
||||
/// reported the same way, and panics are contained.
|
||||
|
||||
@@ -255,35 +255,35 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nonempty_stdin_runs_command_with_stdin() {
|
||||
let result = run_in("hello world\n", &["/bin/cat"]);
|
||||
let result = run_in("hello world\n", &["cat"]);
|
||||
assert_eq!(result, (0, "hello world\n".to_string(), String::new()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn empty_stdin_skips_command() {
|
||||
let result = run_in("", &["/bin/sh", "-c", "echo ran"]);
|
||||
let result = run_in("", &["sh", "-c", "echo ran"]);
|
||||
assert_eq!(result, (0, String::new(), String::new()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn invert_runs_command_on_empty_stdin() {
|
||||
let result = run_in("", &["-n", "/bin/sh", "-c", "echo ran"]);
|
||||
let result = run_in("", &["-n", "sh", "-c", "echo ran"]);
|
||||
assert_eq!(result, (0, "ran\n".to_string(), String::new()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn invert_passes_nonempty_stdin_through() {
|
||||
let result = run_in("data\n", &["-n", "/bin/sh", "-c", "echo ran"]);
|
||||
let result = run_in("data\n", &["-n", "sh", "-c", "echo ran"]);
|
||||
assert_eq!(result, (0, "data\n".to_string(), String::new()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn child_exit_code_propagates() {
|
||||
let result = run_in("x", &["/bin/sh", "-c", "exit 3"]);
|
||||
let result = run_in("x", &["sh", "-c", "exit 3"]);
|
||||
assert_eq!(result, (3, String::new(), String::new()));
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ mod tests {
|
||||
#[test]
|
||||
fn early_exiting_child_is_not_an_error() {
|
||||
let big = "a".repeat(1 << 20);
|
||||
let result = run_in(&big, &["/usr/bin/head", "-c", "1"]);
|
||||
let result = run_in(&big, &["head", "-c", "1"]);
|
||||
assert_eq!(result, (0, "a".to_string(), String::new()));
|
||||
}
|
||||
|
||||
|
||||
@@ -3736,7 +3736,8 @@ struct LsRuntime {
|
||||
|
||||
impl LsRuntime {
|
||||
fn resolve(&self, path: impl AsRef<Path>) -> PathBuf {
|
||||
let path = path.as_ref();
|
||||
let normalized_path = brush_core::sys::fs::normalize_shell_path(path.as_ref());
|
||||
let path = normalized_path.as_ref();
|
||||
if path.is_absolute() { path.to_path_buf() } else { self.cwd.join(path) }
|
||||
}
|
||||
|
||||
@@ -5238,6 +5239,23 @@ mod integration_tests {
|
||||
assert_eq!(capture.out(), "visible-name\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn resolves_msys_drive_alias_operands() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
File::create(dir.path().join("visible-name")).unwrap();
|
||||
let native = dir.path().to_string_lossy().replace('\\', "/");
|
||||
let (drive, tail) = native
|
||||
.split_once(":/")
|
||||
.unwrap_or_else(|| panic!("expected drive-qualified temp path, got {native:?}"));
|
||||
let alias = format!("/{}/{}", drive.to_ascii_lowercase(), tail);
|
||||
|
||||
let (code, capture) = run_util::<Ls>(&[&alias], "", dir.path());
|
||||
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(capture.out(), "visible-name\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_quoting_style_from_the_shell_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -36,7 +36,7 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn matching_process() -> std::process::Child {
|
||||
std::process::Command::new("/bin/sleep")
|
||||
std::process::Command::new("sleep")
|
||||
.arg("30")
|
||||
.spawn()
|
||||
.expect("spawn matching process")
|
||||
|
||||
@@ -887,11 +887,11 @@ fn parse_states(value: &str, target: &mut HashSet<char>) -> std::result::Result<
|
||||
}
|
||||
|
||||
fn resolve_shell_path(cwd: &Path, value: &str) -> PathBuf {
|
||||
let path = Path::new(value);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
let normalized = brush_core::sys::fs::normalize_shell_path(Path::new(value));
|
||||
if normalized.is_absolute() {
|
||||
normalized.into_owned()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
cwd.join(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,3 +957,15 @@ fn write_proc_match_help(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_msys_drive_alias_pidfiles() {
|
||||
assert_eq!(
|
||||
resolve_shell_path(Path::new(r"C:\workspace"), "/c/Users/Adam/app.pid"),
|
||||
PathBuf::from(r"C:\Users\Adam\app.pid"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1558,7 +1558,14 @@ pub fn compile_subst_flags(
|
||||
}
|
||||
let location = ScriptLocation::at_position(lines, line);
|
||||
let mut path = read_file_path(lines, line)?;
|
||||
if let Some(cwd) = cwd && path.is_relative() { path = cwd.join(path); }
|
||||
if let Some(cwd) = cwd {
|
||||
let normalized = brush_core::sys::fs::normalize_shell_path(&path);
|
||||
path = if normalized.is_absolute() {
|
||||
normalized.into_owned()
|
||||
} else {
|
||||
cwd.join(normalized)
|
||||
};
|
||||
}
|
||||
subst.write_file = Some(NamedWriter::new(path, location)?);
|
||||
return Ok(()); // 'w' is the last flag allowed
|
||||
},
|
||||
@@ -1633,7 +1640,12 @@ fn compile_read_file_command(
|
||||
return compilation_error(lines, line, ERR_SANDBOX);
|
||||
}
|
||||
let mut path = read_file_path(lines, line)?;
|
||||
if path.is_relative() { path = context.cwd.join(path); }
|
||||
let normalized = brush_core::sys::fs::normalize_shell_path(&path);
|
||||
path = if normalized.is_absolute() {
|
||||
normalized.into_owned()
|
||||
} else {
|
||||
context.cwd.join(normalized)
|
||||
};
|
||||
cmd.data = CommandData::Path(path);
|
||||
Ok(CommandHandling::Continue)
|
||||
}
|
||||
@@ -1651,7 +1663,12 @@ fn compile_write_file_command(
|
||||
}
|
||||
let location = ScriptLocation::at_position(lines, line);
|
||||
let mut path = read_file_path(lines, line)?;
|
||||
if path.is_relative() { path = context.cwd.join(path); }
|
||||
let normalized = brush_core::sys::fs::normalize_shell_path(&path);
|
||||
path = if normalized.is_absolute() {
|
||||
normalized.into_owned()
|
||||
} else {
|
||||
context.cwd.join(normalized)
|
||||
};
|
||||
cmd.data = CommandData::NamedWriter(NamedWriter::new(path, location)?);
|
||||
Ok(CommandHandling::Continue)
|
||||
}
|
||||
@@ -7913,7 +7930,7 @@ fn re_or_saved_re<'a>(
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell_command(cmd: &str, host: &Host) -> std::process::Command {
|
||||
let mut c = std::process::Command::new("/bin/sh");
|
||||
let mut c = std::process::Command::new("sh");
|
||||
c.arg("-c").arg(cmd);
|
||||
// run relative to the shell's cwd,
|
||||
// not the host process cwd. `output()` already keeps the child's stdio
|
||||
@@ -8822,9 +8839,15 @@ impl ScriptLineProvider {
|
||||
line_number: 0,
|
||||
};
|
||||
} else {
|
||||
// resolve `-f`
|
||||
// script files against the shell working directory.
|
||||
let resolved = if p.is_absolute() { p.clone() } else { self.cwd.join(p) };
|
||||
// resolve `-f` script files against the shell working
|
||||
// directory, normalizing MSYS/WSL drive aliases (`/c/...`)
|
||||
// to native drive paths first — mirrors `Host::resolve`.
|
||||
let normalized = brush_core::sys::fs::normalize_shell_path(p);
|
||||
let resolved = if normalized.is_absolute() {
|
||||
normalized.into_owned()
|
||||
} else {
|
||||
self.cwd.join(normalized)
|
||||
};
|
||||
let file = File::open(resolved)
|
||||
.map_err_context(|| format!("error opening script file {}", p.quote()))?;
|
||||
self.state = State::Active {
|
||||
@@ -8915,6 +8938,30 @@ mod tests {
|
||||
assert_eq!(lines, vec!["file line 1", "file line 2"]);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn test_file_source_resolves_msys_drive_alias() {
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
writeln!(temp_file, "aliased line 1").unwrap();
|
||||
writeln!(temp_file, "aliased line 2").unwrap();
|
||||
|
||||
let native = temp_file.path().to_string_lossy().replace('\\', "/");
|
||||
let (drive, tail) = native
|
||||
.split_once(":/")
|
||||
.unwrap_or_else(|| panic!("expected drive-qualified temp path, got {native:?}"));
|
||||
let alias = format!("/{}/{}", drive.to_ascii_lowercase(), tail);
|
||||
|
||||
let input = vec![ScriptValue::PathVal(PathBuf::from(alias))];
|
||||
let mut provider = ScriptLineProvider::new(input);
|
||||
|
||||
let mut lines = Vec::new();
|
||||
while let Some(line) = provider.next_line().unwrap() {
|
||||
lines.push(line.trim_end().to_string());
|
||||
}
|
||||
|
||||
assert_eq!(lines, vec!["aliased line 1", "aliased line 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_source() {
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
|
||||
@@ -40,6 +40,13 @@ rust_shared_library(
|
||||
# cdylib at all; napi musl addons have always linked the dynamic CRT.
|
||||
"//bazel/triples:x86_64-unknown-linux-musl": ["-Ctarget-feature=-crt-static"],
|
||||
"//bazel/triples:aarch64-unknown-linux-musl": ["-Ctarget-feature=-crt-static"],
|
||||
# Statically link the MSVC CRT so the shipped .node does not import
|
||||
# VCRUNTIME140.dll from the Visual C++ Redistributable, which is absent
|
||||
# on a clean Windows install and makes the loader's dlopen fail with
|
||||
# "The specified module could not be found" (error 126). The C deps are
|
||||
# switched to /MT in lock-step via the static_link_msvcrt cc feature in
|
||||
# the native_addon transition (bazel/defs.bzl).
|
||||
"//bazel/triples:x86_64-pc-windows-msvc": ["-Ctarget-feature=+crt-static"],
|
||||
"//conditions:default": [],
|
||||
}),
|
||||
version = "17.1.5",
|
||||
|
||||
@@ -255,7 +255,7 @@ fn create_windows_napi_tokio_runtime() -> Option<tokio::runtime::Runtime> {
|
||||
/// MUST stay in sync with `VERSION_SENTINEL_EXPORT` in
|
||||
/// `packages/natives/native/index.js` (which derives the name from
|
||||
/// `package.json#version`).
|
||||
#[napi(js_name = "__piNativesV17_2_12")]
|
||||
#[napi(js_name = "__piNativesV17_3_1")]
|
||||
pub const fn pi_natives_version_sentinel() {}
|
||||
|
||||
/// Native module entry point: install crash diagnostics before any tool can
|
||||
|
||||
@@ -491,7 +491,7 @@ mod tests {
|
||||
shell
|
||||
.run(
|
||||
CoreShellRunOptions {
|
||||
command: "/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'".to_string(),
|
||||
command: "sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'".to_string(),
|
||||
cwd: None,
|
||||
env: None,
|
||||
timeout_ms: None,
|
||||
|
||||
+101
-46
@@ -38,6 +38,12 @@ struct ShellSessionCore {
|
||||
shell: BrushShell,
|
||||
}
|
||||
|
||||
impl Drop for ShellSessionCore {
|
||||
fn drop(&mut self) {
|
||||
terminate_internal_background_jobs(&mut self.shell);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ShellAbortState(Arc<TokioMutex<Option<AbortToken>>>);
|
||||
|
||||
@@ -1408,10 +1414,16 @@ async fn terminate_run(registry: &process::SpawnRegistry) {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn terminate_background_jobs(shell: &mut BrushShell) {
|
||||
let mut targets = process::TerminationTargets::new();
|
||||
fn terminate_internal_background_jobs(shell: &mut BrushShell) {
|
||||
for job in &mut shell.jobs_mut().jobs {
|
||||
job.abort_internal_tasks();
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate_background_jobs(shell: &mut BrushShell) {
|
||||
let mut targets = process::TerminationTargets::new();
|
||||
terminate_internal_background_jobs(shell);
|
||||
for job in &shell.jobs().jobs {
|
||||
if let Some(pgid) = job.process_group_id() {
|
||||
targets.add_pgid(pgid);
|
||||
}
|
||||
@@ -1979,12 +1991,30 @@ mod tests {
|
||||
.expect("child did not enter expected executable");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn test_executable(name: &str) -> std::path::PathBuf {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
std::env::var_os("PATH")
|
||||
.and_then(|path| {
|
||||
std::env::split_paths(&path)
|
||||
.map(|directory| directory.join(name))
|
||||
.find(|candidate| {
|
||||
candidate.metadata().is_ok_and(|metadata| {
|
||||
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
|
||||
})
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{name} executable on PATH"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn process_test_command(prefix: &str) -> (tempfile::TempDir, std::path::PathBuf, String) {
|
||||
let dir = tempfile::tempdir().expect("process test directory");
|
||||
let name = format!("{prefix}{}", std::process::id());
|
||||
let command = dir.path().join(&name);
|
||||
std::os::unix::fs::symlink("/bin/sleep", &command).expect("sleep symlink");
|
||||
let sleep = test_executable("sleep");
|
||||
std::os::unix::fs::symlink(sleep, &command).expect("sleep symlink");
|
||||
(dir, command, name)
|
||||
}
|
||||
|
||||
@@ -2869,13 +2899,14 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn kill_builtin_refuses_ancestors_but_not_unrelated_processes() {
|
||||
let (result, output) = execute_captured(
|
||||
let sleep = test_executable("sleep");
|
||||
let command = format!(
|
||||
"parent=$(ps -o ppid= -p $$ | tr -d ' ')\nkill -CONT \"$parent\"; printf \
|
||||
'ancestor=%s\\n' \"$?\"\n/bin/sleep 30 &\nchild=$!\nkill -TERM \"$child\"; printf \
|
||||
'child=%s\\n' \"$?\"\nprintf 'survived\\n'"
|
||||
.to_string(),
|
||||
)
|
||||
.await;
|
||||
'ancestor=%s\\n' \"$?\"\n{} 30 &\nchild=$!\nkill -TERM \"$child\"; printf 'child=%s\\n' \
|
||||
\"$?\"\nprintf 'survived\\n'",
|
||||
quote_arg(sleep.to_str().expect("utf8 sleep path"))
|
||||
);
|
||||
let (result, output) = execute_captured(command).await;
|
||||
assert_eq!(result.exit_code, Some(0), "the shell must survive: {output:?}");
|
||||
assert!(output.contains("survived"), "{output:?}");
|
||||
assert!(
|
||||
@@ -3012,11 +3043,14 @@ mod tests {
|
||||
// An identity "compressor" that also proves it was started with the
|
||||
// shell's working directory and reaches the command's stderr.
|
||||
let shim = bin.join("pi-test-compress");
|
||||
std::fs::write(
|
||||
&shim,
|
||||
"#!/bin/sh\nprintf 'compressor cwd=%s\\n' \"$PWD\" >&2\nexec /bin/cat\n",
|
||||
)
|
||||
.expect("write shim");
|
||||
let shell = test_executable("sh");
|
||||
let cat = test_executable("cat");
|
||||
let shim_source = format!(
|
||||
"#!{}\nprintf 'compressor cwd=%s\\n' \"$PWD\" >&2\nexec {}\n",
|
||||
shell.display(),
|
||||
quote_arg(cat.to_str().expect("utf8 cat path"))
|
||||
);
|
||||
std::fs::write(&shim, shim_source).expect("write shim");
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).expect("chmod shim");
|
||||
|
||||
// Enough distinct lines that the 1K buffer forces spilling through the
|
||||
@@ -4432,13 +4466,40 @@ replace = [{ pattern = "hello", replacement = "HI" }]
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn one_shot_completion_aborts_internal_background_jobs() {
|
||||
let marker = tempfile::NamedTempFile::new().expect("marker file");
|
||||
let marker_path = marker.path().to_string_lossy();
|
||||
std::fs::remove_file(marker.path()).expect("remove initial marker");
|
||||
let command = format!("{{ sleep 1; echo leaked > {}; }} &", quote_arg(&marker_path));
|
||||
|
||||
execute_shell(
|
||||
ShellExecuteOptions { command, ..Default::default() },
|
||||
None,
|
||||
CancelToken::default(),
|
||||
)
|
||||
.await
|
||||
.expect("one-shot shell execution");
|
||||
// `execute_shell` returns after its short post-exit idle drain (~250ms),
|
||||
// while the background job cannot write the marker until its 1s sleep
|
||||
// elapses. Wait well past that delay so a job that outlived the dropped
|
||||
// session has demonstrably had its chance to run — the pre-fix leak fires
|
||||
// at ~1s and is caught here; the fixed path aborts the task on drop and the
|
||||
// marker never appears.
|
||||
time::sleep(Duration::from_millis(2000)).await;
|
||||
|
||||
assert!(
|
||||
!marker.path().exists(),
|
||||
"an internal background job outlived its one-shot shell session"
|
||||
);
|
||||
}
|
||||
|
||||
/// `live_background_job_count` reports 0 when the session has no live
|
||||
/// external background jobs and 1 while one is running. The host relies on
|
||||
/// this to retain a per-call shell whose `&`/`nohup` child is still alive
|
||||
/// instead of dropping it (which would SIGKILL the child via kill-on-drop).
|
||||
/// Path-qualified `/bin/sleep` is used so it spawns a real external process
|
||||
/// (the bare `sleep` builtin runs in-process and is intentionally not
|
||||
/// counted).
|
||||
/// `sh -c` forces an external process because the bare `sleep` builtin runs
|
||||
/// in-process and is intentionally not counted.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn live_background_job_count_tracks_external_background_jobs() {
|
||||
@@ -4462,7 +4523,7 @@ replace = [{ pattern = "hello", replacement = "HI" }]
|
||||
// An external background process is tracked while it runs.
|
||||
shell
|
||||
.run(
|
||||
ShellRunOptions { command: "/bin/sleep 30 &".into(), ..Default::default() },
|
||||
ShellRunOptions { command: "sh -c 'sleep 30' &".into(), ..Default::default() },
|
||||
None,
|
||||
CancelToken::default(),
|
||||
)
|
||||
@@ -4600,7 +4661,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
let root = unique_temp_dir("heredoc-chain");
|
||||
let minimizer = printf_minimizer(&root.join("minimizer.toml"), None);
|
||||
let (result, output) = run_command_capture(
|
||||
"/bin/cat <<'PY'\nhello $USER\nPY\nprintf 'after\\n'",
|
||||
"cat <<'PY'\nhello $USER\nPY\nprintf 'after\\n'",
|
||||
None,
|
||||
Some(minimizer),
|
||||
CancelToken::default(),
|
||||
@@ -4805,7 +4866,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
// `printf '%d\n' "$$"` then `sleep 0.5`. Long enough for our `getsid`.
|
||||
let exec = session
|
||||
.shell
|
||||
.run_string("/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, ¶ms)
|
||||
.run_string("sh -c 'printf \"%d\\n\" \"$$\"; sleep 0.5'", &source_info, ¶ms)
|
||||
.await
|
||||
.expect("run_string");
|
||||
drop(params);
|
||||
@@ -4870,7 +4931,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
shell_b
|
||||
.run(
|
||||
ShellRunOptions {
|
||||
command: "/bin/sh -c 'printf \"ready\\n\"; sleep 30'".into(),
|
||||
command: "sh -c 'printf \"ready\\n\"; sleep 30'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tx_b),
|
||||
@@ -4902,7 +4963,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
shell_a
|
||||
.run(
|
||||
ShellRunOptions {
|
||||
command: "/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 2'".into(),
|
||||
command: "sh -c 'printf \"%d\\n\" \"$$\"; sleep 2'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
Some(tx_a),
|
||||
@@ -4963,9 +5024,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
let escaped_pid_path = pid_path.to_string_lossy().replace('\'', "'\\''");
|
||||
std::fs::write(
|
||||
&snapshot_path,
|
||||
format!(
|
||||
"/bin/sh -c 'printf \"%d\\n\" \"$$\" > \"$1\"; sleep 30' sh '{escaped_pid_path}'\n"
|
||||
),
|
||||
format!("sh -c 'printf \"%d\\n\" \"$$\" > \"$1\"; sleep 30' sh '{escaped_pid_path}'\n"),
|
||||
)
|
||||
.expect("write snapshot file");
|
||||
|
||||
@@ -5018,7 +5077,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
|
||||
let child_dead = time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
// SAFETY: `child_pid` came from the foreground `/bin/sh` spawned by the
|
||||
// SAFETY: `child_pid` came from the foreground `sh` spawned by the
|
||||
// snapshot; `kill(pid, 0)` only probes whether that process still exists.
|
||||
let kill_result = unsafe { libc::kill(child_pid, 0) };
|
||||
if kill_result == -1 {
|
||||
@@ -5108,14 +5167,14 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
|
||||
let shell_handle = tokio::spawn(async move {
|
||||
let source_info = SourceInfo::from("pi-natives:test");
|
||||
// First stage prints its own PID and sleeps; `cat` forwards the PID
|
||||
// line to our reader and exits on EOF. The first stage leads the
|
||||
// pipeline's process group, the second (`cat`) is the join-or-detach
|
||||
// stage that would EPERM without the wiring fix.
|
||||
// First stage prints its own PID and sleeps; `sh -c cat` forwards
|
||||
// the PID line to our reader and exits on EOF. The first stage
|
||||
// leads the pipeline's process group, while the second stage is the
|
||||
// join-or-detach process that would EPERM without the wiring fix.
|
||||
let exec = session
|
||||
.shell
|
||||
.run_string(
|
||||
"/bin/sh -c 'printf \"%d\\n\" \"$$\"; sleep 1' | /bin/cat",
|
||||
"sh -c 'printf \"%d\\n\" \"$$\"; sleep 1' | sh -c cat",
|
||||
&source_info,
|
||||
¶ms,
|
||||
)
|
||||
@@ -5170,7 +5229,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_accepts_last_background_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "/bin/sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(),
|
||||
command: "sh -c 'exit 7' & mover=$!; wait \"$mover\"".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -5187,9 +5246,9 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_n_p_records_completed_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "/bin/sh -c 'sleep 0.2; exit 42' & slow=$!; /bin/sh -c 'exit 13' & fast=$!; \
|
||||
wait -n -p hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" \
|
||||
-eq 13 ] && [ \"$hit\" = \"$fast\" ]"
|
||||
command: "sh -c 'sleep 0.2; exit 42' & slow=$!; sh -c 'exit 13' & fast=$!; wait -n -p \
|
||||
hit \"$slow\" \"$fast\"; status=$?; wait \"$slow\"; [ \"$status\" -eq 13 ] && \
|
||||
[ \"$hit\" = \"$fast\" ]"
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -5207,7 +5266,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn wait_f_accepts_process_id() {
|
||||
let options = ShellExecuteOptions {
|
||||
command: "/bin/sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(),
|
||||
command: "sh -c 'exit 5' & child=$!; wait -f \"$child\"".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -5350,13 +5409,9 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn quoted_heredoc_without_trailing_newline_runs() {
|
||||
let (result, output) = run_command_capture(
|
||||
"/bin/cat <<'PY'\nhello $USER\nPY",
|
||||
None,
|
||||
None,
|
||||
CancelToken::default(),
|
||||
)
|
||||
.await;
|
||||
let (result, output) =
|
||||
run_command_capture("cat <<'PY'\nhello $USER\nPY", None, None, CancelToken::default())
|
||||
.await;
|
||||
|
||||
assert_eq!(result.exit_code, Some(0));
|
||||
assert_eq!(output, "hello $USER\n");
|
||||
@@ -5397,7 +5452,7 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
let command = if cfg!(windows) {
|
||||
"nohup cmd /C exit 7"
|
||||
} else {
|
||||
"nohup /bin/sh -c 'exit 7'"
|
||||
"nohup sh -c 'exit 7'"
|
||||
};
|
||||
let options = ShellExecuteOptions { command: command.to_string(), ..Default::default() };
|
||||
let result = execute_shell(options, None, CancelToken::default())
|
||||
@@ -5415,8 +5470,8 @@ replace = [{ pattern = "^.+$", replacement = "PWD" }]
|
||||
async fn nohup_background_captures_operand_pid() {
|
||||
let (tx, rx) = flume::unbounded::<String>();
|
||||
let options = ShellExecuteOptions {
|
||||
command: "nohup /bin/sh -c 'exit 0' >/dev/null 2>&1 & pid=$!; printf 'pid=%s\n' \
|
||||
\"$pid\"; test -n \"$pid\""
|
||||
command: "nohup sh -c 'exit 0' >/dev/null 2>&1 & pid=$!; printf 'pid=%s\n' \"$pid\"; \
|
||||
test -n \"$pid\""
|
||||
.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
+18
-7
@@ -84,12 +84,16 @@ fn translate_unix_drive_path(path: &Path) -> Option<PathBuf> {
|
||||
let bytes = raw.as_bytes();
|
||||
let (drive, tail) = drive_alias_parts(bytes)?;
|
||||
|
||||
// `tail` is a suffix of the valid UTF-8 `raw` beginning at an ASCII `/`
|
||||
// boundary, so it is itself valid UTF-8. Translate separators per `char` —
|
||||
// iterating bytes would split multibyte scalars (e.g. `José` → `José`).
|
||||
let tail = std::str::from_utf8(tail).ok()?;
|
||||
let mut native = String::with_capacity(3 + tail.len());
|
||||
native.push(char::from(drive).to_ascii_uppercase());
|
||||
native.push(':');
|
||||
native.push('\\');
|
||||
for &byte in tail {
|
||||
native.push(if is_path_separator(byte) { '\\' } else { char::from(byte) });
|
||||
for ch in tail.chars() {
|
||||
native.push(if ch == '/' || ch == '\\' { '\\' } else { ch });
|
||||
}
|
||||
Some(PathBuf::from(native))
|
||||
}
|
||||
@@ -119,11 +123,6 @@ fn drive_alias_parts(bytes: &[u8]) -> Option<(u8, &[u8])> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
const fn is_path_separator(byte: u8) -> bool {
|
||||
byte == b'/' || byte == b'\\'
|
||||
}
|
||||
|
||||
pub use super::platform::fs::*;
|
||||
|
||||
/// Extension trait for path-related filesystem operations.
|
||||
@@ -189,6 +188,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_alias_tail_preserves_non_ascii_components() {
|
||||
assert_eq!(
|
||||
translate_unix_drive_path(Path::new("/c/Users/José/file")).as_deref(),
|
||||
Some(Path::new("C:\\Users\\José\\file")),
|
||||
);
|
||||
assert_eq!(
|
||||
translate_unix_drive_path(Path::new("/mnt/d/项目/データ")).as_deref(),
|
||||
Some(Path::new("D:\\项目\\データ")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_drive_alias_roots_report_consumed_components() {
|
||||
assert_eq!(
|
||||
|
||||
+1
-2
@@ -393,8 +393,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_executable_returns_input_unchanged() {
|
||||
// /bin/sh exists and is executable on every supported Unix host.
|
||||
let path = PathBuf::from("/bin/sh");
|
||||
let path = std::env::current_exe().expect("current test executable");
|
||||
let resolved = resolve_executable(path.clone());
|
||||
assert_eq!(resolved.as_deref(), Some(path.as_path()));
|
||||
}
|
||||
|
||||
@@ -294,12 +294,14 @@ Fields:
|
||||
|
||||
## Subagents
|
||||
|
||||
`advisor.subagents` controls whether spawned task/eval subagents also get an advisor runtime.
|
||||
Subagents run unadvised by default; advisors are opted in **per agent** instead of via a blanket toggle:
|
||||
|
||||
- `false` (default): only the main session can run an advisor.
|
||||
- `true`: eligible subagent sessions build their own advisor subsystem with the same settings/model-role resolution, then rerun both `WATCHDOG.md` and `WATCHDOG.yml` discovery for that subagent session's `cwd` and agent directory.
|
||||
- Agent definition frontmatter `advisor`: `true` advises spawned sessions of that agent with the model resolved for the `advisor` role; a string (e.g. `advisor: "deepseek/deepseek-v4-flash"` or `advisor: "@smol:high"`) sets an explicit advisor model pattern with an optional `:level` thinking suffix.
|
||||
- The `task.agentAdvisor` settings record (agent name → `"on"` / `"off"` / model pattern) overrides the frontmatter, and is configured per agent from the `/agents` hub: Enter on an agent opens its property strip; the advisor strip offers on/off, a model-browser pick, or a raw pattern.
|
||||
|
||||
Subagent advisors remain isolated from the subagent's primary tool session in the same way the main advisor is isolated from the main agent.
|
||||
The legacy `advisor.subagents: true` setting migrates to `task.agentAdvisor: { task: "on" }` — the bundled generic `task` agent keeps its advisor, other agents start unadvised.
|
||||
|
||||
An advised subagent session builds its own advisor subsystem with the same settings/model-role resolution (an explicit pattern lands on the spawned session's `modelRoles.advisor`), then reruns both `WATCHDOG.md` and `WATCHDOG.yml` discovery for that subagent session's `cwd` and agent directory. Subagent advisors remain isolated from the subagent's primary tool session in the same way the main advisor is isolated from the main agent.
|
||||
|
||||
## Cost and context behavior
|
||||
|
||||
@@ -319,7 +321,7 @@ The advisor is a passive reviewer with its own model usage, so — like a task s
|
||||
|
||||
- legacy/default advisor: `<session>/__advisor.jsonl`
|
||||
- named advisor: `<session>/__advisor.<slug>.jsonl`
|
||||
- subagent advisor (`advisor.subagents: true`): `<session>/<SubId>/__advisor[.<slug>].jsonl`
|
||||
- subagent advisor (frontmatter `advisor` / `task.agentAdvisor`): `<session>/<SubId>/__advisor[.<slug>].jsonl`
|
||||
|
||||
Paths derive from the owning session file (not the shared artifacts root), so each primary/subagent advisor writes a distinct file. The reserved `__advisor` stem cannot collide with a task subagent id.
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ The per-command child environment is then built by `buildNonInteractiveEnv()` (`
|
||||
|
||||
- pagers disabled (`PAGER=cat`, `GIT_PAGER=cat`, … and `LESS=FRX`),
|
||||
- editor prompts disabled (`GIT_EDITOR=true`, `EDITOR=true`, `VISUAL=true`),
|
||||
- terminal/credential prompts reduced (`TERM=dumb`, `GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS=/usr/bin/false`, `NO_COLOR=1`, `CI=1`),
|
||||
- terminal/credential prompts reduced (`TERM=dumb`, `GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS=/usr/bin/false`, `NO_COLOR=1`, `CI=true` unless `PI_BASH_NO_CI`/`CLAUDE_BASH_NO_CI` is set),
|
||||
- package-manager/tooling automation flags for non-interactive behavior (npm/pnpm/yarn/pip/cargo/terraform/gh, …),
|
||||
- on Windows, UTF-8 locale/codepage defaults are added when absent.
|
||||
|
||||
|
||||
@@ -112,6 +112,10 @@ Set `collab.webUrl` when the browser UI is hosted separately from the websocket
|
||||
|
||||
## Self-hosting the relay
|
||||
|
||||
The production relay is not currently distributed for self-hosting: its Go source and standalone binaries are not published. The endpoint list below documents the hosted service's network contract, not an installable release.
|
||||
|
||||
For local protocol development, this repository includes a source-available, WebSocket-only stand-in at [`packages/collab-web/scripts/local-relay.ts`](../packages/collab-web/scripts/local-relay.ts). Run `bun run relay` from `packages/collab-web` to listen on `ws://localhost:7466`. It implements `/r/<roomId>` but does not serve the browser client, `/share` blobs, or `/healthz`, so it is not a replacement for the production service.
|
||||
|
||||
The relay is a small content-blind Go service. It keeps no state beyond live connections and exposes:
|
||||
|
||||
- `GET /` — the static collab-web guest client (target of the `/collab` deep link),
|
||||
|
||||
@@ -65,7 +65,7 @@ Put broad, durable project background in `AGENTS.md`. Reserve `RULES.md` for sho
|
||||
| `opencode` | `.config/opencode/AGENTS.md` | User | User file `~/.config/opencode/AGENTS.md` only. |
|
||||
| `github` | `.github/copilot-instructions.md` | User + project | Project file `<cwd>/.github/copilot-instructions.md` only (no ancestor walk-up), plus a user-global `~/.copilot/copilot-instructions.md` (relocate with `COPILOT_HOME`). `AGENTS.md` candidates from `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` are also considered at user scope, where normal one-user-file deduplication applies. |
|
||||
| `agents` | `.agent/AGENTS.md`, `.agents/AGENTS.md` | User + project | User files from `~/.agent/` and `~/.agents/`; project files discovered while walking up from the current directory to the repository root. |
|
||||
| `agents-md` | `AGENTS.md` | Project | Standalone (non-config-directory) `AGENTS.md` files, discovered by walking up from the current directory to the repository root (or home when no repo root is known). Files whose parent directory name starts with `.` are ignored — those belong to a config-directory provider instead. |
|
||||
| `agents-md` | `AGENTS.md` | Project | Standalone (non-config-directory) `AGENTS.md` files, discovered by walking up from the current directory to the repository root and, when that repository is nested under the user's home directory, through enclosing workspace directories up to but not including the home directory. With no repository root, discovery uses the home directory as the boundary for sessions under home and includes that boundary file. Files whose parent directory name starts with `.` are ignored — those belong to a config-directory provider instead. |
|
||||
| `github` | `.github/instructions/**/*.instructions.md` | Project rules | GitHub Copilot / VS Code instruction files become rules. `applyTo: '*'`, `applyTo: '**'`, or `applyTo: '**/*'` is injected as always-apply content; other `applyTo` globs are listed in the rulebook with a generated description when needed and are readable as `rule://<name>`. Missing `applyTo` also produces a rulebook entry and a discovery warning. |
|
||||
|
||||
Providers marked "(no ancestor walk-up)" only look in the current working directory's config directory. If you need ancestor walk-up behavior, prefer the native `.omp/AGENTS.md` format or a standalone `AGENTS.md` (the `agents-md` provider), or launch `omp` from the directory that holds the config directory.
|
||||
|
||||
+1
-1
@@ -251,7 +251,7 @@ Cancelable pre-events:
|
||||
- `agent_start` / `agent_end` — agent loop lifecycle notification; `agent_end` remains notification-only
|
||||
- `session_stop` — main-session stop hook, awaited before settle; may continue with `{ continue: true, additionalContext }` or `{ decision: "block", reason }`; capped at 8 consecutive continuations and never fires for task/subagent sessions
|
||||
- `turn_start` / `turn_end`
|
||||
- `message_start` / `message_update` / `message_end`
|
||||
- `message_start` / `message_update` / `message_end` — lifecycle notifications; `message_end` receives a detached message snapshot, so use `tool_result` or `context` when an extension needs to change provider context
|
||||
|
||||
### Tool lifecycle
|
||||
|
||||
|
||||
@@ -136,6 +136,8 @@ hindsight:
|
||||
|
||||
By default, Hindsight uses `per-project-tagged` scoping: writes go to a shared bank with a project tag, while recall includes project-tagged and untagged global memories. `per-project` isolates each working-directory project in its own bank; `global` uses one shared bank. An explicit `hindsight.bankId` selects the bank base. Changes to the bank ID, prefix, or scoping rebuild the primary session state so later operations use the new scope.
|
||||
|
||||
Both project-scoped modes name the project the same way: take the repository's primary checkout root (so every linked worktree of one repository resolves to the same directory), then lowercase its basename. A checkout at `~/code/General` therefore tags `project:general`. Tags are matched literally, so this fold is what keeps one repository in one memory scope no matter how the path is capitalised.
|
||||
|
||||
The primary session recalls on its first model turn (`hindsight.autoRecall: true`) and automatically retains completed conversation turns every three user turns by default. `/memory enqueue` flushes queued tool retains and forces retention of the current session. At agent end, the primary state schedules cadence-based retention and flushes the retain queue; session disposal drains that queue before releasing the state. Request failures and configured timeouts are logged and leave the coding session usable. Subagents alias the parent's client, bank, and scope for explicit `recall`, `retain`, and `reflect` calls, but do not run their own automatic recall or retention.
|
||||
|
||||
Recall is injected as background context, not instructions, and recalled memory is also available as extra context during compaction. Selecting Hindsight exposes `recall`, `retain`, and `reflect`; `memory_edit` is not available because upstream Hindsight memories are not edited through this backend.
|
||||
|
||||
+3
-1
@@ -61,6 +61,7 @@ providers:
|
||||
api: openai-completions
|
||||
reasoning: false
|
||||
input: [text]
|
||||
imageInputDecoder: stb # local STB decoder; OMP converts WebP before dispatch
|
||||
cost:
|
||||
input: 0
|
||||
output: 0
|
||||
@@ -101,6 +102,7 @@ providers:
|
||||
- `auth`: `apiKey` (default), `none`, or `oauth`; for `models.yml` custom models, `oauth` is accepted by schema but does not waive the `apiKey` requirement
|
||||
- `discovery.type`: `ollama`, `llama.cpp`, `lm-studio`, `openai-models-list`, `proxy`, or `litellm`
|
||||
- `transport`: `pi-native` only. When set, every model under that provider is sent to an `omp auth-gateway` compatible `baseUrl` via `POST /v1/pi/stream`; `apiKey` is the gateway bearer.
|
||||
- `imageInputDecoder`: `stb` only. Set this on a custom model or `modelOverrides` entry when the serving backend uses an STB-compatible image decoder that cannot accept WebP; OMP converts attached and historical WebP images before provider dispatch.
|
||||
|
||||
## Validation rules (current)
|
||||
|
||||
@@ -190,7 +192,7 @@ Provider defaults vs per-model overrides:
|
||||
|
||||
- Provider `headers`, `compat`, and `remoteCompaction` are baselines.
|
||||
- Model `headers` override provider header keys.
|
||||
- `modelOverrides` can override model metadata (`name`, `reasoning`, `thinking`, `input`,
|
||||
- `modelOverrides` can override model metadata (`name`, `reasoning`, `thinking`, `input`, `imageInputDecoder`,
|
||||
`supportsTools`, `cost`, `premiumMultiplier`, `contextWindow`, `maxTokens`,
|
||||
`omitMaxOutputTokens`, `headers`, `compat`, `contextPromotionTarget`, `compactionModel`, and
|
||||
`remoteCompaction`).
|
||||
|
||||
@@ -64,7 +64,7 @@ Notes:
|
||||
|
||||
This mirrors the old cargo `ci` profile. Because the profile lives **in the transition**, a bare `bazel build //:natives-<t>` is always release-grade regardless of `-c`, and every addon shares one cache entry per (platform, source) pair. The rule then symlinks the produced shared library to the loader's canonical `pi_natives.<platform>-<arch>[-<variant>].node` name, scoped under the rule name (`bazel-bin/natives-<t>/…`) so gnu/musl outputs with identical basenames cannot collide at the package level.
|
||||
|
||||
Per-target codegen that is not part of the transition lives in `crates/pi-natives/BUILD.bazel` `rustc_flags` selects: `-Ctarget-cpu=x86-64-v2` (baseline) / `x86-64-v3` (modern) via `//bazel/variants`, the napi link args (`-Wl,-undefined,dynamic_lookup` on macOS, `-Wl,-z,nodelete` on linux — `build.rs`/`napi_build::setup()` is deliberately not wired in), and `-Ctarget-feature=-crt-static` for musl.
|
||||
Per-target codegen that is not part of the transition lives in `crates/pi-natives/BUILD.bazel` `rustc_flags` selects: `-Ctarget-cpu=x86-64-v2` (baseline) / `x86-64-v3` (modern) via `//bazel/variants`, the napi link args (`-Wl,-undefined,dynamic_lookup` on macOS, `-Wl,-z,nodelete` on linux — `build.rs`/`napi_build::setup()` is deliberately not wired in), `-Ctarget-feature=-crt-static` for musl, and `-Ctarget-feature=+crt-static` for win32-x64 msvc (paired with the `static_link_msvcrt` cc feature enabled in the `native_addon` transition so the C deps compile `/MT` in lock-step — the shipped `.node` then imports no `VCRUNTIME140.dll` from the VC++ Redistributable).
|
||||
|
||||
### 3) Platforms and toolchains
|
||||
|
||||
@@ -73,7 +73,7 @@ Per-target codegen that is not part of the transition lives in `crates/pi-native
|
||||
| linux gnu (x64/arm64) | `@zig_sdk//libc_aware/toolchain:linux_*_gnu.2.17` (hermetic zig cc) | glibc **2.17** portability floor — same floor the previous cross builds used |
|
||||
| linux musl (x64/arm64) | `@zig_sdk//libc_aware/toolchain:linux_*_musl` | dynamic CRT (`-Ctarget-feature=-crt-static` in the crate BUILD) |
|
||||
| darwin (x64/arm64) | host Xcode toolchain | Apple frameworks aren't redistributable; darwin addons build on mac hosts only |
|
||||
| win32-x64 msvc | `//bazel/toolchains/msvc` (`@msvc_cc`): clang-cl + lld-link + xwin CRT/SDK | hermetic cross-link from linux-x64 CI pods and darwin dev hosts; see `bazel/toolchains/msvc/NOTES.md` |
|
||||
| win32-x64 msvc | `//bazel/toolchains/msvc` (`@msvc_cc`): clang-cl + lld-link + xwin CRT/SDK | hermetic cross-link from linux-x64 CI pods and darwin dev hosts; **static CRT** (`+crt-static` + `static_link_msvcrt`) so the addon needs no VC++ Redistributable; see `bazel/toolchains/msvc/NOTES.md` |
|
||||
|
||||
Rust toolchains are nightly (pinned in `MODULE.bazel`), with repo-local musl re-registrations in `//bazel/toolchains` carrying an explicit `@zig_sdk//libc:musl` constraint (rules_rust's generated gnu and musl toolchains otherwise share (os, cpu) constraints).
|
||||
|
||||
@@ -217,7 +217,7 @@ bazelisk build --nobuild //:natives-win32-x64-baseline
|
||||
| rstest macro: "Cargo.toml not found" in a vendored test | rstest verifies `Cargo.toml` exists in the manifest dir | `compile_data = ["Cargo.toml"]` on the `rust_test` (see `crates/vendor/uu-tail/BUILD.bazel`) |
|
||||
| vendored tests fail on bare `test_data/...` paths / symlink into srcs | tests assume cargo's cwd, incompatible with runfiles execution | `tags = ["manual"]`; run via `cargo nextest` when touching the fork; hermetic sibling test covers the contract |
|
||||
| blake3 msvc: `ml64.exe` not found | cc-rs resolves MASM from build-script PATH on non-windows hosts | `bin/ml64.exe → llvm-ml -m64` shim in `@msvc_cc`, prepended via the `blake3` annotation PATH |
|
||||
| audiopus_sys msvc: cmake demands VS generator / rc+mt tools; `try_compile` wants `msvcrtd.lib` | cross cmake on linux/mac hosts; Debug config → `/MDd` which the lean xwin splat lacks | `CMAKE_GENERATOR_x86_64_pc_windows_msvc=Ninja` + `@msvc_cc`'s `toolchain.cmake` (`CMAKE_TOOLCHAIN_FILE_x86_64_pc_windows_msvc`) pinning wrappers + Release try-compile + `/MD` |
|
||||
| audiopus_sys msvc: cmake demands VS generator / rc+mt tools; `try_compile` wants `msvcrtd.lib` | cross cmake on linux/mac hosts; Debug config → `/MDd` which the lean xwin splat lacks | `CMAKE_GENERATOR_x86_64_pc_windows_msvc=Ninja` + `@msvc_cc`'s `toolchain.cmake` (`CMAKE_TOOLCHAIN_FILE_x86_64_pc_windows_msvc`) pinning wrappers + Release try-compile + `/MT` (static CRT, matches the addon policy) |
|
||||
| win32 link oddities generally | — | read `bazel/toolchains/msvc/NOTES.md` first: wrapper self-location, `lld-link` flavor/driver-link behavior, `LIB`, `/MD` CRT choice, xwin splat caveats |
|
||||
| `rust_test(crate = ...)` "can't find crate" at macro expansion | rmeta-only pipelined deps break macro_rules re-export harness compiles | rust pipelined_compilation stays OFF (`.bazelrc` note) |
|
||||
| build script can't find cmake/ninja | `--incompatible_strict_action_env` — no host env leaks | explicit `PATH` in the crate annotation (`MODULE.bazel`), not host env |
|
||||
|
||||
@@ -109,7 +109,7 @@ Malformed `package.json` JSON is a hard failure at read time; malformed manifest
|
||||
- `[a,b]`: validates each feature exists in manifest features map
|
||||
- `[]`: empty feature list
|
||||
- bare spec: `null` (use defaults policy later in loader)
|
||||
7. Validate declared extension entries (`#validateInstalledExtensions`): each manifest `extensions` entry must resolve on disk and import to a factory function. On failure, roll back the install — restore the previous `plugins/package.json`, remove the freshly installed package, and restore any prior version from a backup taken before `bun install` — then abort.
|
||||
7. Validate declared extension entries (`#validateInstalledExtensions`): each manifest `extensions` entry must resolve on disk, import to a factory function, and initialize successfully against a throwaway registration surface. On failure, roll back the install — restore the previous `plugins/package.json`, remove the freshly installed package, and restore any prior version from a backup taken before `bun install` — then abort.
|
||||
8. Upsert lockfile runtime state: `{ version, enabledFeatures, enabled: true }`.
|
||||
|
||||
### Update semantics
|
||||
|
||||
@@ -102,7 +102,6 @@ Types: `OpenAICompat` / `ResolvedOpenAISharedCompat` in `packages/catalog/src/ty
|
||||
| `stripDeepseekSpecialTokens` | DeepSeek on NVIDIA NIM or direct API | Strips leaked chat-template tokens (`<|User|>`, …) from visible text |
|
||||
| `streamMarkupHealingPattern` | `"kimi"` (Kimi/Moonshot), `"dsml"` (DeepSeek DSML hosts), `"thinking"` (generic compat hosts), unset for official OpenAI | Selects the `StreamMarkupHealing` pattern for leaked template markup |
|
||||
| `emptyLengthFinishIsContextError` | Ollama | Empty completion with `finish_reason: "length"` → context-overflow error |
|
||||
| `enableGeminiThinkingLoopGuard` | Gemini-family model ids | Activates the thinking-loop guard on OpenAI-compat streams (`utils/thinking-loop.ts`) |
|
||||
| `streamFirstEventTimeoutMs` | `0` for local backends | First-event watchdog hint (`0` = unbounded prefill/model-load time) |
|
||||
| `streamIdleTimeoutMs` | GLM/Alibaba coding plans 600 s; MiMo, Kimi reasoning, DeepSeek reasoning, local backends 300 s | Inter-event idle watchdog floor (`stream.ts`) |
|
||||
|
||||
@@ -174,7 +173,7 @@ If a host rejects the emitted effort with 400/422, `resolveOpenAIReasoningEffort
|
||||
- **Structured deltas**: providers emit `thinking_start` / `thinking_delta` / `thinking_end` stream events.
|
||||
- **History replay**: prior thinking is replayed via `reasoningContentField` on assistant messages (KV-cache preservation on DeepSeek/Z.AI/Qwen/local backends); models that demand reasoning content on tool-call turns get real content or a `"."` placeholder per `allowsSyntheticReasoningContentForToolCalls`.
|
||||
- **Leaked thinking healing**: `wrapLeakedThinkingStream` (`utils/leaked-thinking-stream.ts`) converts in-band ` ```thinking ` / `<think>` fences from misbehaving hosts into structured thinking blocks live.
|
||||
- **Loop guard**: `withGeminiThinkingLoopGuard` (`utils/thinking-loop.ts`) detects runaway reasoning (verbatim repeats, near-duplicate trigram clusters, progress-lexicon stalls) and kills the stream with a retryable `AIError.Flag.ThinkingLoop`.
|
||||
- **Loop guard**: `withThinkingLoopGuard` (`utils/thinking-loop.ts`) detects runaway reasoning (verbatim repeats, near-duplicate trigram clusters, progress-lexicon stalls) and kills the stream with a retryable `AIError.Flag.ThinkingLoop`.
|
||||
|
||||
### Interactions
|
||||
|
||||
|
||||
@@ -188,11 +188,11 @@ Google Gemini integrations use REST/SSE over HTTP (`POST https://generativelangu
|
||||
- **`streamGenerateContent` SSE protocol**: Streams are consumed via `readSseJson<GenerateContentResponse>` in `streamGoogleGenAI`.
|
||||
- **Thought parts & signature retention**: `isThinkingPart` identifies reasoning text when `part.thought === true`. Encrypted `part.thoughtSignature` fields are preserved across deltas using `retainThoughtSignature`. In `convertMessages`, thought signatures are retained only when message provider/model match the target (`msg.provider === model.provider && msg.model === model.id`) and pass `isValidThoughtSignature` (base64 check). Gemini 3 tool calls lacking a signature fall back to `SKIP_THOUGHT_SIGNATURE` (`"skip_thought_signature_validator"`).
|
||||
- **Empty response retry loop**: `streamGoogleGenAI` guards against Gemini returning `finishReason: STOP` with blank content without calling tools. `hasMeaningfulGoogleContent` validates output; if empty, `streamGoogleGenAI` retries up to `MAX_EMPTY_STREAM_RETRIES` (2 retries, 3 total attempts) with exponential backoff (`EMPTY_STREAM_BASE_DELAY_MS * 2^attempt`) after resetting stream output via `resetGoogleStreamOutputForRetry`.
|
||||
- **Thinking loop guard**: Implemented in `packages/ai/src/utils/thinking-loop.ts` (`ThinkingLoopDetector`, `isGeminiThinkingModel`). Streams before tool calls are monitored for three runaway shapes:
|
||||
- **Thinking loop guard**: Implemented in `packages/ai/src/utils/thinking-loop.ts` (`ThinkingLoopDetector`). Gemini, DeepSeek, and Grok model-id families are monitored before tool calls for three runaway shapes:
|
||||
1. *Verbatim tail repetition* (`VERBATIM_TAIL_WINDOW = 250`, >= 180 repeated chars).
|
||||
2. *Near-duplicate segments* (trigram Jaccard similarity >= 0.8 across last 16 segments).
|
||||
3. *Progress-lexicon stall* (novelty <= 0.2 without new concrete reference anchors over 8 consecutive segments).
|
||||
Additionally, `GEMINI_HEADER_RUNAWAY_THRESHOLD = 24` halts streams emitting excessive titled reasoning summaries without acting. Triggers emit a synthetic retryable `error` tagged with `AIError.Flag.ThinkingLoop`.
|
||||
4. Gemini's `GEMINI_HEADER_RUNAWAY_THRESHOLD = 24` halts streams emitting excessive titled reasoning summaries without acting. Triggers emit a synthetic retryable `error` tagged with `AIError.Flag.ThinkingLoop`.
|
||||
- **Finish reason mapping & incomplete streams**: `candidate.finishReason` is mapped via `mapStopReason`; `stop`/`length` reasons upgrade to `toolUse` if output contains tool calls. Drops without `finishReason` throw `ProviderResponseError` with `kind: "incomplete-stream"`.
|
||||
- **UsageMetadata accounting**: Attached to trailing chunks in `consumeGoogleStream`. `input` is calculated as `promptTokenCount - (cachedContentTokenCount || 0)`; `output` as `candidatesTokenCount + (thoughtsTokenCount || 0)`; `cacheRead` as `cachedContentTokenCount || 0`; and `reasoningTokens` as `thoughtsTokenCount`. Token costs are computed via `calculateCost(model, output.usage)`.
|
||||
|
||||
@@ -570,7 +570,7 @@ Pi Native is a lossless internal server/client transport protocol used when a pi
|
||||
- **Idle & First-Event Watchdogs**: Client wraps SSE streams with `iterateWithIdleTimeout` using `PI_STREAM_FIRST_EVENT_TIMEOUT_MS` and `PI_STREAM_IDLE_TIMEOUT_MS`. `isPiNativeProgressEvent` in `packages/ai/src/providers/pi-native-client.ts` ignores `type: "start"` events so initial setup does not reset the idle timeout.
|
||||
- **Synthetic Terminal Boundaries**: If the SSE stream closes without a `done` or `error` event, client's `streamPiNative` constructs a synthetic assistant message via `makeSyntheticAssistant`. It pushes `{ type: "error", reason: "aborted", error: { ..., stopReason: "aborted", errorMessage: "stream closed without terminal event" } }` if caller aborted, or `{ type: "done", reason: "stop", message: { ..., stopReason: "stop" } }` on ungraceful clean close.
|
||||
- **Server Iterator Exception Fallback**: If the server's `encodeStream` event iterator throws, it enqueues `data: {"type":"error","reason":"error","errorMessage":"..."}\n\n` followed by `data: [DONE]\n\n` so client iterators resolve instead of hanging.
|
||||
- **Gemini Thinking Loop Guard**: `packages/ai/src/stream.ts` `streamSimple` wraps `streamPiNative` with `withGeminiThinkingLoopGuard` and `withProviderInFlightLimit`, ensuring degenerate Gemini thinking loops abort with empty-content retryable errors.
|
||||
- **Thinking loop guard**: `packages/ai/src/stream.ts` `streamSimple` wraps `streamPiNative` with `withThinkingLoopGuard` and `withProviderInFlightLimit`, ensuring Gemini, DeepSeek, and Grok runaway thinking streams abort with empty-content retryable errors.
|
||||
|
||||
### Auth & usage
|
||||
- **Bearer Token Authorization**: Client (`packages/ai/src/providers/pi-native-client.ts` `buildHeaders`) passes `options.apiKey` (the gateway bearer token) in `Authorization: Bearer <apiKey>`, unless `model.headers.Authorization` is explicitly provided.
|
||||
@@ -1303,7 +1303,7 @@ OpenRouter is a unified multi-provider routing gateway serving hundreds of third
|
||||
- **Provider Order & Exclusion Preferences**: `applyOpenAIGatewayRouting` in `packages/ai/src/providers/openai-shared.ts` injects catalog `openRouterRouting` preferences (`OpenRouterRouting` interface with `only?: string[]` and `order?: string[]`) into the top-level `provider` request parameter when `compat.isOpenRouterHost` is true.
|
||||
- **Anthropic `cache_control` Breakpoints**: `isOpenRouterAnthropicModel` (`packages/ai/src/providers/openai-shared.ts`) identifies models matching `provider === "openrouter"` and ID starting with `anthropic/`. On the Chat Completions wire, `applyOpenAIChatCompletionsPromptCachePolicy` (`openai-completions.ts`) attaches `cache_control: { type: "ephemeral" }` to the last non-empty text part of the latest message. On the Responses wire, `applyOpenAIResponsesPromptCachePolicy` (`openai-responses.ts`) sets `params.cache_control = cacheRetention === "long" ? { type: "ephemeral", ttl: "1h" } : { type: "ephemeral" }`.
|
||||
- **Catalog Default Max-Tokens Omission**: `resolveOpenAIOutputTokenParam` in `packages/ai/src/providers/openai-shared.ts` omits default output token limits (`max_tokens`, `max_completion_tokens`, `max_output_tokens`) when `isOpenRouterHost` is true and `maxTokensExplicit` is false. This prevents OpenRouter from filtering out upstreams whose advertised output ceiling is below catalog maximums when executing `provider.order` / `only` fallbacks; explicitly specified caller `maxTokens` are retained.
|
||||
- **Custom Request Headers**: `getOpenRouterHeaders` in `packages/ai/src/utils/openrouter-headers.ts` attaches `User-Agent: Oh-My-Pi/<ver>`, `HTTP-Referer: https://omp.sh/`, `X-OpenRouter-Title: Oh-My-Pi`, `X-OpenRouter-Categories: cli-agent`, `X-OpenRouter-Cache: true`, and `X-OpenRouter-Cache-TTL: 3600` to all requests for edge response caching.
|
||||
- **Custom Request Headers**: `getOpenRouterHeaders` in `packages/ai/src/utils/openrouter-headers.ts` attaches `User-Agent: omp/<ver>`, `HTTP-Referer: https://omp.sh/`, `X-OpenRouter-Title: omp`, `X-OpenRouter-Categories: cli-agent`, `X-OpenRouter-Cache: true`, and `X-OpenRouter-Cache-TTL: 3600` to all requests for edge response caching.
|
||||
|
||||
### Auth & usage
|
||||
- **Auth Key Validation via `/api/v1/auth/key`**: `loginOpenRouter` in `packages/ai/src/registry/openrouter.ts` configures API key validation using `validateApiKeyAgainstModelsEndpoint` targeted at `https://openrouter.ai/api/v1/auth/key`. Public `/api/v1/models` returns HTTP 200 for unauthenticated requests, so `/api/v1/auth/key` is used as the canonical identity check (returning 200 for valid keys, 401 otherwise). Key resolution checks `OPENROUTER_API_KEY` via `getEnvApiKey` in `packages/ai/src/stream.ts`.
|
||||
|
||||
@@ -24,9 +24,9 @@ It focuses on current implementation behavior, including fallback paths and cave
|
||||
|
||||
`SessionManager` stores file sessions under a canonical-cwd bucket by default:
|
||||
|
||||
- `~/.omp/agent/sessions/<scope>-<project-basename>-<sha256(canonical-cwd)>/*.jsonl`
|
||||
- `~/.omp/agent/sessions/<encoded-cwd>/*.jsonl`
|
||||
|
||||
`scope` is `home`, `tmp`, or `abs`. Legacy relative/absolute bucket names are migrated best-effort. `SessionManager.list(cwd, sessionDir?)` reads only the resolved bucket unless an explicit `sessionDir` is provided.
|
||||
`<encoded-cwd>` is the path-encoded canonical cwd (`-<relative>` under home, `-tmp-<relative>` under the temp root, `--<encoded-absolute>--` otherwise; see [session.md](session.md#on-disk-layout)). Buckets from the reverted 17.2.5-17.2.8 hashed scheme are migrated best-effort. `SessionManager.list(cwd, sessionDir?)` reads only the resolved bucket unless an explicit `sessionDir` is provided.
|
||||
|
||||
### Two listing paths with different payloads
|
||||
|
||||
|
||||
+3
-3
@@ -37,12 +37,12 @@ Does not cover `/tree` UI rendering behavior beyond semantics that affect sessio
|
||||
Default file-session location:
|
||||
|
||||
```text
|
||||
~/.omp/agent/sessions/<scope>-<project-basename>-<sha256(canonical-cwd)>/<timestamp>_<sessionId>.jsonl
|
||||
~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<sessionId>.jsonl
|
||||
```
|
||||
|
||||
`<scope>` is `home`, `tmp`, or `abs`, chosen after canonicalizing cwd (so symlink aliases share a bucket). The readable basename is sanitized and capped at 80 characters; the full canonical cwd digest prevents the collisions possible with the old separator-replacement scheme.
|
||||
`<encoded-cwd>` is derived from the canonicalized cwd (so symlink aliases share a bucket): `-<relative>` for directories under home, `-tmp-<relative>` for directories under the temp root, and `--<encoded-absolute>--` for anything else, with path separators replaced by `-`.
|
||||
|
||||
On access, the old home-relative (`-<relative>`), temp-relative (`-tmp-<relative>`), and absolute (`--<encoded-absolute>--`) buckets are migrated into the hashed bucket best-effort. Colliding legacy buckets are split by the cwd recorded in each session header before migration.
|
||||
On access, buckets written by the short-lived hashed scheme (`<scope>-<project-basename>-<sha256(canonical-cwd)>`, used in 17.2.5-17.2.8 and reverted in 17.2.9 by #7397) are migrated back into the path-encoded names best-effort, along with older `--<home-encoded>-*--` spellings of home-relative buckets.
|
||||
|
||||
Blob store location:
|
||||
|
||||
|
||||
+1
-1
@@ -374,7 +374,7 @@ See [Advisor and WATCHDOG.md](./advisor-watchdog.md) for runtime behavior, `WATC
|
||||
| Key | Type | Default | Notes |
|
||||
| --------------------- | ------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `advisor.enabled` | boolean | `false` | Enable the advisor runtime when `modelRoles.advisor` resolves to an available model. |
|
||||
| `advisor.subagents` | boolean | `false` | Also enable advisor runtimes for spawned task/eval subagents. |
|
||||
| `task.agentAdvisor` | record | `{}` | Per-agent subagent advisor: agent name → `"on"` / `"off"` / advisor model pattern. Overrides agent frontmatter `advisor`; configured from the `/agents` hub. |
|
||||
| `advisor.syncBacklog` | enum | `off` | Bounded advisor catch-up delay: `off`, `1`, `3`, or `5`. The primary waits up to 30 seconds only while advisor backlog is at or above the threshold. |
|
||||
| `advisor.immuneTurns` | number | `3` | After a `concern`/`blocker` interrupts, route further concerns/blockers as non-interrupting asides for this many completed primary turns. |
|
||||
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ No fallback search is performed for missing assets.
|
||||
- **Skills**: named, optional capability packs selected by task context or explicitly requested
|
||||
- **AGENTS.md/context files**: persistent instruction files loaded as context-file capability and merged by level/depth rules
|
||||
|
||||
`src/discovery/agents-md.ts` specifically walks ancestor directories from `cwd` to discover standalone `AGENTS.md` files (stopping at the repo root, or home when no repo root is known), skipping files whose containing directory name starts with a dot.
|
||||
`src/discovery/agents-md.ts` walks ancestor directories from `cwd` to discover standalone `AGENTS.md` files. For repositories nested under the user's home directory, it continues through enclosing workspace directories up to but not including the home directory. With no repository root under home, the home boundary remains included. Otherwise it stops at the repository root, or at the filesystem root when no repository root is known outside home. Files in hidden owner directories are skipped.
|
||||
|
||||
### Skills vs slash commands
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ It covers runtime behavior as implemented today, including precedence, invalid-d
|
||||
Task agents normalize into `AgentDefinition` (`src/task/types.ts`):
|
||||
|
||||
- required `name`, `description`, and `systemPrompt`
|
||||
- optional `tools`, `spawns`, prioritized `model` list, `thinkingLevel`, `output`, `blocking`, `autoloadSkills`, `readSummarize`, `prewalk`
|
||||
- optional `tools`, `spawns`, prioritized `model` list, `thinkingLevel`, `output`, `blocking`, `autoloadSkills`, `readSummarize`, `prewalk`, `advisor`
|
||||
- `source`: `"bundled" | "user" | "project"` (extension agents are tagged with their extension root's project/user level)
|
||||
- optional `filePath`
|
||||
|
||||
@@ -43,7 +43,8 @@ Parsing comes from frontmatter via `parseAgentFields()` (`src/discovery/helpers.
|
||||
- `thinking-level` / `thinking` selects the agent's configured effort. When `task.enableEffort` (default `false`) exposes it, a task item's coarse `effort` (`lo`, `med`, `hi`) takes precedence at launch. OMP maps that hint to the selected model's lowest, middle, or highest supported effort, then clamps it to `task.maxEffort` (default `max`). The ceiling is carried across retry-fallback model switches. If the selected model has no supported effort at or below the ceiling, the spawn fails; models without a controllable effort surface instead fall back to their normal selector.
|
||||
- `blocking: true` makes the parent wait for that agent even when async task execution is enabled
|
||||
- `autoloadSkills` names skills from the parent session to inject before the first child prompt; unknown names are ignored
|
||||
- `prewalk: true` starts the subagent on its resolved model and hands off to the default prewalk target (the `smol` role) at its first edit/write, exactly like the session-level `--prewalk`; a string value (e.g. `prewalk: "@smol"` or `prewalk: "openai/gpt-5-mini"`) picks a custom target. The `task.agentPrewalk` settings record (agent name → `"on"` / `"off"` / pattern, toggled per agent from `/agents` with `P`) overrides the frontmatter. Resolution happens in `runSubprocess` (`src/task/executor.ts`). An unavailable target is skipped instead of failing the spawn. A resolved target is skipped only when both its model identity and its effective thinking mode/level match the starting selection after model clamping; a same-model effort downgrade is a real hand-off and still arms and switches at the first edit/write.
|
||||
- `prewalk: true` starts the subagent on its resolved model and hands off to the default prewalk target (the `smol` role) at its first edit/write, exactly like the session-level `--prewalk`; a string value (e.g. `prewalk: "@smol"` or `prewalk: "openai/gpt-5-mini"`) picks a custom target. The `task.agentPrewalk` settings record (agent name → `"on"` / `"off"` / pattern, configured per agent from the `/agents` hub via its prewalk strip) overrides the frontmatter. Resolution happens in `runSubprocess` (`src/task/executor.ts`). An unavailable target is skipped instead of failing the spawn. A resolved target is skipped only when both its model identity and its effective thinking mode/level match the starting selection after model clamping; a same-model effort downgrade is a real hand-off and still arms and switches at the first edit/write.
|
||||
- `advisor: true` pairs spawned sessions of the agent with an advisor running the model resolved for the `advisor` role; a string value (e.g. `advisor: "deepseek/deepseek-v4-flash"` or `advisor: "@smol:high"`) sets an explicit advisor model pattern (optional `:level` suffix), applied as the spawned session's `modelRoles.advisor`. The `task.agentAdvisor` settings record (agent name → `"on"` / `"off"` / pattern, configured per agent from the `/agents` hub via its advisor strip) overrides the frontmatter. Resolution happens in `runSubprocess` (`src/task/executor.ts`); subagents default to no advisor, and the effective opt-in is persisted in `session_init` so cold revival restores it.
|
||||
|
||||
## Role-backed custom agents
|
||||
|
||||
|
||||
+2
-1
@@ -94,7 +94,7 @@ Artifacts and side channels:
|
||||
9. If `isolated`, it requires a git repo (`getRepoRoot(...)` / `captureBaseline(...)`), maps `task.isolation.mode` to a backend-kind hint (`parseIsolationMode`), and materializes the workspace via the natives PAL (`ensureIsolation` → `isoResolve`/`isoStart`), walking the candidate list when a backend is unavailable.
|
||||
10. Artifacts dir comes from the parent session file when available, otherwise a temp dir. When the session is executing an approved plan, the plan reference is handed to the subagent.
|
||||
11. Non-isolated spawns call `runSubprocess(...)` directly with parent cwd; isolated spawns run inside the isolation workspace, then commit to a branch (`mergeMode === "branch"`) or capture a patch, and always clean up the workspace.
|
||||
12. `runSubprocess(...)` creates a child agent session with an isolated settings snapshot (parent settings inherited — `async.enabled` and `bash.autoBackground.enabled` are **inherited** from the parent, not force-disabled; `tier.openai`/`tier.anthropic`/`tier.google` are re-resolved through `tier.subagent`; `tools.approvalMode` is forced to `yolo` because headless subagents have no UI to confirm prompts against; per-spawn overrides may disable read summarization and clear extra workspace roots for isolated runs), child `agentId` equal to the allocated id, child internal URL router/`AgentOutputManager`, output schema, the shared `context` (batch calls) in the system prompt's `CONTEXT` section, and the IRC peer roster in the system prompt.
|
||||
12. `runSubprocess(...)` creates a child agent session with an isolated settings snapshot (parent settings inherited — `async.enabled` and `bash.autoBackground.enabled` are **inherited** from the parent, not force-disabled; `tier.openai`/`tier.anthropic`/`tier.google` are re-resolved through `tier.subagent`; `tools.approvalMode` is forced to `yolo` because headless subagents have no UI to confirm prompts against; `advisor.enabled` is forced off unless the spawn opts in per agent; per-spawn overrides may disable read summarization and clear extra workspace roots for isolated runs), child `agentId` equal to the allocated id, child internal URL router/`AgentOutputManager`, output schema, the shared `context` (batch calls) in the system prompt's `CONTEXT` section, and the IRC peer roster in the system prompt.
|
||||
13. Child tool availability: explicit `agent.tools` if provided; auto-add `task` when the agent has `spawns` and depth allows; strip `task` at `task.maxRecursionDepth`; ensure `hub` is present in explicit tool lists; expand `exec` to `eval` + `bash`; strip parent-owned `todo` — unless the spawn is prewalk-armed, whose plan nudge + todo gate need the child to commit its own todo list before the model hand-off.
|
||||
14. The child must finish through the hidden `yield` tool; up to 3 reminder prompts, the last forcing `toolChoice = yield` when supported. `finalizeSubprocessOutput(...)` reconciles raw text, `yield` payloads, structured schemas, and abort states.
|
||||
15. End-of-run lifecycle (keep-alive, in the run finalizer):
|
||||
@@ -115,6 +115,7 @@ Artifacts and side channels:
|
||||
- Isolation merge strategy: patch mode (capture/apply root patches) or branch mode (commit to `omp/task/<id>`, cherry-pick into parent).
|
||||
- Agent source precedence is first-wins by exact name: project `.omp/agents`; user `.omp/agent/agents`; OMP extension-package `agents/` roots in CLI → project settings → user settings → installed npm/link plugin order; Claude marketplace plugin agents (project before user); then bundled (`scout`, `designer`, `reviewer`, `security-reviewer`, `librarian`, `task`, `sonic`).
|
||||
- Prewalk: agent frontmatter `prewalk` or `task.agentPrewalk[agentName]` can start on the normal model and hand off to a cheaper resolved model at the first edit/write. `task.prewalk` (default off) arms this behavior for the bundled generic `task` agent. Missing/unconfigured targets and exact model+effort no-ops skip the handoff rather than failing the spawn.
|
||||
- Advisor: agent frontmatter `advisor` or `task.agentAdvisor[agentName]` (`"on"` / `"off"` / model pattern) pairs the child session with an advisor; an explicit pattern lands on the child's `modelRoles.advisor`. Subagents default to no advisor.
|
||||
|
||||
## Side Effects
|
||||
- Filesystem
|
||||
|
||||
+47
-12
@@ -66,8 +66,20 @@ needs to know whether the user has scrolled away from the tail.
|
||||
- A component tree that reports **no seam** gets shell semantics: whatever
|
||||
scrolls off is final. Shrinking such a frame into its committed prefix
|
||||
re-anchors the window and leaves the stale copy in history (§3).
|
||||
- Inside multiplexers, a resize leaves the pane history wrapped at the old
|
||||
width (same as any shell output).
|
||||
- Inside terminal multiplexers, a width change terminates the physical-row
|
||||
coordinate epoch. The renderer captures an opaque
|
||||
`NativeScrollbackWidthEpoch` marker from the last emitted source state before
|
||||
`SIGWINCH`, then resolves that same logical boundary after the settled-width
|
||||
render. Host-reflowed history stays immutable. Output queued during
|
||||
settlement is emitted only from the resolved old boundary to the current
|
||||
source boundary at the terminal-owned viewport bottom; the settled viewport
|
||||
then repaints in place. No old-width and new-width row counts are compared,
|
||||
and no old viewport row is recommitted. Components without the source
|
||||
contract retain the conservative physical-row fallback. Visible overlays
|
||||
freeze the seam and pinned live regions clip advancement at their final
|
||||
boundary. Height-only resizes retain the existing ledger. Direct HerdR panes
|
||||
use this path because clearing and replaying scrollback flickers in its
|
||||
host-owned pane.
|
||||
|
||||
---
|
||||
|
||||
@@ -81,7 +93,9 @@ needs to know whether the user has scrolled away from the tail.
|
||||
geometry frames). The detector samples the prefix tail (up to 8 non-blank
|
||||
rows in the last 24, SGR-stripped). A single in-place mismatch is accepted
|
||||
as stale history; a structural shift re-anchors at the first changed row,
|
||||
favoring duplication over content loss.
|
||||
favoring duplication over content loss. An in-place width change does not
|
||||
audit or re-slice the prior epoch's physical coordinates; it resolves the
|
||||
captured logical source marker in the settled-width frame.
|
||||
3. Classify the frame as a gesture-driven full paint, an opt-in divergence
|
||||
rebuild, or an ordinary update and calculate the window/commit chunk.
|
||||
Overlays freeze commits. A pinned live region clips its offscreen mutable
|
||||
@@ -95,7 +109,7 @@ needs to know whether the user has scrolled away from the tail.
|
||||
| `#emitFullPaint` | home + committed chunk + window rows; optional ED3 | initial paint, explicit geometry/session/reset gestures, or rebuild |
|
||||
| `#emitUpdate` scroll-append | new bottom rows plus changed-row range | rows leaving the screen are exactly the commit chunk |
|
||||
| `#emitUpdate` in-window diff | relative move plus changed-row rewrite | nothing scrolls or commits |
|
||||
| `#emitUpdate` seam rewrite | commit chunk plus full window rewrite | commit/window re-anchor, hidden-gap backfill, or mux resize |
|
||||
| `#emitUpdate` seam rewrite | commit chunk plus full window rewrite | commit/window re-anchor or hidden-gap backfill |
|
||||
|
||||
**ED3 (`CSI 3 J`) is emitted in exactly one place** —
|
||||
`#emitFullPaint({ clearScrollback: true })`. The normal callers are explicit
|
||||
@@ -135,6 +149,14 @@ commits are prefix-only. `NativeScrollbackCommittedRows` lets containers pass
|
||||
the committed count down to children, and `NativeScrollbackReplay` lets
|
||||
components release layout locks before a destructive replay.
|
||||
|
||||
`NativeScrollbackWidthEpoch` is the cross-width source contract. Capture reads
|
||||
only state that produced the last emitted frame. Resolve projects that source
|
||||
boundary into the newly rendered width, while the current-boundary method
|
||||
identifies the logical suffix queued during settlement. Containers propagate
|
||||
the marker through nested sources; Markdown snapshots its last rendered source
|
||||
text, so a streaming update received before `SIGWINCH` cannot masquerade as
|
||||
already-emitted output.
|
||||
|
||||
`TranscriptContainer` implements the application seam. It scans for the first
|
||||
unfinalized transcript block. Finalized blocks before it are exact; that live
|
||||
block may extend the exact boundary through
|
||||
@@ -164,22 +186,35 @@ contract, not a terminal-specific optimization.
|
||||
deliberate exception: it clears and replays the complete current frame.
|
||||
3. **Commits are exactly the chunk.** Any byte shape that scrolls the screen
|
||||
must scroll only rows accounted for by the commit advance.
|
||||
4. **NEVER probe the viewport position or fork on platform in the update
|
||||
4. **A multiplexer width resize NEVER advances history.** The old committed
|
||||
physical-row coordinate is opaque after reflow. The resize leaves the
|
||||
host-reflowed viewport in place and establishes a complete-frame baseline
|
||||
independent of the native commit count. Subsequent growth writes the exact
|
||||
current-width rows newly crossing the seam—not blank scroll commands—then
|
||||
repaints the bounded viewport; only that slice advances commits. Visible
|
||||
overlays advance neither the baseline nor the seam ledger; overlay exit
|
||||
backfills the exact hidden slice. Pinned live regions advance only through
|
||||
their final boundary; finalization releases the deferred mutable slice.
|
||||
During a height shrink, only occupied old-frame rows actually moved into
|
||||
history by the host are excluded from the append-owned seam; empty viewport
|
||||
rows do not consume content-driven movement. Height-only resizes do not
|
||||
terminate the epoch.
|
||||
5. **NEVER probe the viewport position or fork on platform in the update
|
||||
path.** win32 behaves like POSIX. The probe APIs are gone; do not
|
||||
reintroduce them.
|
||||
5. **Only declare rows exact when their bytes are stable.** Mutable transcript
|
||||
6. **Only declare rows exact when their bytes are stable.** Mutable transcript
|
||||
content may commit as an unpinned frozen snapshot, but rows before the seam
|
||||
remain under the exact-prefix audit.
|
||||
6. **Park the hardware cursor at real content bottom**, not the padded window
|
||||
7. **Park the hardware cursor at real content bottom**, not the padded window
|
||||
bottom, or height shrinks scroll live rows into history and duplicate them
|
||||
per resize step.
|
||||
7. **Cursor writes live inside the synchronized-output frame**, before ESU —
|
||||
8. **Cursor writes live inside the synchronized-output frame**, before ESU —
|
||||
never as a second frame after it.
|
||||
8. **NEVER throw in the render hot path.** Clamp over-wide lines
|
||||
9. **NEVER throw in the render hot path.** Clamp over-wide lines
|
||||
(`truncateToWidth`); a width mismatch is cosmetic, not fatal.
|
||||
9. **Multiplexers get no destructive clear and no history rewrap on resize** —
|
||||
repaint the window in place; pane history keeps its old wrap.
|
||||
10. **Any change to the ledger math, the emitters, or the seam must be
|
||||
10. **Multiplexers get no destructive clear and no history rewrap on resize** —
|
||||
repaint the window in place; pane history keeps its old wrap.
|
||||
11. **Any change to the ledger math, the emitters, or the seam must be
|
||||
validated by the stress harness (§6)** across its full scenario matrix,
|
||||
not by a single-terminal smoke test.
|
||||
|
||||
|
||||
@@ -162,9 +162,11 @@ Resize events are event-driven from `ProcessTerminal` to `TUI.requestRender()`.
|
||||
|
||||
Effects:
|
||||
|
||||
- A resize is an explicit user gesture: outside multiplexers the engine erases and replays (`ED3` + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame.
|
||||
- Inside terminal multiplexers, resize repaints the visible window in place after a settle debounce (issue #2088); pane history keeps its old wrap, like any shell output, because pane scrollback cannot be erased safely.
|
||||
- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. `resizeRepaintsInPlace()` (covering multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer.
|
||||
- Direct HerdR panes follow the in-place multiplexer path: their host owns the
|
||||
pane, and destructive `ED3` transcript replay produces visible flashes.
|
||||
- Inside terminal multiplexers, height-only resize retains the append ledger and repaints the visible window in place after the settle debounce (issue #2088). A width change instead terminates the physical-row epoch: old committed coordinates become opaque, pane history remains immutable at its authored wrap, and the settled render establishes a complete-frame baseline. Subsequent growth writes only current-width rows newly crossing the scrollback seam before repainting the bounded viewport.
|
||||
- Nested tmux, screen, Zellij, or cmux sessions inside HerdR use the same path.
|
||||
- Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. `resizeRepaintsInPlace()` (covering ED3-unsafe multiplexers and these terminals; overridable via `PI_TUI_RESIZE_IN_PLACE`) routes them through the in-place repaint, which never touches the alt buffer.
|
||||
- Overlay visibility can depend on terminal dimensions (`OverlayOptions.visible`); focus is corrected when overlays become non-visible after resize.
|
||||
|
||||
## Streaming and incremental UI updates
|
||||
|
||||
Generated
+248
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"nodes": {
|
||||
"bun2nix": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems",
|
||||
"treefmt-nix": "treefmt-nix"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784665499,
|
||||
"narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=",
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"bun2nix-darwin-x64": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs-darwin-x64"
|
||||
],
|
||||
"systems": "systems_2",
|
||||
"treefmt-nix": "treefmt-nix_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784665499,
|
||||
"narHash": "sha256-9BMxlTxCCDAeoNLtb1a/st7udtTIJep+wpUzquA29VU=",
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"rev": "0f2a1f0b6f42cebe3b149bf62d38754c5e0e9729",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "bun2nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"bun2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"bun2nix-darwin-x64",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782949081,
|
||||
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-bun": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786530394,
|
||||
"narHash": "sha256-Bxx47lHMVHFD7JCt0bQGwuYK71qKioYq2Y74CPg/KHs=",
|
||||
"owner": "ryoppippi",
|
||||
"repo": "nix-bun",
|
||||
"rev": "3c2ccb115cc79d0556743743654475d4f6446f11",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ryoppippi",
|
||||
"repo": "nix-bun",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1786384358,
|
||||
"narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-darwin-x64": {
|
||||
"locked": {
|
||||
"lastModified": 1786527240,
|
||||
"narHash": "sha256-OLtJPnSXcRy79Rf7BhYaMeXAVF625FpLcd3+Svq641Y=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e0c84f9d0ad137f076dc957494f5b39885597d4f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-26.05-darwin",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"bun2nix": "bun2nix",
|
||||
"bun2nix-darwin-x64": "bun2nix-darwin-x64",
|
||||
"nix-bun": "nix-bun",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"nixpkgs-darwin-x64": "nixpkgs-darwin-x64",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786507911,
|
||||
"narHash": "sha256-w5aZRLbiu7H6TqsYXVMdRKg0S4DRaJpxyqxx86AwxVk=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "39db48099ad16834af7e27485a4babf9c28b3897",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1776166891,
|
||||
"narHash": "sha256-bI8yrEGjrohR5hkQox7UrxDH7XqrYMwI8SL/LrJ1+S8=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "triplet",
|
||||
"rev": "6de7bc09397911ce03636afbcf6118745ab2cda0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "triplet",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1680978224,
|
||||
"narHash": "sha256-+xT9B1ZbhMg/zpJqd00S06UCZb/A2URW9bqqrZ/JTOg=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "x86_64-darwin",
|
||||
"rev": "db0463cce4cd60fb791f33a83d29a1ed53edab9b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "x86_64-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"bun2nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784369104,
|
||||
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"treefmt-nix_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"bun2nix-darwin-x64",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1784369104,
|
||||
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
description = "OMP coding agent and development environment";
|
||||
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://nix-community.cachix.org" ];
|
||||
extra-trusted-public-keys = [
|
||||
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
|
||||
];
|
||||
};
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
# nixpkgs unstable dropped Intel macOS in 26.11; keep that supported
|
||||
# platform on the final stable branch that still receives security fixes.
|
||||
nixpkgs-darwin-x64.url = "github:NixOS/nixpkgs/nixpkgs-26.05-darwin";
|
||||
|
||||
bun2nix = {
|
||||
url = "github:nix-community/bun2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
# bun2nix's per-system helper packages must use the same Intel-compatible
|
||||
# package set as the derivation consuming its overlay.
|
||||
bun2nix-darwin-x64 = {
|
||||
url = "github:nix-community/bun2nix";
|
||||
inputs.nixpkgs.follows = "nixpkgs-darwin-x64";
|
||||
inputs.systems.url = "github:nix-systems/x86_64-darwin";
|
||||
};
|
||||
|
||||
nix-bun = {
|
||||
url = "github:ryoppippi/nix-bun";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
bun2nix,
|
||||
bun2nix-darwin-x64,
|
||||
nix-bun,
|
||||
nixpkgs,
|
||||
nixpkgs-darwin-x64,
|
||||
rust-overlay,
|
||||
...
|
||||
}:
|
||||
let
|
||||
systems = [
|
||||
"aarch64-darwin"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"x86_64-linux"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
nixpkgsFor = system: if system == "x86_64-darwin" then nixpkgs-darwin-x64 else nixpkgs;
|
||||
bun2nixFor = system: if system == "x86_64-darwin" then bun2nix-darwin-x64 else bun2nix;
|
||||
pkgsFor =
|
||||
system:
|
||||
import (nixpkgsFor system) {
|
||||
inherit system;
|
||||
overlays = [
|
||||
rust-overlay.overlays.default
|
||||
(bun2nixFor system).overlays.default
|
||||
(final: _previous: {
|
||||
# Instantiate the pinned upstream binary against this package
|
||||
# set so Intel macOS does not re-enter nix-bun's unstable input.
|
||||
bun = final.callPackage (nix-bun.outPath + "/package.nix") {
|
||||
sourcesFile = nix-bun.outPath + "/versions/1.3.14.json";
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
packageFor =
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
in
|
||||
pkgs.callPackage ./nix/package.nix {
|
||||
inherit rustToolchain;
|
||||
source = self.outPath;
|
||||
};
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system: {
|
||||
default = packageFor system;
|
||||
omp = packageFor system;
|
||||
});
|
||||
|
||||
apps = forAllSystems (system: {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/omp";
|
||||
meta.description = "Run OMP";
|
||||
};
|
||||
omp = self.apps.${system}.default;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
|
||||
in
|
||||
{
|
||||
default = import ./nix/dev-shell.nix { inherit pkgs rustToolchain; };
|
||||
}
|
||||
);
|
||||
|
||||
checks = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
homeManagerEvaluation = pkgs.lib.evalModules {
|
||||
specialArgs = { inherit pkgs; };
|
||||
modules = [
|
||||
{
|
||||
options.home.packages = pkgs.lib.mkOption {
|
||||
type = pkgs.lib.types.listOf pkgs.lib.types.package;
|
||||
default = [ ];
|
||||
};
|
||||
options.home.file = pkgs.lib.mkOption {
|
||||
type = pkgs.lib.types.attrsOf pkgs.lib.types.anything;
|
||||
default = { };
|
||||
};
|
||||
}
|
||||
self.homeManagerModules.default
|
||||
{
|
||||
programs.omp.enable = true;
|
||||
programs.omp.settings.startup.quiet = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
nixosEvaluation = pkgs.lib.evalModules {
|
||||
specialArgs = { inherit pkgs; };
|
||||
modules = [
|
||||
{
|
||||
options.environment.systemPackages = pkgs.lib.mkOption {
|
||||
type = pkgs.lib.types.listOf pkgs.lib.types.package;
|
||||
default = [ ];
|
||||
};
|
||||
}
|
||||
self.nixosModules.default
|
||||
{ programs.omp.enable = true; }
|
||||
];
|
||||
};
|
||||
modulesEvaluate =
|
||||
assert builtins.elem self.packages.${system}.default homeManagerEvaluation.config.home.packages;
|
||||
assert homeManagerEvaluation.config.home.file ? ".omp/agent/config.yml";
|
||||
assert builtins.elem self.packages.${system}.default
|
||||
nixosEvaluation.config.environment.systemPackages;
|
||||
pkgs.runCommand "omp-module-evaluation" { } "touch $out";
|
||||
in
|
||||
{
|
||||
bun-lock = pkgs.runCommand "omp-bun-lock" { nativeBuildInputs = [ pkgs.bun2nix ]; } ''
|
||||
cp -R ${self.outPath} source
|
||||
chmod -R u+w source
|
||||
cd source
|
||||
mv nix/bun.nix nix/bun.expected.nix
|
||||
bun2nix -l bun.lock -c ../ -o nix/bun.nix
|
||||
diff -u nix/bun.expected.nix nix/bun.nix
|
||||
touch "$out"
|
||||
'';
|
||||
modules = modulesEvaluate;
|
||||
omp = self.packages.${system}.default;
|
||||
}
|
||||
);
|
||||
|
||||
formatter = forAllSystems (system: (pkgsFor system).nixfmt);
|
||||
|
||||
overlays.default = _final: previous: {
|
||||
omp = self.packages.${previous.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
|
||||
homeManagerModules.default = import ./nix/home-manager.nix { inherit self; };
|
||||
homeManagerModules.omp = self.homeManagerModules.default;
|
||||
nixosModules.default = import ./nix/nixos-module.nix { inherit self; };
|
||||
nixosModules.omp = self.nixosModules.default;
|
||||
};
|
||||
}
|
||||
+2170
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
{
|
||||
pkgs,
|
||||
rustToolchain,
|
||||
}:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
linuxLibraries = with pkgs; [
|
||||
libpulseaudio
|
||||
pipewire
|
||||
stdenv.cc.cc.lib
|
||||
zlib
|
||||
];
|
||||
in
|
||||
pkgs.mkShell (
|
||||
{
|
||||
name = "omp-dev";
|
||||
|
||||
packages =
|
||||
(with pkgs; [
|
||||
bun
|
||||
bun2nix
|
||||
rustToolchain
|
||||
cargo-nextest
|
||||
rustPlatform.bindgenHook
|
||||
nixfmt
|
||||
typescript-language-server
|
||||
|
||||
python312
|
||||
python312Packages.pip
|
||||
uv
|
||||
basedpyright
|
||||
|
||||
bash
|
||||
cacert
|
||||
curl
|
||||
fd
|
||||
git
|
||||
git-lfs
|
||||
imagemagick
|
||||
openssh
|
||||
ripgrep
|
||||
sqlite
|
||||
unzip
|
||||
|
||||
cmake
|
||||
ninja
|
||||
pkg-config
|
||||
zig
|
||||
|
||||
cairo
|
||||
giflib
|
||||
libjpeg
|
||||
libopus
|
||||
librsvg
|
||||
openssl
|
||||
pango
|
||||
pcre2
|
||||
zlib
|
||||
])
|
||||
++ lib.optionals pkgs.stdenv.hostPlatform.isLinux linuxLibraries;
|
||||
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
|
||||
# Bazel's downloaded host tools assume an FHS loader; Cargo is the
|
||||
# repository's supported local-iteration path inside the Nix shell.
|
||||
OMP_NATIVE_BUILD_BACKEND = "cargo";
|
||||
PCRE2_SYS_STATIC = "1";
|
||||
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
|
||||
}
|
||||
// lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
|
||||
LD_LIBRARY_PATH = lib.makeLibraryPath linuxLibraries;
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
{ self }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.programs.omp;
|
||||
yaml = pkgs.formats.yaml { };
|
||||
in
|
||||
{
|
||||
options.programs.omp = {
|
||||
enable = lib.mkEnableOption "OMP coding agent";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
defaultText = lib.literalExpression "inputs.omp.packages.${pkgs.stdenv.hostPlatform.system}.default";
|
||||
description = "OMP package to install.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.nullOr yaml.type;
|
||||
default = null;
|
||||
description = ''
|
||||
Settings written declaratively to {file}`~/.omp/agent/config.yml`.
|
||||
The file is a read-only store symlink: changes made from inside OMP
|
||||
(`/settings`, onboarding) replace it but revert on the next
|
||||
`home-manager switch`.
|
||||
'';
|
||||
example = {
|
||||
theme.dark = "titanium";
|
||||
startup.quiet = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = [ cfg.package ];
|
||||
home.file.".omp/agent/config.yml" = lib.mkIf (cfg.settings != null) {
|
||||
source = yaml.generate "omp-config.yml" cfg.settings;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{ self }:
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.programs.omp;
|
||||
in
|
||||
{
|
||||
options.programs.omp = {
|
||||
enable = lib.mkEnableOption "OMP coding agent";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
defaultText = lib.literalExpression "inputs.omp.packages.${pkgs.stdenv.hostPlatform.system}.default";
|
||||
description = "OMP package to install system-wide.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
};
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
{
|
||||
autoPatchelfHook,
|
||||
alsa-lib,
|
||||
bun,
|
||||
bun2nix,
|
||||
cmake,
|
||||
darwin,
|
||||
lib,
|
||||
libopus,
|
||||
libpulseaudio,
|
||||
ninja,
|
||||
pipewire,
|
||||
pkg-config,
|
||||
rustPlatform,
|
||||
rustToolchain,
|
||||
source,
|
||||
stdenv,
|
||||
stdenvNoCC,
|
||||
unzip,
|
||||
# Wayland screencast support links libpipewire, whose runtime closure adds
|
||||
# ~750 MB (gstreamer, ffmpeg, systemd, ...). Official npm/Bazel addons ship
|
||||
# without it, so default to the lean build; opt in via `.override`.
|
||||
withWaylandScreencast ? false,
|
||||
}:
|
||||
let
|
||||
packageJson = lib.importJSON ../packages/coding-agent/package.json;
|
||||
rootPackageJson = lib.importJSON ../package.json;
|
||||
platform =
|
||||
{
|
||||
aarch64-darwin = {
|
||||
addon = "pi_natives.darwin-arm64.node";
|
||||
nativeLibrary = "libpi_natives.dylib";
|
||||
};
|
||||
aarch64-linux = {
|
||||
addon = "pi_natives.linux-arm64.node";
|
||||
nativeLibrary = "libpi_natives.so";
|
||||
};
|
||||
x86_64-darwin = {
|
||||
addon = "pi_natives.darwin-x64-baseline.node";
|
||||
nativeLibrary = "libpi_natives.dylib";
|
||||
rustFlags = "-C target-cpu=x86-64-v2";
|
||||
};
|
||||
x86_64-linux = {
|
||||
addon = "pi_natives.linux-x64-baseline.node";
|
||||
nativeLibrary = "libpi_natives.so";
|
||||
rustFlags = "-C target-cpu=x86-64-v2";
|
||||
};
|
||||
}
|
||||
.${stdenv.hostPlatform.system} or (throw "Unsupported OMP platform: ${stdenv.hostPlatform.system}");
|
||||
patchedDependencies = lib.mapAttrs (
|
||||
_: patch: source + "/${patch}"
|
||||
) rootPackageJson.patchedDependencies;
|
||||
patchOverrides = bun2nix.patchedDependenciesToOverrides { inherit patchedDependencies; };
|
||||
bunRuntimeTemplate = stdenvNoCC.mkDerivation {
|
||||
pname = "omp-bun-runtime-template";
|
||||
inherit (bun) version;
|
||||
src = bun.src;
|
||||
|
||||
nativeBuildInputs = [ unzip ];
|
||||
dontUnpack = true;
|
||||
dontFixup = true;
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
unzip -q "$src"
|
||||
install -Dm755 bun-*/bun "$out/libexec/bun"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "omp";
|
||||
inherit (packageJson) version;
|
||||
src = source;
|
||||
|
||||
cargoDeps = rustPlatform.importCargoLock { lockFile = ../Cargo.lock; };
|
||||
bunDeps = bun2nix.fetchBunDeps {
|
||||
bunNix = ./bun.nix;
|
||||
overrides = patchOverrides;
|
||||
};
|
||||
|
||||
nativeBuildInputs = [
|
||||
bun
|
||||
bun2nix.hook
|
||||
cmake
|
||||
ninja
|
||||
pkg-config
|
||||
rustPlatform.bindgenHook
|
||||
rustPlatform.cargoSetupHook
|
||||
rustToolchain
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isLinux [ autoPatchelfHook ]
|
||||
++ lib.optionals stdenv.hostPlatform.isDarwin [ darwin.autoSignDarwinBinariesHook ];
|
||||
|
||||
# pcre2 is vendored via PCRE2_SYS_STATIC, but opus must link the nixpkgs
|
||||
# library: audiopus_sys' bundled cmake build installs to lib64 while its
|
||||
# link-search hardcodes lib, so the pkg-config path is the one that works.
|
||||
# libgcc_s is resolved from the compiler's lib output during autoPatchelf.
|
||||
# All dynamic store paths are pinned into the closure via nix-support (see
|
||||
# installPhase).
|
||||
buildInputs = [
|
||||
libopus
|
||||
]
|
||||
++ lib.optionals stdenv.hostPlatform.isLinux [ stdenv.cc.cc.lib ]
|
||||
++ lib.optionals withWaylandScreencast [ pipewire ];
|
||||
|
||||
strictDeps = true;
|
||||
# Nix builders cannot reliably hardlink cache files into node_modules
|
||||
# (and Darwin's clonefile backend also rejects store permissions).
|
||||
bunInstallFlags = [
|
||||
"--linker=isolated"
|
||||
"--backend=copyfile"
|
||||
];
|
||||
dontConfigure = true;
|
||||
dontRunLifecycleScripts = true;
|
||||
dontUseBunBuild = true;
|
||||
dontUseBunCheck = true;
|
||||
dontUseBunInstall = true;
|
||||
dontStrip = true;
|
||||
|
||||
env = {
|
||||
CMAKE_POLICY_VERSION_MINIMUM = "3.5";
|
||||
PCRE2_SYS_STATIC = "1";
|
||||
SOURCE_DATE_EPOCH = "1";
|
||||
}
|
||||
// lib.optionalAttrs (platform ? rustFlags) { RUSTFLAGS = platform.rustFlags; }
|
||||
// lib.optionalAttrs stdenv.hostPlatform.isDarwin { BUN_NO_CODESIGN_MACHO_BINARY = "1"; };
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
echo "Building pi-natives"
|
||||
cargo build --release -p pi-natives ${lib.optionalString withWaylandScreencast "--features wayland-pipewire"}
|
||||
install -Dm755 "target/release/${platform.nativeLibrary}" \
|
||||
"packages/natives/native/${platform.addon}"
|
||||
${lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
# The loader extracts this archived addon at runtime, so fix its
|
||||
# interpreter-independent Nix RPATH before Bun embeds it.
|
||||
autoPatchelf -- "packages/natives/native/${platform.addon}"
|
||||
# pi-voice dlopens libpulse-simple.so.0 / libpulse.so.0 / libasound.so.2
|
||||
# by bare name; glibc resolves those through the calling object's
|
||||
# RUNPATH, so append the client libraries here. Nothing links them, so
|
||||
# autoPatchelf cannot discover them on its own.
|
||||
patchelf --add-rpath "${
|
||||
lib.makeLibraryPath [
|
||||
libpulseaudio
|
||||
alsa-lib
|
||||
]
|
||||
}" \
|
||||
"packages/natives/native/${platform.addon}"
|
||||
''}
|
||||
${lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||
# arm64 Darwin requires even locally-built Mach-O addons to carry an
|
||||
# ad-hoc signature. Sign before Bun archives the file.
|
||||
signIfRequired "packages/natives/native/${platform.addon}"
|
||||
''}
|
||||
|
||||
echo "Compiling OMP"
|
||||
BUN_COMPILE_EXECUTABLE_PATH="${bunRuntimeTemplate}/libexec/bun" \
|
||||
bun --cwd="$PWD/packages/coding-agent" run build
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
install -Dm755 packages/coding-agent/dist/omp "$out/bin/omp"
|
||||
|
||||
# The addon is gzip-compressed inside the compiled binary, so the store
|
||||
# paths it links against are invisible to the output reference scanner.
|
||||
# Record them in plain text to pin the libraries into the runtime closure.
|
||||
mkdir -p "$out/nix-support"
|
||||
${
|
||||
if stdenv.hostPlatform.isLinux then
|
||||
''
|
||||
patchelf --print-rpath "packages/natives/native/${platform.addon}" \
|
||||
> "$out/nix-support/embedded-addon-runpath"
|
||||
''
|
||||
else
|
||||
''
|
||||
echo "${lib.getLib libopus}/lib" > "$out/nix-support/embedded-addon-runpath"
|
||||
''
|
||||
}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
doInstallCheck = true;
|
||||
installCheckPhase = ''
|
||||
runHook preInstallCheck
|
||||
HOME="$TMPDIR" "$out/bin/omp" --smoke-test | grep -q "smoke-test: ok"
|
||||
BUN_BE_BUN=1 "$out/bin/omp" -e \
|
||||
'if (Bun.version !== "${bun.version}" || typeof Bun.Image !== "function") process.exit(1)'
|
||||
runHook postInstallCheck
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Terminal-based coding agent with multi-model support";
|
||||
homepage = "https://omp.sh";
|
||||
changelog = "https://github.com/can1357/oh-my-pi/releases/tag/v${packageJson.version}";
|
||||
license = lib.licenses.mit;
|
||||
mainProgram = "omp";
|
||||
platforms = [
|
||||
"aarch64-darwin"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"x86_64-linux"
|
||||
];
|
||||
sourceProvenance = with lib.sourceTypes; [
|
||||
binaryNativeCode
|
||||
fromSource
|
||||
];
|
||||
};
|
||||
}
|
||||
+14
-13
@@ -23,19 +23,19 @@
|
||||
"@bufbuild/protoc-gen-es": "^2.12.1",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@napi-rs/cli": "3.7.2",
|
||||
"@oh-my-pi/hashline": "17.2.12",
|
||||
"@oh-my-pi/omp-stats": "17.2.12",
|
||||
"@oh-my-pi/omptype": "17.2.12",
|
||||
"@oh-my-pi/pi-agent-core": "17.2.12",
|
||||
"@oh-my-pi/pi-ai": "17.2.12",
|
||||
"@oh-my-pi/pi-catalog": "17.2.12",
|
||||
"@oh-my-pi/pi-coding-agent": "17.2.12",
|
||||
"@oh-my-pi/pi-mnemopi": "17.2.12",
|
||||
"@oh-my-pi/pi-natives": "17.2.12",
|
||||
"@oh-my-pi/pi-tui": "17.2.12",
|
||||
"@oh-my-pi/pi-utils": "17.2.12",
|
||||
"@oh-my-pi/pi-wire": "17.2.12",
|
||||
"@oh-my-pi/snapcompact": "17.2.12",
|
||||
"@oh-my-pi/hashline": "17.3.1",
|
||||
"@oh-my-pi/omp-stats": "17.3.1",
|
||||
"@oh-my-pi/omptype": "17.3.1",
|
||||
"@oh-my-pi/pi-agent-core": "17.3.1",
|
||||
"@oh-my-pi/pi-ai": "17.3.1",
|
||||
"@oh-my-pi/pi-catalog": "17.3.1",
|
||||
"@oh-my-pi/pi-coding-agent": "17.3.1",
|
||||
"@oh-my-pi/pi-mnemopi": "17.3.1",
|
||||
"@oh-my-pi/pi-natives": "17.3.1",
|
||||
"@oh-my-pi/pi-tui": "17.3.1",
|
||||
"@oh-my-pi/pi-utils": "17.3.1",
|
||||
"@oh-my-pi/pi-wire": "17.3.1",
|
||||
"@oh-my-pi/snapcompact": "17.3.1",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.220.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.9.0",
|
||||
@@ -167,6 +167,7 @@
|
||||
"gen:stats": "bun --cwd=packages/stats run gen:stats",
|
||||
"gen:stats:reset": "bun --cwd=packages/stats run gen:stats:reset",
|
||||
"gen:changelog": "bun scripts/rewrite-changelog.ts",
|
||||
"gen:nix": "bun scripts/gen-nix-bun.ts",
|
||||
"gen:tool-views": "bun --cwd=packages/collab-web run gen:tool-views",
|
||||
"gen:bundle": "bun --cwd=packages/coding-agent run gen:bundle",
|
||||
"gen:mupdf": "bun --cwd=packages/coding-agent run gen:mupdf",
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the manual `/shake` command to retain a small history of recent tool results, preventing the agent from losing its active working context.
|
||||
|
||||
## [17.2.13] - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Cursor sessions re-executing settled tools when an owned dialect projector rebuilds toolCall blocks: `snapshotAssistantContentBlock` now copies `kCursorExecResolved` explicitly so agent-loop still skips already-settled calls.
|
||||
|
||||
## [17.2.10] - 2026-08-06
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-agent-core",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -31,7 +31,11 @@ import {
|
||||
wrapInbandToolStream,
|
||||
} from "@oh-my-pi/pi-ai/dialect";
|
||||
import * as AIError from "@oh-my-pi/pi-ai/error";
|
||||
import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
||||
import {
|
||||
type CursorExecResolvedCarrier,
|
||||
copyCursorExecResolved,
|
||||
kCursorExecResolved,
|
||||
} from "@oh-my-pi/pi-ai/utils/block-symbols";
|
||||
import {
|
||||
createHarmonyAuditEvent,
|
||||
detectHarmonyLeakInAssistantMessage,
|
||||
@@ -356,12 +360,18 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
|
||||
return { ...block, block: structuredCloneJSON(block.block) };
|
||||
case "fallback":
|
||||
return { ...block, from: { ...block.from }, to: { ...block.to } };
|
||||
case "toolCall":
|
||||
return {
|
||||
case "toolCall": {
|
||||
const snap = {
|
||||
...block,
|
||||
arguments: structuredCloneJSON(block.arguments),
|
||||
providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata),
|
||||
};
|
||||
// Object spread copies enumerable symbols in Bun, but the Cursor
|
||||
// exec-resolved marker is load-bearing for skip-on-dispatch — copy
|
||||
// it explicitly so a projector/snapshot path cannot drop it.
|
||||
copyCursorExecResolved(snap, block);
|
||||
return snap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
The following is a summary of a branch that this conversation came back from:
|
||||
Branch-return summary:
|
||||
|
||||
<summary>
|
||||
{{summary}}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
The user explored a different conversation branch before returning here.
|
||||
Summary of that exploration:
|
||||
User explored another conversation branch, then returned here.
|
||||
Exploration summary:
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
You MUST summarize what was done in this conversation, written like a pull request description.
|
||||
|
||||
Rules:
|
||||
- MUST be 2-3 sentences max
|
||||
- MUST describe the changes made, not the process
|
||||
- NEVER mention running tests, builds, or other validation steps
|
||||
- NEVER explain what the user asked for
|
||||
- MUST write in first person (I added…, I fixed…)
|
||||
- NEVER ask questions
|
||||
Summarize conversation changes as a pull request description.
|
||||
MUST 2–3 sentences; first person (`I added…`, `I fixed…`); describe changes, not process.
|
||||
NEVER mention tests, builds, or other validation steps; explain user request; ask questions.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that model used. You MUST build on the work already done and NEVER duplicate it. Here is that summary:
|
||||
Prior model work/tool state available.
|
||||
MUST build on prior work; NEVER duplicate prior work.
|
||||
|
||||
<summary>
|
||||
{{summary}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
||||
Turn prefix too large; recent-work suffix retained.
|
||||
|
||||
You MUST summarize the prefix to provide context for the retained suffix:
|
||||
MUST summarize prefix for retained suffix:
|
||||
|
||||
## Original Request
|
||||
|
||||
@@ -12,6 +12,6 @@ You MUST summarize the prefix to provide context for the retained suffix:
|
||||
## Context for Suffix
|
||||
- [Information needed to understand the retained recent work]
|
||||
|
||||
You MUST output only the structured summary. You NEVER include extra text.
|
||||
MUST output only the structured summary; NEVER extra text.
|
||||
|
||||
You MUST be concise. You MUST preserve exact file paths, function names, error messages, and relevant tool outputs or command results if they appear. You MUST focus on what's needed to understand the kept suffix.
|
||||
MUST concise. MUST preserve exact file paths, function names, error messages, relevant tool outputs, and command results if present. MUST focus on information needed to understand the retained suffix.
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
You MUST incorporate the new messages above into the existing handoff summary in <previous-summary> tags, used by another LLM to resume the task.
|
||||
RULES:
|
||||
- MUST preserve all information from the previous summary
|
||||
- MUST add new progress, decisions, and context from new messages
|
||||
- MUST update Progress: move items from "In Progress" to "Done" when completed
|
||||
- MUST update "Next Steps" based on what was accomplished
|
||||
- MUST preserve exact file paths, function names, and error messages
|
||||
- You MAY remove anything no longer relevant
|
||||
Update existing handoff summary in <previous-summary> tags from new messages above for another LLM to resume.
|
||||
|
||||
IMPORTANT: If the new messages end with an unanswered question or request to the user, you MUST add it to Critical Context (replacing any previous pending question if answered).
|
||||
MUST:
|
||||
- preserve all previous-summary information; add new progress, decisions, context.
|
||||
- Progress: move completed "In Progress" items to "Done".
|
||||
- update "Next Steps" for completed work.
|
||||
- preserve exact file paths, function names, error messages.
|
||||
- MAY remove irrelevant content.
|
||||
- If new messages end with an unanswered user question/request: add it to Critical Context; replace any previous pending question if answered.
|
||||
- output only the structured summary; NEVER extra text.
|
||||
- keep sections concise.
|
||||
- preserve relevant tool outputs/command results.
|
||||
- include mentioned repository state changes (branch, uncommitted changes).
|
||||
|
||||
You MUST use this format (omit sections if not applicable):
|
||||
Format (omit inapplicable sections):
|
||||
|
||||
## Goal
|
||||
[Preserve existing goals; add new ones if task expanded]
|
||||
@@ -39,7 +42,3 @@ You MUST use this format (omit sections if not applicable):
|
||||
|
||||
## Additional Notes
|
||||
[Other important info not fitting above]
|
||||
|
||||
You MUST output only the structured summary; you NEVER include extra text.
|
||||
|
||||
Sections MUST be kept concise. You MUST preserve relevant tool outputs/command results. You MUST include repository state changes (branch, uncommitted changes) if mentioned.
|
||||
|
||||
@@ -1 +1 @@
|
||||
Output exceeded the available model context and was truncated
|
||||
Output: exceeded available model context → truncated.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Summarize conversations between users and AI coding assistants. Produce structured summaries in the exact specified format.
|
||||
Summarize user–AI coding-assistant conversations in the exact specified structured format.
|
||||
|
||||
NEVER continue the conversation. NEVER respond to questions in it. Output ONLY the structured summary.
|
||||
NEVER continue the conversation or answer its questions. Output ONLY the structured summary.
|
||||
|
||||
@@ -52,11 +52,13 @@ export const DEFAULT_SHAKE_CONFIG: ShakeConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Manual `/shake`: aggressive — drops every eligible region across history,
|
||||
* artifact recovery reads included (the user's full escape hatch).
|
||||
* Manual `/shake`: aggressive — no savings threshold and drops eligible
|
||||
* regions across history, artifact recovery reads included (the user's full
|
||||
* escape hatch). Still keeps a small recent tail so it cannot strip the tool
|
||||
* results the agent is currently working from (#7776).
|
||||
*/
|
||||
export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
|
||||
protectTokens: 0,
|
||||
protectTokens: 4_000,
|
||||
minSavings: 0,
|
||||
protectedTools: ["skill", isSkillReadToolResult],
|
||||
fenceMinTokens: 400,
|
||||
@@ -65,6 +67,10 @@ export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
|
||||
/** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
|
||||
export const RESCUE_SHAKE_CONFIG: ShakeConfig = {
|
||||
...AGGRESSIVE_SHAKE_CONFIG,
|
||||
// Rescue must be able to elide the newest oversized result even inside the
|
||||
// manual preset's recent-tail window (#7776) — a dead-end recovery that
|
||||
// cannot drop its blocker is not a recovery.
|
||||
protectTokens: 0,
|
||||
protectedTools: [...AGGRESSIVE_SHAKE_CONFIG.protectedTools, isArtifactRecoveryToolResult],
|
||||
};
|
||||
|
||||
|
||||
@@ -728,7 +728,17 @@ export type ToolLoadMode = "essential" | "discoverable";
|
||||
*/
|
||||
export type ToolApprovalDecision =
|
||||
| ToolTier
|
||||
| { tier: ToolTier; reason?: string; override?: boolean; policy?: "allow" | "deny" | "prompt" };
|
||||
| {
|
||||
tier: ToolTier;
|
||||
reason?: string;
|
||||
override?: boolean;
|
||||
policy?: "allow" | "deny" | "prompt";
|
||||
/** User-policy key for this decision. When set, `tools.approval.<policyKey>`
|
||||
* is consulted instead of `tools.approval.<tool.name>`. Lets a dispatcher
|
||||
* tool (e.g. `write` for an `xd://` device call) scope user allow/deny/
|
||||
* prompt policies to the tool it dispatches into. */
|
||||
policyKey?: string;
|
||||
};
|
||||
export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2216,7 +2216,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
let steerReady = false;
|
||||
let drained = false;
|
||||
let observedAbort = false;
|
||||
let resolvedByTimeout = false;
|
||||
const toolRelease = Promise.withResolvers<void>();
|
||||
|
||||
const tool: AgentTool<typeof toolSchema, Record<string, never>> = {
|
||||
name: "wait",
|
||||
@@ -2226,24 +2226,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
interruptible: params => params.op === "wait",
|
||||
async execute(_toolCallId, _params, signal) {
|
||||
steerReady = true;
|
||||
const { promise, resolve } = Promise.withResolvers<void>();
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
resolvedByTimeout = true;
|
||||
resolve();
|
||||
}, 300);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
await promise;
|
||||
if (!signal?.aborted) await toolRelease.promise;
|
||||
observedAbort = signal?.aborted === true;
|
||||
return { content: [{ type: "text", text: "waited" }], details: {} };
|
||||
},
|
||||
@@ -2260,7 +2243,11 @@ describe("agentLoop with AgentMessage", () => {
|
||||
model: mock.model,
|
||||
convertToLlm: identityConverter,
|
||||
interruptMode: "immediate",
|
||||
hasSteeringMessages: () => steerReady && !drained,
|
||||
hasSteeringMessages: () => {
|
||||
const queued = steerReady && !drained;
|
||||
if (queued) toolRelease.resolve();
|
||||
return queued;
|
||||
},
|
||||
getSteeringMessages: async () => {
|
||||
if (steerReady && !drained) {
|
||||
drained = true;
|
||||
@@ -2276,7 +2263,7 @@ describe("agentLoop with AgentMessage", () => {
|
||||
}
|
||||
|
||||
expect(observedAbort).toBe(false);
|
||||
expect(resolvedByTimeout).toBe(true);
|
||||
expect(steerReady).toBe(true);
|
||||
expect(drained).toBe(true);
|
||||
expect(
|
||||
events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"),
|
||||
@@ -3203,7 +3190,12 @@ describe("agentLoop event-driven steering watch", () => {
|
||||
// drain
|
||||
}
|
||||
})();
|
||||
const completed = await Promise.race([drain.then(() => true), Bun.sleep(1000).then(() => false)]);
|
||||
// This is the behavior under test, so retain a deadline; cancel its timer
|
||||
// when teardown succeeds instead of leaving a losing sleep alive.
|
||||
const timeout = Promise.withResolvers<boolean>();
|
||||
const timeoutId = setTimeout(() => timeout.resolve(false), 1000);
|
||||
const completed = await Promise.race([drain.then(() => true), timeout.promise]);
|
||||
clearTimeout(timeoutId);
|
||||
try {
|
||||
expect(completed).toBe(true);
|
||||
expect(executed).toEqual(["only"]);
|
||||
|
||||
@@ -1386,18 +1386,6 @@ describe("Agent", () => {
|
||||
expect(cwdPerCall).toEqual(["/live/repo-a", "/live/repo-b"]);
|
||||
});
|
||||
|
||||
it("returns static metadata via the plain setter", () => {
|
||||
const agent = new Agent();
|
||||
expect(agent.metadata).toBeUndefined();
|
||||
|
||||
const value = { user_id: "static" };
|
||||
agent.metadata = value;
|
||||
expect(agent.metadata).toEqual({ user_id: "static" });
|
||||
|
||||
agent.metadata = undefined;
|
||||
expect(agent.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("metadataForProvider resolves dynamic value at every call when a resolver is installed", () => {
|
||||
const agent = new Agent();
|
||||
let live = "alpha";
|
||||
@@ -1416,7 +1404,6 @@ describe("Agent", () => {
|
||||
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-resolver" });
|
||||
|
||||
agent.metadata = { user_id: "from-static" };
|
||||
expect(agent.metadata).toEqual({ user_id: "from-static" });
|
||||
expect(agent.metadataForProvider("any")).toEqual({ user_id: "from-static" });
|
||||
});
|
||||
|
||||
@@ -1439,7 +1426,6 @@ describe("Agent", () => {
|
||||
|
||||
agent.setMetadataResolver(undefined);
|
||||
expect(agent.metadataForProvider("any")).toEqual({ user_id: "static" });
|
||||
expect(agent.metadata).toEqual({ user_id: "static" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,17 +36,27 @@ describe("agentPauseGate", () => {
|
||||
const context: AgentContext = { systemPrompt: ["Test"], messages: [], tools: [] };
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
|
||||
const parked = Promise.withResolvers<void>();
|
||||
const originalWait = agentPauseGate.waitUntilResumed;
|
||||
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
|
||||
parked.resolve();
|
||||
return originalWait.call(agentPauseGate, signal);
|
||||
};
|
||||
expect(agentPauseGate.pause()).toBe(true);
|
||||
expect(agentPauseGate.pause()).toBe(false); // already engaged
|
||||
|
||||
const result = agentLoop([createUserMessage("hi")], context, config, undefined, mock.stream).result();
|
||||
await Bun.sleep(20);
|
||||
await parked.promise;
|
||||
expect(mock.calls.length).toBe(0); // parked before the first provider call
|
||||
|
||||
expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0);
|
||||
const messages = await result;
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(messages[messages.length - 1].role).toBe("assistant");
|
||||
try {
|
||||
expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0);
|
||||
const messages = await result;
|
||||
expect(mock.calls.length).toBe(1);
|
||||
expect(messages[messages.length - 1].role).toBe("assistant");
|
||||
} finally {
|
||||
agentPauseGate.waitUntilResumed = originalWait;
|
||||
}
|
||||
});
|
||||
|
||||
it("holds tool execution at the tool boundary when paused mid-turn", async () => {
|
||||
@@ -96,6 +106,12 @@ describe("agentPauseGate", () => {
|
||||
const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter };
|
||||
const abortController = new AbortController();
|
||||
|
||||
const parked = Promise.withResolvers<void>();
|
||||
const originalWait = agentPauseGate.waitUntilResumed;
|
||||
agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => {
|
||||
parked.resolve();
|
||||
return originalWait.call(agentPauseGate, signal);
|
||||
};
|
||||
agentPauseGate.pause();
|
||||
const result = agentLoop(
|
||||
[createUserMessage("hi")],
|
||||
@@ -104,19 +120,23 @@ describe("agentPauseGate", () => {
|
||||
abortController.signal,
|
||||
mock.stream,
|
||||
).result();
|
||||
await Bun.sleep(20);
|
||||
await parked.promise;
|
||||
abortController.abort("user interrupt");
|
||||
|
||||
// The run must terminate as aborted promptly (not stay parked until
|
||||
// resume). The provider request itself carries the aborted signal, so
|
||||
// whether the transport is entered at all is an implementation detail.
|
||||
const messages = await result;
|
||||
const last = messages[messages.length - 1];
|
||||
expect(last.role).toBe("assistant");
|
||||
if (last.role === "assistant") {
|
||||
expect(last.stopReason).toBe("aborted");
|
||||
try {
|
||||
const messages = await result;
|
||||
const last = messages[messages.length - 1];
|
||||
expect(last.role).toBe("assistant");
|
||||
if (last.role === "assistant") {
|
||||
expect(last.stopReason).toBe("aborted");
|
||||
}
|
||||
expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process
|
||||
} finally {
|
||||
agentPauseGate.waitUntilResumed = originalWait;
|
||||
}
|
||||
expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process
|
||||
});
|
||||
|
||||
it("re-parks a waiter when the gate is re-engaged in the same tick as resume", async () => {
|
||||
@@ -128,7 +148,7 @@ describe("agentPauseGate", () => {
|
||||
|
||||
agentPauseGate.resume();
|
||||
agentPauseGate.pause(); // re-engage before the waiter's microtask runs
|
||||
await Bun.sleep(10);
|
||||
await Promise.resolve();
|
||||
expect(released).toBe(false);
|
||||
|
||||
agentPauseGate.resume();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
collectShakeRegions,
|
||||
DEFAULT_SHAKE_CONFIG,
|
||||
estimateTokens,
|
||||
RESCUE_SHAKE_CONFIG,
|
||||
} from "@oh-my-pi/pi-agent-core/compaction";
|
||||
import type { AssistantMessage, TextContent, ToolCall, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
|
||||
@@ -204,17 +205,35 @@ describe("applyShakeRegions — multi-region ordering", () => {
|
||||
});
|
||||
|
||||
describe("shake config presets", () => {
|
||||
test("aggressive preset protects skill and drops everything else", () => {
|
||||
expect(AGGRESSIVE_SHAKE_CONFIG.protectTokens).toBe(0);
|
||||
test("aggressive preset protects skill and keeps a small recent tail", () => {
|
||||
expect(AGGRESSIVE_SHAKE_CONFIG.protectTokens).toBeGreaterThan(0);
|
||||
expect(AGGRESSIVE_SHAKE_CONFIG.minSavings).toBe(0);
|
||||
expect(AGGRESSIVE_SHAKE_CONFIG.protectedTools).toContain("skill");
|
||||
});
|
||||
|
||||
test("manual shake preserves the recent tool-result tail instead of stripping everything", () => {
|
||||
const older = messageEntry(toolResultMessage("bash", "old-result ".repeat(300)));
|
||||
const recent = messageEntry(toolResultMessage("bash", "recent-result ".repeat(3000)));
|
||||
const regions = collectShakeRegions([older, recent], AGGRESSIVE_SHAKE_CONFIG);
|
||||
|
||||
// The recent result sits inside the preserved tail; the older one is
|
||||
// still shaken aggressively.
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0].entry).toBe(older);
|
||||
});
|
||||
|
||||
test("default preset keeps a protect window", () => {
|
||||
expect(DEFAULT_SHAKE_CONFIG.protectTokens).toBeGreaterThan(0);
|
||||
expect(DEFAULT_SHAKE_CONFIG.protectedTools).toContain("skill");
|
||||
});
|
||||
|
||||
test("rescue preset overrides the manual tail so it can elide the newest result", () => {
|
||||
const recent = messageEntry(toolResultMessage("bash", "oversized-result ".repeat(2000)));
|
||||
const regions = collectShakeRegions([recent], RESCUE_SHAKE_CONFIG);
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0].entry).toBe(recent);
|
||||
});
|
||||
|
||||
test("empty branch yields no regions", () => {
|
||||
expect(collectShakeRegions([] as SessionEntry[], AGGRESSIVE_SHAKE_CONFIG)).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,9 @@ describe("conditional tool-result protection", () => {
|
||||
fileResult,
|
||||
];
|
||||
|
||||
const regions = collectShakeRegions(entries, AGGRESSIVE_SHAKE_CONFIG);
|
||||
// protectTokens: 0 isolates the matcher behavior from the aggressive
|
||||
// preset's recent-tail window (covered by shake.test.ts).
|
||||
const regions = collectShakeRegions(entries, { ...AGGRESSIVE_SHAKE_CONFIG, protectTokens: 0 });
|
||||
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0]?.kind).toBe("toolResult");
|
||||
|
||||
@@ -2,6 +2,66 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [17.3.0] - 2026-08-13
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed `withGeminiThinkingLoopGuard` to `withThinkingLoopGuard`; the guard applies to Gemini, DeepSeek, and Grok model-id families.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated OpenCode Go integration to use the official usage endpoint, removing hardcoded caps, enabling real-time credential validation, and routing multi-key pools based on rolling and weekly headroom.
|
||||
- Optimized Anthropic prompt caching with rolling 5-minute breakpoints and idle refreshes to keep the prompt prefix warm.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Ollama chat adapter to correctly forward sampling parameters like temperature and topP to the provider.
|
||||
- Fixed OpenAI agent turns ending prematurely after a web search with no visible answer, ensuring the agent continues processing the search results.
|
||||
- Fixed a resource leak where completed model streams retained provider concurrency permits longer than necessary.
|
||||
- Fixed image input support for qwen3.8-max and newer models when using DashScope compatible-mode.
|
||||
- Fixed xAI usage reporting falling back to a stale cache when a new weekly cycle starts with 0% consumed credits.
|
||||
- Fixed Together AI login validation failures by querying the authenticated models list instead of a hardcoded model.
|
||||
- Fixed credential-health probes and usage fetches failing when using reference-stored API keys (such as environment variables or commands) by ensuring secrets are correctly resolved.
|
||||
- Fixed Perplexity email-OTP login by preserving the session cookies required for verification.
|
||||
- Fixed thinking configuration for OpenAI and Daybreak models to correctly send reasoning.effort: "none" when thinking is disabled.
|
||||
- Fixed Grok runaway thinking streams bypassing the thinking-loop guard.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed legacy local request-cost estimation machinery and database schemas previously used for OpenCode Go estimates.
|
||||
|
||||
## [17.2.15] - 2026-08-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where AWS_BEDROCK_SKIP_AUTH failed to expose Amazon Bedrock models when AWS credential files were unavailable.
|
||||
- Fixed an issue where forceReasoningOff was ignored by Anthropic and Google transports, which allowed native thinking alongside a caller-supplied external scratchpad.
|
||||
|
||||
## [17.2.14] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added `forceReasoningOff` and `disableReasoning` options to disable reasoning in OpenAI and Azure OpenAI models
|
||||
|
||||
## [17.2.13] - 2026-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Standardized first-party outbound User-Agent headers on `omp/<version>` via the shared `USER_AGENT` utility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107) by [@svperfecta](https://github.com/svperfecta)).
|
||||
- Fixed Antigravity Flash turns hanging after successful response headers when the endpoint never emitted an SSE event; the provider now cancels the stalled body and fails over after 60 seconds while retaining the longer allowance for Pro reasoning starts.
|
||||
- Fixed Cursor exec-bridge bash/grep calls failing ArkType validation when the server omitted optional frame fields: synthesized and executed tool args now drop `undefined` keys (`cwd`, `case`, `skip`, `timeout`) instead of writing `optional: value || undefined`.
|
||||
- Fixed Cursor sessions double-executing settled tools when `tools.format` is an owned dialect (e.g. `gemini`): `wrapInbandToolStream` rebuilt toolCall blocks without copying `kCursorExecResolved`, so agent-loop re-ran bash/grep/todo and appended a second result for the same call id.
|
||||
- Fixed Codex Responses Lite requests for opaque model codenames such as Daybreak omitting the required `reasoning.context: "all_turns"` value and failing with HTTP 400.
|
||||
- Fixed Cursor personal usage reporting for current Pro / Pro+ / Ultra `/api/usage-summary` payloads that expose `individualUsage.plan` (and optional `onDemand`) instead of the older `individualUsage.overall` bucket ([#7998](https://github.com/can1357/oh-my-pi/pull/7998) by [@dnth](https://github.com/dnth)).
|
||||
- Allowed passive Google callers to accept empty or thinking-only `STOP` responses as successful silence instead of exhausting the provider's empty-response retry budget. ([#8223](https://github.com/can1357/oh-my-pi/issues/8223))
|
||||
- Fixed the AWS credential resolver ignoring `role_arn` profiles: shared-config role chaining (`source_profile` recursion, `web_identity_token_file`, `credential_source`) now resolves via STS `AssumeRole`/`AssumeRoleWithWebIdentity`, honoring `role_session_name`/`duration_seconds`/`external_id`, so Bedrock is detected on EKS/IRSA and multi-account setups instead of reporting "No models available" ([#8209](https://github.com/can1357/oh-my-pi/issues/8209)).
|
||||
- Fixed Bedrock availability being under-detected on Nitro/EKS hosts: the EC2 metadata probe now recognizes Nitro DMI markers (`board_asset_tag` instance ids, `Amazon EC2` vendor fields) in addition to the Xen `ec2` UUID prefix ([#8209](https://github.com/can1357/oh-my-pi/issues/8209)).
|
||||
- Fixed DeepSeek Responses targets (opencode-go) rejecting a thinking-mode continuation with `400 The reasoning_text in the thinking mode must be passed back to the API` after a prewalk hand-off plus mid-run compaction: the Responses input builder re-encoded replayed assistant turns without a reasoning item, so the request enabled reasoning but shipped no `reasoning_text`. The encoder now synthesizes a `reasoning_text` reasoning item for every replayed assistant turn when the target requires reasoning replay in thinking mode (`requiresReasoningContentForAllAssistantTurns` / `requiresReasoningContentForToolCalls`), mirroring the chat-completions `reasoning_content` safety net ([#8248](https://github.com/can1357/oh-my-pi/issues/8248)).
|
||||
|
||||
## [17.2.12] - 2026-08-08
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@oh-my-pi/pi-ai",
|
||||
"version": "17.2.12",
|
||||
"version": "17.3.1",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"homepage": "https://omp.sh",
|
||||
"author": "Can Boluk",
|
||||
|
||||
@@ -36,8 +36,6 @@ import type {
|
||||
CredentialRankingContext,
|
||||
CredentialRankingStrategy,
|
||||
ObservedUsageEntry,
|
||||
UsageCostHistoryEntry,
|
||||
UsageCostHistoryQuery,
|
||||
UsageCredential,
|
||||
UsageFetchContext,
|
||||
UsageFetchParams,
|
||||
@@ -66,7 +64,7 @@ import {
|
||||
listCodexResetCredits,
|
||||
pickSoonestExpiringCredit,
|
||||
} from "./usage/openai-codex-reset";
|
||||
import { opencodeGoUsageProvider } from "./usage/opencode-go";
|
||||
import { opencodeGoRankingStrategy, opencodeGoUsageProvider } from "./usage/opencode-go";
|
||||
import { syntheticUsageProvider } from "./usage/synthetic";
|
||||
import { umansUsageProvider } from "./usage/umans";
|
||||
import { xaiOauthUsageProvider } from "./usage/xai-oauth";
|
||||
@@ -454,10 +452,6 @@ export interface AuthCredentialStore {
|
||||
* skipped — the broker host records into its own database instead.
|
||||
*/
|
||||
recordUsageSnapshots?(entries: UsageHistoryEntry[]): void;
|
||||
/** Append observed request costs for providers without upstream usage APIs. */
|
||||
recordUsageCosts?(entries: UsageCostHistoryEntry[]): void;
|
||||
/** Read observed request costs, oldest first. */
|
||||
listUsageCosts?(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[];
|
||||
/** Read recorded usage-limit snapshots, oldest first. */
|
||||
listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[];
|
||||
/**
|
||||
@@ -698,6 +692,10 @@ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>> = {
|
||||
"google-antigravity": 2,
|
||||
zai: 2,
|
||||
// v2: retires cached reports from the OMP-observed spend estimator (dollar
|
||||
// units) now that limits come from the upstream percent-based `/usage`
|
||||
// endpoint; the 24h last-good retention would otherwise keep serving them.
|
||||
"opencode-go": 2,
|
||||
// v2: cache identity gained an `org:` component so two subscriptions on one
|
||||
// account email stop sharing a slot. v3 retires parsed reports created before
|
||||
// Anthropic extra-usage rows existed; header ingestion can otherwise keep
|
||||
@@ -1072,6 +1070,7 @@ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>(
|
||||
["anthropic", claudeRankingStrategy],
|
||||
["google-antigravity", antigravityRankingStrategy],
|
||||
["zai", zaiRankingStrategy],
|
||||
["opencode-go", opencodeGoRankingStrategy],
|
||||
]);
|
||||
|
||||
function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
|
||||
@@ -3175,7 +3174,6 @@ export class AuthStorage {
|
||||
const report = await providerImpl.fetchUsage(params, {
|
||||
fetch: this.#usageFetch,
|
||||
logger: this.#usageLogger,
|
||||
listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [],
|
||||
});
|
||||
// Attribute the report to the credential's organization. The orgId and
|
||||
// orgName fallbacks apply independently: Claude's usage endpoint stamps
|
||||
@@ -3197,6 +3195,13 @@ export class AuthStorage {
|
||||
}
|
||||
return report;
|
||||
} catch (error) {
|
||||
if (error instanceof AIError.ProviderHttpError && (error.status === 401 || error.status === 403)) {
|
||||
// Definitive auth failure (revoked key, lapsed subscription): purge
|
||||
// the last-good report so #fetchUsageCached's failure branch can't
|
||||
// keep rendering and ranking from stale quota the way it does for
|
||||
// transient failures. Mirrors the definitive-OAuth-refresh path.
|
||||
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
|
||||
}
|
||||
logger.debug("AuthStorage usage fetch failed", {
|
||||
provider: request.provider,
|
||||
error: String(error),
|
||||
@@ -3297,42 +3302,6 @@ export class AuthStorage {
|
||||
return this.#store.listUsageHistory?.(query) ?? [];
|
||||
}
|
||||
|
||||
/** Record one observed provider request cost for later local usage aggregation. */
|
||||
recordUsageCost(
|
||||
provider: Provider,
|
||||
costUsd: number,
|
||||
options?: { sessionId?: string; recordedAt?: number; baseUrl?: string },
|
||||
): boolean {
|
||||
if (!Number.isFinite(costUsd) || costUsd <= 0) return false;
|
||||
const record = this.#store.recordUsageCosts;
|
||||
if (!record) return false;
|
||||
const credential = this.#resolveObservedUsageCredential(provider, options?.sessionId);
|
||||
if (!credential) return false;
|
||||
const entry: UsageCostHistoryEntry = {
|
||||
recordedAt: options?.recordedAt ?? Date.now(),
|
||||
provider,
|
||||
accountKey: this.#buildUsageCacheIdentity(credential),
|
||||
costUsd,
|
||||
};
|
||||
try {
|
||||
record.call(this.#store, [entry]);
|
||||
const cacheKey = this.#buildUsageReportCacheKey({
|
||||
provider,
|
||||
credential,
|
||||
baseUrl: options?.baseUrl,
|
||||
});
|
||||
const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
|
||||
this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: Date.now() - 1 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.#usageLogger?.debug("usage cost record failed", {
|
||||
provider,
|
||||
error: String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one completed request's usage to the store's observer hook.
|
||||
* Broker-backed stores batch these into per-install reports so the broker
|
||||
@@ -3383,28 +3352,6 @@ export class AuthStorage {
|
||||
return this.#store.getClientUsageSummary?.(sinceMs) ?? { clients: [] };
|
||||
}
|
||||
|
||||
#resolveObservedUsageCredential(provider: Provider, sessionId?: string): UsageCredential | undefined {
|
||||
const entries = this.#getStoredCredentials(provider);
|
||||
const sessionCredential = this.#getSessionCredential(provider, sessionId);
|
||||
if (sessionCredential) {
|
||||
const credential = entries[sessionCredential.index]?.credential;
|
||||
if (credential) {
|
||||
return credential.type === "api_key"
|
||||
? { type: "api_key", apiKey: credential.key }
|
||||
: this.#buildUsageCredential(credential);
|
||||
}
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
const credential = entries[0]!.credential;
|
||||
return credential.type === "api_key"
|
||||
? { type: "api_key", apiKey: credential.key }
|
||||
: this.#buildUsageCredential(credential);
|
||||
}
|
||||
const envKey = getEnvApiKey(provider);
|
||||
if (envKey) return { type: "api_key", apiKey: envKey };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ingestUsageHeaders(
|
||||
provider: Provider,
|
||||
headers: Record<string, string>,
|
||||
@@ -3488,9 +3435,9 @@ export class AuthStorage {
|
||||
return true;
|
||||
}
|
||||
|
||||
#collectUsageRequests(options?: {
|
||||
async #collectUsageRequests(options?: {
|
||||
baseUrlResolver?: (provider: Provider) => string | undefined;
|
||||
}): UsageRequestDescriptor[] {
|
||||
}): Promise<UsageRequestDescriptor[]> {
|
||||
const resolver = this.#usageProviderResolver;
|
||||
if (!resolver) return [];
|
||||
|
||||
@@ -3550,10 +3497,19 @@ export class AuthStorage {
|
||||
|
||||
for (const entry of entries) {
|
||||
const credential = entry.credential;
|
||||
const request =
|
||||
credential.type === "api_key"
|
||||
? this.#buildUsageRequest(provider, { type: "api_key", apiKey: credential.key }, baseUrl)
|
||||
: this.#buildUsageRequestForOauth(provider, credential, baseUrl);
|
||||
let request: UsageRequestDescriptor;
|
||||
if (credential.type === "api_key") {
|
||||
// Stored keys may be references (env var name, "!command") —
|
||||
// resolve to the actual secret before it reaches a provider
|
||||
// fetcher's Authorization header. Unresolvable references are
|
||||
// skipped: probing with the literal reference string would
|
||||
// 401 and flag a working credential as bad.
|
||||
const apiKey = await this.#configValueResolver(credential.key);
|
||||
if (!apiKey) continue;
|
||||
request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl);
|
||||
} else {
|
||||
request = this.#buildUsageRequestForOauth(provider, credential, baseUrl);
|
||||
}
|
||||
if (providerImpl.supports && !providerImpl.supports(request)) continue;
|
||||
requests.push(request);
|
||||
}
|
||||
@@ -4005,7 +3961,7 @@ export class AuthStorage {
|
||||
}
|
||||
if (!this.#usageProviderResolver) return null;
|
||||
|
||||
const requests = this.#collectUsageRequests(options);
|
||||
const requests = await this.#collectUsageRequests(options);
|
||||
if (requests.length === 0) return [];
|
||||
|
||||
this.#usageLogger?.debug("Usage fetch requested", {
|
||||
@@ -4101,7 +4057,6 @@ export class AuthStorage {
|
||||
const ctx: UsageFetchContext = {
|
||||
fetch: this.#usageFetch,
|
||||
logger: this.#usageLogger,
|
||||
listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [],
|
||||
};
|
||||
|
||||
const results: CredentialHealthResult[] = [];
|
||||
@@ -4123,10 +4078,21 @@ export class AuthStorage {
|
||||
|
||||
const baseUrl = options?.baseUrlResolver?.(row.provider as Provider);
|
||||
const cred = row.credential;
|
||||
const initialRequest: UsageRequestDescriptor =
|
||||
cred.type === "api_key"
|
||||
? this.#buildUsageRequest(row.provider as Provider, { type: "api_key", apiKey: cred.key }, baseUrl)
|
||||
: this.#buildUsageRequestForOauth(row.provider as Provider, cred, baseUrl);
|
||||
let initialRequest: UsageRequestDescriptor;
|
||||
if (cred.type === "api_key") {
|
||||
// Stored keys may be references (env var name, "!command") — probe
|
||||
// with the resolved secret, not the reference string, so both the
|
||||
// usage probe and the completion probe exercise the real bytes.
|
||||
const apiKey = await this.#configValueResolver(cred.key);
|
||||
if (!apiKey) {
|
||||
base.reason = "api key reference could not be resolved";
|
||||
results.push(base);
|
||||
continue;
|
||||
}
|
||||
initialRequest = this.#buildUsageRequest(row.provider as Provider, { type: "api_key", apiKey }, baseUrl);
|
||||
} else {
|
||||
initialRequest = this.#buildUsageRequestForOauth(row.provider as Provider, cred, baseUrl);
|
||||
}
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
const probeSignal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
||||
|
||||
@@ -25,8 +25,6 @@ import type {
|
||||
ClientProviderUsage,
|
||||
ClientUsageReport,
|
||||
ClientUsageSummary,
|
||||
UsageCostHistoryEntry,
|
||||
UsageCostHistoryQuery,
|
||||
UsageHistoryEntry,
|
||||
UsageHistoryQuery,
|
||||
} from "../usage";
|
||||
@@ -387,8 +385,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
#releaseCredentialRefreshLeaseStmt: Statement;
|
||||
#credentialBlockReconcileAfter: Map<string, number> = new Map();
|
||||
#insertUsageHistoryStmt: Statement;
|
||||
#insertUsageCostStmt: Statement;
|
||||
#listUsageCostsStmt: Statement;
|
||||
#lastUsageHistoryStmt: Statement;
|
||||
#listUsageHistoryStmt: Statement;
|
||||
#updateUsageHistoryStmt: Statement;
|
||||
@@ -516,12 +512,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#listUsageHistoryStmt = this.#db.prepare(
|
||||
"SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC",
|
||||
);
|
||||
this.#insertUsageCostStmt = this.#db.prepare(
|
||||
"INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)",
|
||||
);
|
||||
this.#listUsageCostsStmt = this.#db.prepare(
|
||||
"SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC",
|
||||
);
|
||||
}
|
||||
|
||||
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
|
||||
@@ -634,14 +624,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
resets_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at);
|
||||
CREATE TABLE IF NOT EXISTS usage_cost_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recorded_at INTEGER NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
account_key TEXT NOT NULL,
|
||||
cost_usd REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
install_id TEXT PRIMARY KEY,
|
||||
@@ -1769,42 +1751,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
recordUsageCosts(entries: UsageCostHistoryEntry[]): void {
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd);
|
||||
}
|
||||
} catch {
|
||||
// Cost history is best-effort; never break request persistence.
|
||||
}
|
||||
}
|
||||
|
||||
listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] {
|
||||
try {
|
||||
const provider = query?.provider ?? null;
|
||||
const accountKey = query?.accountKey ?? null;
|
||||
const rows = this.#listUsageCostsStmt.all(
|
||||
query?.sinceMs ?? 0,
|
||||
provider,
|
||||
provider,
|
||||
accountKey,
|
||||
accountKey,
|
||||
) as Array<{
|
||||
recorded_at: number;
|
||||
provider: string;
|
||||
account_key: string;
|
||||
cost_usd: number;
|
||||
}>;
|
||||
return rows.map(row => ({
|
||||
recordedAt: row.recorded_at,
|
||||
provider: row.provider as Provider,
|
||||
accountKey: row.account_key,
|
||||
costUsd: row.cost_usd,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
recordClientUsage(report: ClientUsageReport): void {
|
||||
const now = Date.now();
|
||||
@@ -2051,8 +1997,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#lastUsageHistoryStmt.finalize();
|
||||
this.#listUsageHistoryStmt.finalize();
|
||||
this.#updateUsageHistoryStmt.finalize();
|
||||
this.#insertUsageCostStmt.finalize();
|
||||
this.#listUsageCostsStmt.finalize();
|
||||
this.#updateIfMatchesStmt.finalize();
|
||||
this.#updateIfMatchesWithLeaseStmt.finalize();
|
||||
this.#deleteIfMatchesWithLeaseStmt.finalize();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../types";
|
||||
import {
|
||||
clearStreamingPartialJson,
|
||||
copyCursorExecResolved,
|
||||
getStreamingPartialJson,
|
||||
type StreamingPartialJsonCarrier,
|
||||
setStreamingPartialJson,
|
||||
@@ -54,6 +55,7 @@ function cloneToolCall(source: StreamingToolCall): StreamingToolCall {
|
||||
};
|
||||
const partialJson = getStreamingPartialJson(source);
|
||||
if (partialJson !== undefined) setStreamingPartialJson(block, partialJson);
|
||||
copyCursorExecResolved(block, source);
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -65,6 +67,7 @@ function syncToolCall(target: StreamingToolCall, source: StreamingToolCall): voi
|
||||
const partialJson = getStreamingPartialJson(source);
|
||||
if (partialJson === undefined) clearStreamingPartialJson(target);
|
||||
else setStreamingPartialJson(target, partialJson);
|
||||
copyCursorExecResolved(target, source);
|
||||
}
|
||||
|
||||
function hasNamedNativeToolCall(source: StreamingToolCall | undefined): source is StreamingToolCall {
|
||||
|
||||
@@ -13,7 +13,11 @@ export type AwsCredentialsErrorKind =
|
||||
/** STS web-identity exchange failed or returned malformed credentials. */
|
||||
| "web-identity"
|
||||
/** ECS/container credential endpoint failed or returned malformed credentials. */
|
||||
| "container";
|
||||
| "container"
|
||||
/** Shared-config role chain is misconfigured (cycle, missing source_profile, unsupported credential_source). */
|
||||
| "profile"
|
||||
/** STS `AssumeRole` call failed or returned malformed credentials. */
|
||||
| "assume-role";
|
||||
|
||||
/** A failure resolving AWS credentials for the Bedrock provider. */
|
||||
export class AwsCredentialsError extends Error {
|
||||
|
||||
@@ -47,6 +47,19 @@ import { decodeEventStream } from "./aws-eventstream";
|
||||
import { signRequest } from "./aws-sigv4";
|
||||
import { transformMessages } from "./transform-messages";
|
||||
|
||||
/**
|
||||
* Headers SigV4 generates for itself. A caller cannot be allowed to supply these:
|
||||
* `signRequest` would sign the caller's value but return its own, so the signature
|
||||
* would not match what goes on the wire.
|
||||
*/
|
||||
const SIGNER_OWNED_HEADERS = new Set(["host", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]);
|
||||
|
||||
/** Headers the Bedrock request sets itself; a caller copy in any casing duplicates them. */
|
||||
// `content-length` included: the fetch layer recomputes it from the serialized
|
||||
// body, so a caller value would be signed but not sent, and AWS rejects the
|
||||
// mismatch.
|
||||
const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization", "content-length"]);
|
||||
|
||||
export type BedrockThinkingDisplay = "summarized" | "omitted";
|
||||
|
||||
export interface BedrockOptions extends StreamOptions {
|
||||
@@ -356,7 +369,32 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
|
||||
const bodyText = JSON.stringify(commandInput);
|
||||
const body = new TextEncoder().encode(bodyText);
|
||||
// Caller headers are merged BEFORE signing, so SigV4 covers them and they
|
||||
// reach the wire. Bedrock built its header map from scratch and ignored
|
||||
// `options.headers` entirely, so tracing/attribution headers set by a
|
||||
// caller (or by a `before_provider_headers` extension) were silently
|
||||
// dropped here while working on every other provider. Content-type and
|
||||
// accept stay last: the eventstream framing is not the caller's to change.
|
||||
//
|
||||
// The signer's OWN headers are dropped first, and that is load-bearing:
|
||||
// `signRequest` lets a caller value overwrite `host`/`x-amz-*` in the map
|
||||
// it signs, but always RETURNS the generated ones, which `requestHeaders`
|
||||
// below then puts on the wire. A caller supplying any of them would sign
|
||||
// one set of values and send another, and Bedrock would reject every
|
||||
// request with a signature mismatch.
|
||||
// Lower-cased, and names the request sets itself are dropped. Keeping a
|
||||
// caller `Content-Type` beside the fixed `content-type` leaves TWO object
|
||||
// keys: SigV4 signs one value while fetch canonicalizes both into a single
|
||||
// comma-joined wire header, so AWS validates different bytes than were
|
||||
// signed and rejects the request.
|
||||
const callerHeaders: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(options?.headers ?? {})) {
|
||||
const field = name.toLowerCase();
|
||||
if (SIGNER_OWNED_HEADERS.has(field) || BEDROCK_RESERVED_HEADERS.has(field)) continue;
|
||||
callerHeaders[field] = value;
|
||||
}
|
||||
const baseHeaders: Record<string, string> = {
|
||||
...callerHeaders,
|
||||
"content-type": "application/json",
|
||||
accept: "application/vnd.amazon.eventstream",
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ import { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeout
|
||||
export { AnthropicApiError, AnthropicConnectionError, AnthropicConnectionTimeoutError };
|
||||
|
||||
import type { FetchImpl } from "../types";
|
||||
import type { MessageCreateParamsStreaming } from "./anthropic-wire";
|
||||
import type { MessageCreateParams } from "./anthropic-wire";
|
||||
|
||||
/** Default pre-response timeout, matching the SDK's 10-minute default. */
|
||||
const DEFAULT_TIMEOUT_MS = 600_000;
|
||||
@@ -173,7 +173,7 @@ export class AnthropicMessages {
|
||||
this.#path = path;
|
||||
}
|
||||
|
||||
create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest {
|
||||
create(params: MessageCreateParams, options?: AnthropicRequestOptions): AnthropicApiRequest {
|
||||
return this.#client.request(this.#path, params, options);
|
||||
}
|
||||
}
|
||||
@@ -184,8 +184,8 @@ export class AnthropicMessages {
|
||||
* alternative Messages-API client via `AnthropicOptions.client`.
|
||||
*/
|
||||
export interface AnthropicMessagesClientLike {
|
||||
messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown };
|
||||
beta?: { messages: { create(params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): unknown } };
|
||||
messages: { create(params: MessageCreateParams, options?: AnthropicRequestOptions): unknown };
|
||||
beta?: { messages: { create(params: MessageCreateParams, options?: AnthropicRequestOptions): unknown } };
|
||||
}
|
||||
|
||||
export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
|
||||
@@ -199,7 +199,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
|
||||
this.beta = { messages: new AnthropicMessages(this, "/v1/messages?beta=true") };
|
||||
}
|
||||
|
||||
request(path: string, params: MessageCreateParamsStreaming, options?: AnthropicRequestOptions): AnthropicApiRequest {
|
||||
request(path: string, params: MessageCreateParams, options?: AnthropicRequestOptions): AnthropicApiRequest {
|
||||
return new AnthropicApiRequest(() => this.#send(path, params, options));
|
||||
}
|
||||
|
||||
@@ -218,11 +218,7 @@ export class AnthropicMessagesClient implements AnthropicMessagesClientLike {
|
||||
return headers;
|
||||
}
|
||||
|
||||
async #send(
|
||||
path: string,
|
||||
params: MessageCreateParamsStreaming,
|
||||
options?: AnthropicRequestOptions,
|
||||
): Promise<Response> {
|
||||
async #send(path: string, params: MessageCreateParams, options?: AnthropicRequestOptions): Promise<Response> {
|
||||
const opts = this.#options;
|
||||
const fetchFn: FetchImpl = opts.fetch ?? fetch;
|
||||
const callerSignal = options?.signal;
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
type ContentBlockParam,
|
||||
type FallbackParam,
|
||||
isAnthropicWebSearchHistoryBlock,
|
||||
type MessageCreateParams,
|
||||
type MessageCreateParamsStreaming,
|
||||
type MessageParam,
|
||||
type RawMessageStreamEvent,
|
||||
@@ -482,17 +483,11 @@ function dropAnthropicStrictTools(params: MessageCreateParamsStreaming): void {
|
||||
function getCacheControl(
|
||||
model: Model<"anthropic-messages">,
|
||||
cacheRetention: CacheRetention | undefined,
|
||||
isOAuthToken: boolean,
|
||||
): { retention: CacheRetention; cacheControl?: AnthropicCacheControl } {
|
||||
// OAuth mirrors Claude Code and always defaults to 1h retention. API-key
|
||||
// requests also default to 1h where the endpoint supports it (canonical
|
||||
// Anthropic API, `compat.supportsLongCacheRetention`): agent sessions
|
||||
// routinely idle past 5 minutes waiting on background jobs, and a 5m
|
||||
// breakpoint cold-misses the entire prefix on resume. PI_CACHE_RETENTION
|
||||
// still overrides the API-key default in either direction.
|
||||
const retention = isOAuthToken
|
||||
? (cacheRetention ?? "long")
|
||||
: resolveCacheRetention(cacheRetention, model.compat.supportsLongCacheRetention ? "long" : "short");
|
||||
// Five-minute writes are the cheapest cache population strategy. Longer
|
||||
// retention remains an explicit PI_CACHE_RETENTION/request override; idle
|
||||
// sessions keep the short entry warm with bounded read-only refreshes.
|
||||
const retention = resolveCacheRetention(cacheRetention, "short");
|
||||
if (retention === "none") {
|
||||
return { retention };
|
||||
}
|
||||
@@ -1653,6 +1648,31 @@ export function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLi
|
||||
}
|
||||
}
|
||||
|
||||
function parseAnthropicWireUsage(value: unknown): AnthropicWireUsage | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const cacheCreation = isRecord(value.cache_creation)
|
||||
? {
|
||||
...(typeof value.cache_creation.ephemeral_5m_input_tokens === "number"
|
||||
? { ephemeral_5m_input_tokens: value.cache_creation.ephemeral_5m_input_tokens }
|
||||
: {}),
|
||||
...(typeof value.cache_creation.ephemeral_1h_input_tokens === "number"
|
||||
? { ephemeral_1h_input_tokens: value.cache_creation.ephemeral_1h_input_tokens }
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
...(typeof value.input_tokens === "number" ? { input_tokens: value.input_tokens } : {}),
|
||||
...(typeof value.output_tokens === "number" ? { output_tokens: value.output_tokens } : {}),
|
||||
...(typeof value.cache_read_input_tokens === "number"
|
||||
? { cache_read_input_tokens: value.cache_read_input_tokens }
|
||||
: {}),
|
||||
...(typeof value.cache_creation_input_tokens === "number"
|
||||
? { cache_creation_input_tokens: value.cache_creation_input_tokens }
|
||||
: {}),
|
||||
...(cacheCreation === undefined ? {} : { cache_creation: cacheCreation }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnthropicFallbackWireBlock(value: unknown): AnthropicFallbackContent | undefined {
|
||||
if (!isRecord(value) || value.type !== "fallback") return undefined;
|
||||
const from = isRecord(value.from) && typeof value.from.model === "string" ? value.from.model : undefined;
|
||||
@@ -1857,6 +1877,7 @@ const streamAnthropicOnce = (
|
||||
});
|
||||
}
|
||||
|
||||
const zeroOutputCacheRefresh = options?.anthropicCacheRefreshRequest === true;
|
||||
let client: AnthropicMessagesClientLike;
|
||||
let isOAuthToken: boolean;
|
||||
|
||||
@@ -1927,7 +1948,7 @@ const streamAnthropicOnce = (
|
||||
// requests must not deviate from CC's header fingerprint.
|
||||
if (
|
||||
!(options?.isOAuth ?? isAnthropicOAuthToken(apiKey)) &&
|
||||
getCacheControl(model, options?.cacheRetention, false).cacheControl?.ttl === "1h" &&
|
||||
getCacheControl(model, options?.cacheRetention).cacheControl?.ttl === "1h" &&
|
||||
!extraBetas.includes(extendedCacheTtlBeta)
|
||||
) {
|
||||
extraBetas.push(extendedCacheTtlBeta);
|
||||
@@ -1958,7 +1979,7 @@ const streamAnthropicOnce = (
|
||||
model,
|
||||
apiKey,
|
||||
extraBetas,
|
||||
stream: true,
|
||||
stream: !zeroOutputCacheRefresh,
|
||||
interleavedThinking: options?.interleavedThinking ?? true,
|
||||
headers: options?.headers,
|
||||
dynamicHeaders: copilotDynamicHeaders?.headers,
|
||||
@@ -2005,6 +2026,60 @@ const streamAnthropicOnce = (
|
||||
return nextParams;
|
||||
};
|
||||
let params = await prepareParams();
|
||||
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
|
||||
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
||||
const requestTimeoutMs =
|
||||
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
|
||||
|
||||
if (zeroOutputCacheRefresh) {
|
||||
const refreshParams: MessageCreateParams = { ...params, max_tokens: 0, stream: false };
|
||||
rawRequestDump = {
|
||||
provider: model.provider,
|
||||
api: output.api,
|
||||
model: model.id,
|
||||
method: "POST",
|
||||
url: `${baseUrl}/v1/messages${isOAuthToken ? "?beta=true" : ""}`,
|
||||
body: refreshParams,
|
||||
};
|
||||
const { requestSignal } = activeAbortTracker;
|
||||
const requestOptions = {
|
||||
...createSdkStreamRequestOptions(requestSignal, requestTimeoutMs),
|
||||
maxRetries: 0,
|
||||
};
|
||||
const request: unknown =
|
||||
isOAuthToken && client.beta
|
||||
? client.beta.messages.create(refreshParams, requestOptions)
|
||||
: client.messages.create(refreshParams, requestOptions);
|
||||
if (!hasAnthropicRawResponseRequest(request)) {
|
||||
throw new AIError.AnthropicStreamEnvelopeError(
|
||||
"Anthropic cache refresh request did not expose a raw response",
|
||||
);
|
||||
}
|
||||
const response = await request.asResponse();
|
||||
await notifyProviderResponse(options, response, model, response.headers.get("request-id"));
|
||||
const body: unknown = await response.json();
|
||||
if (!isRecord(body)) {
|
||||
throw new AIError.AnthropicStreamEnvelopeError("Anthropic cache refresh returned a malformed response");
|
||||
}
|
||||
const wireUsage = parseAnthropicWireUsage(body.usage);
|
||||
if (!wireUsage) {
|
||||
throw new AIError.AnthropicStreamEnvelopeError("Anthropic cache refresh response omitted usage");
|
||||
}
|
||||
if (typeof body.id === "string") output.responseId = body.id;
|
||||
output.usage.input = wireUsage.input_tokens ?? 0;
|
||||
output.usage.output = wireUsage.output_tokens ?? 0;
|
||||
output.usage.cacheRead = wireUsage.cache_read_input_tokens ?? 0;
|
||||
output.usage.cacheWrite = wireUsage.cache_creation_input_tokens ?? 0;
|
||||
applyAnthropicUsageExtras(output.usage, wireUsage);
|
||||
output.usage.totalTokens =
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
output.duration = performance.now() - startTime;
|
||||
stream.push({ type: "start", partial: output });
|
||||
stream.push({ type: "done", reason: "stop", message: output });
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in flag: the response parser only honors `fallback` content
|
||||
// blocks and `usage.iterations` when the current request opted into
|
||||
@@ -2019,10 +2094,6 @@ const streamAnthropicOnce = (
|
||||
| (AnthropicServerToolContent & { [kStreamingPartialJson]?: string })
|
||||
| (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number })
|
||||
) & { [kStreamingBlockIndex]: number };
|
||||
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
|
||||
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
||||
const requestTimeoutMs =
|
||||
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
|
||||
const blocks = output.content as Block[];
|
||||
const finalizeStreamBlock = (block: Block, contentIndex: number): void => {
|
||||
if (block.type === "text") {
|
||||
@@ -2807,65 +2878,19 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (model, con
|
||||
export type AnthropicSystemBlock = {
|
||||
type: "text";
|
||||
text: string;
|
||||
cache_control?: AnthropicCacheControl;
|
||||
};
|
||||
type SystemBlockOptions = {
|
||||
includeClaudeCodeInstruction?: boolean;
|
||||
extraInstructions?: string[];
|
||||
/** Text of the first user message — used as fingerprint seed for the billing header. */
|
||||
firstUserMessageText?: string;
|
||||
cacheControl?: AnthropicCacheControl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Place system-block cache breakpoints that survive volatile project context.
|
||||
*
|
||||
* omp normally appends its project footer (cwd, date, workspace tree) after the
|
||||
* stable system prefix. When cwd is outside a single direct child repository,
|
||||
* an active-repo context block follows that footer. Caching up to the last three
|
||||
* eligible blocks therefore covers both layouts:
|
||||
*
|
||||
* - stable prefix, project footer
|
||||
* - stable prefix, project footer, active-repo context
|
||||
*
|
||||
* A footer change can then fall back to the stable-prefix entry instead of
|
||||
* re-writing the entire system cache (issue #7324).
|
||||
*
|
||||
* @returns breakpoints placed, capped by `maxBreakpoints`.
|
||||
*/
|
||||
function cacheSystemPrefixBreakpoints(
|
||||
blocks: AnthropicSystemBlock[],
|
||||
cacheControl: AnthropicCacheControl | undefined,
|
||||
maxBreakpoints: number,
|
||||
firstCacheableIndex: number,
|
||||
): number {
|
||||
if (!cacheControl || maxBreakpoints <= 0) return 0;
|
||||
let placed = 0;
|
||||
for (let index = blocks.length - 1; index >= firstCacheableIndex && placed < maxBreakpoints; index--) {
|
||||
if (blocks[index].cache_control != null) continue;
|
||||
blocks[index] = { ...blocks[index], cache_control: cloneAnthropicCacheControl(cacheControl) };
|
||||
placed++;
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
||||
/**
|
||||
* First system-block index that may carry a cache breakpoint. Skips the OAuth
|
||||
* cloak blocks that must stay uncached: the CC billing header (block 0, a
|
||||
* per-request fingerprint) and the Claude Code identity instruction (block 1).
|
||||
*/
|
||||
function firstCacheableSystemIndex(blocks: readonly AnthropicSystemBlock[]): number {
|
||||
let index = 0;
|
||||
if (blocks[index]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX)) index++;
|
||||
if (blocks[index]?.text === claudeCodeSystemInstruction) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
export function buildAnthropicSystemBlocks(
|
||||
systemPrompt: readonly string[] | undefined,
|
||||
options: SystemBlockOptions = {},
|
||||
): AnthropicSystemBlock[] | undefined {
|
||||
const { includeClaudeCodeInstruction = false, extraInstructions = [], firstUserMessageText, cacheControl } = options;
|
||||
const { includeClaudeCodeInstruction = false, extraInstructions = [], firstUserMessageText } = options;
|
||||
const sanitizedPrompts = normalizeSystemPrompts(systemPrompt);
|
||||
const trimmedInstructions = extraInstructions.map(instruction => instruction.trim()).filter(Boolean);
|
||||
const hasBillingHeader = sanitizedPrompts.some(prompt => prompt.startsWith(CLAUDE_BILLING_HEADER_PREFIX));
|
||||
@@ -2882,7 +2907,6 @@ export function buildAnthropicSystemBlocks(
|
||||
for (const prompt of sanitizedPrompts) {
|
||||
blocks.push({ type: "text", text: prompt });
|
||||
}
|
||||
cacheSystemPrefixBreakpoints(blocks, cacheControl, 3, firstCacheableSystemIndex(blocks));
|
||||
|
||||
return blocks;
|
||||
}
|
||||
@@ -2894,10 +2918,6 @@ export function buildAnthropicSystemBlocks(
|
||||
for (const prompt of sanitizedPrompts) {
|
||||
blocks.push({ type: "text", text: prompt });
|
||||
}
|
||||
const lastIndex = blocks.length - 1;
|
||||
if (cacheControl && lastIndex >= 0 && blocks[lastIndex].cache_control == null) {
|
||||
blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cloneAnthropicCacheControl(cacheControl) };
|
||||
}
|
||||
return blocks.length > 0 ? blocks : undefined;
|
||||
}
|
||||
|
||||
@@ -3149,29 +3169,17 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, maxAll
|
||||
thinking.budget_tokens = clampedBudget;
|
||||
}
|
||||
|
||||
type CacheControlBlock = {
|
||||
cache_control?: AnthropicCacheControl | null;
|
||||
};
|
||||
|
||||
function applyCacheControlToLastTextBlock(
|
||||
blocks: Array<ContentBlockParam & CacheControlBlock>,
|
||||
cacheControl: AnthropicCacheControl,
|
||||
): boolean {
|
||||
if (blocks.length === 0) return false;
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
if (blocks[i].type === "text") {
|
||||
if (blocks[i].cache_control != null) return false;
|
||||
blocks[i] = { ...blocks[i], cache_control: cloneAnthropicCacheControl(cacheControl) };
|
||||
return true;
|
||||
function applyCacheControlToLastBlock(blocks: ContentBlockParam[], cacheControl: AnthropicCacheControl): boolean {
|
||||
for (let index = blocks.length - 1; index >= 0; index--) {
|
||||
const block = blocks[index];
|
||||
// Anthropic rejects cache_control on generated reasoning and fallback
|
||||
// boundary blocks. Preserve the requested trailing boundary on every
|
||||
// ordinary content block, including tool use and tool results.
|
||||
if (block.type === "thinking" || block.type === "redacted_thinking" || block.type === "fallback") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// No text block — fall back to the last block that accepts cache_control;
|
||||
// thinking/redacted_thinking blocks reject the field with a 400.
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const type = blocks[i].type;
|
||||
if (type === "thinking" || type === "redacted_thinking") continue;
|
||||
if (blocks[i].cache_control != null) return false;
|
||||
blocks[i] = { ...blocks[i], cache_control: cloneAnthropicCacheControl(cacheControl) };
|
||||
if ("cache_control" in block && block.cache_control != null) return false;
|
||||
blocks[index] = { ...block, cache_control: cloneAnthropicCacheControl(cacheControl) };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -3180,28 +3188,10 @@ function applyCacheControlToLastTextBlock(
|
||||
function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?: AnthropicCacheControl): void {
|
||||
if (!cacheControl) return;
|
||||
|
||||
const MAX_CACHE_BREAKPOINTS = 4;
|
||||
let cacheBreakpointsUsed = countCacheControlBreakpoints(params);
|
||||
if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
|
||||
let isCCLayout = false;
|
||||
|
||||
if (params.system && Array.isArray(params.system) && params.system.length > 0) {
|
||||
isCCLayout = params.system[0]?.text?.startsWith(CLAUDE_BILLING_HEADER_PREFIX) === true;
|
||||
const maxSystemBreakpoints = Math.min(3, MAX_CACHE_BREAKPOINTS - cacheBreakpointsUsed);
|
||||
cacheBreakpointsUsed += cacheSystemPrefixBreakpoints(
|
||||
params.system as AnthropicSystemBlock[],
|
||||
cacheControl,
|
||||
maxSystemBreakpoints,
|
||||
isCCLayout ? firstCacheableSystemIndex(params.system as AnthropicSystemBlock[]) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) return;
|
||||
|
||||
// `convertAnthropicMessages` appends this neutral pad after a trailing
|
||||
// assistant because Anthropic rejects assistant-prefill endings. It is absent
|
||||
// from the next normal turn, so caching it wastes a scarce breakpoint; anchor
|
||||
// the cache window on the preceding real assistant instead.
|
||||
// from the next normal turn, so anchor the rolling window on the preceding
|
||||
// real assistant instead.
|
||||
const trailingIndex = params.messages.length - 1;
|
||||
const trailingMessage = params.messages[trailingIndex];
|
||||
const hasTrailingAssistantPad =
|
||||
@@ -3209,160 +3199,20 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
|
||||
trailingMessage.content === "Continue." &&
|
||||
params.messages[trailingIndex - 1]?.role === "assistant";
|
||||
const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
|
||||
const messageWindowSize = isCCLayout ? 1 : 2;
|
||||
const start = Math.max(0, messageEnd - messageWindowSize + 1);
|
||||
for (let i = messageEnd; i >= start; i--) {
|
||||
if (cacheBreakpointsUsed >= MAX_CACHE_BREAKPOINTS) break;
|
||||
const message = params.messages[i];
|
||||
const start = Math.max(0, messageEnd - 1);
|
||||
for (let index = messageEnd; index >= start; index--) {
|
||||
const message = params.messages[index];
|
||||
if (!message) continue;
|
||||
if (typeof message.content === "string") {
|
||||
message.content = [
|
||||
{ type: "text", text: message.content, cache_control: cloneAnthropicCacheControl(cacheControl) },
|
||||
];
|
||||
cacheBreakpointsUsed++;
|
||||
} else if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
if (
|
||||
applyCacheControlToLastTextBlock(
|
||||
message.content as Array<ContentBlockParam & CacheControlBlock>,
|
||||
cacheControl,
|
||||
)
|
||||
) {
|
||||
cacheBreakpointsUsed++;
|
||||
}
|
||||
} else if (Array.isArray(message.content)) {
|
||||
applyCacheControlToLastBlock(message.content, cacheControl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCacheControlBlockTtl(block: CacheControlBlock, seenFiveMinute: { value: boolean }): void {
|
||||
const cacheControl = block.cache_control;
|
||||
if (!cacheControl) return;
|
||||
if (cacheControl.ttl !== "1h") {
|
||||
seenFiveMinute.value = true;
|
||||
return;
|
||||
}
|
||||
if (seenFiveMinute.value) {
|
||||
const normalized = cloneAnthropicCacheControl(cacheControl);
|
||||
delete normalized.ttl;
|
||||
block.cache_control = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCacheControlTtlOrdering(params: MessageCreateParamsStreaming): void {
|
||||
const seenFiveMinute = { value: false };
|
||||
if (params.tools) {
|
||||
for (const tool of params.tools as Array<AnthropicWireTool & CacheControlBlock>) {
|
||||
normalizeCacheControlBlockTtl(tool, seenFiveMinute);
|
||||
}
|
||||
}
|
||||
if (params.system && Array.isArray(params.system)) {
|
||||
for (const block of params.system as Array<AnthropicSystemBlock & CacheControlBlock>) {
|
||||
normalizeCacheControlBlockTtl(block, seenFiveMinute);
|
||||
}
|
||||
}
|
||||
for (const message of params.messages) {
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
for (const block of message.content as Array<ContentBlockParam & CacheControlBlock>) {
|
||||
normalizeCacheControlBlockTtl(block, seenFiveMinute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findLastCacheControlIndex<T extends CacheControlBlock>(blocks: T[]): number {
|
||||
for (let index = blocks.length - 1; index >= 0; index--) {
|
||||
if (blocks[index]?.cache_control != null) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function stripCacheControlExceptIndex<T extends CacheControlBlock>(
|
||||
blocks: T[],
|
||||
preserveIndex: number,
|
||||
excessCounter: { value: number },
|
||||
): void {
|
||||
for (let index = 0; index < blocks.length && excessCounter.value > 0; index++) {
|
||||
if (index === preserveIndex) continue;
|
||||
if (!blocks[index]?.cache_control) continue;
|
||||
delete blocks[index].cache_control;
|
||||
excessCounter.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function stripAllCacheControl<T extends CacheControlBlock>(blocks: T[], excessCounter: { value: number }): void {
|
||||
for (const block of blocks) {
|
||||
if (excessCounter.value <= 0) return;
|
||||
if (!block.cache_control) continue;
|
||||
delete block.cache_control;
|
||||
excessCounter.value--;
|
||||
}
|
||||
}
|
||||
|
||||
function stripMessageCacheControl(
|
||||
messages: MessageCreateParamsStreaming["messages"],
|
||||
excessCounter: { value: number },
|
||||
): void {
|
||||
for (const message of messages) {
|
||||
if (excessCounter.value <= 0) return;
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
for (const block of message.content as Array<ContentBlockParam & CacheControlBlock>) {
|
||||
if (excessCounter.value <= 0) return;
|
||||
if (!block.cache_control) continue;
|
||||
delete block.cache_control;
|
||||
excessCounter.value--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function countCacheControlBreakpoints(params: MessageCreateParamsStreaming): number {
|
||||
let total = 0;
|
||||
if (params.tools) {
|
||||
for (const tool of params.tools as Array<AnthropicWireTool & CacheControlBlock>) {
|
||||
if (tool.cache_control) total++;
|
||||
}
|
||||
}
|
||||
if (params.system && Array.isArray(params.system)) {
|
||||
for (const block of params.system as Array<AnthropicSystemBlock & CacheControlBlock>) {
|
||||
if (block.cache_control) total++;
|
||||
}
|
||||
}
|
||||
for (const message of params.messages) {
|
||||
if (!Array.isArray(message.content)) continue;
|
||||
for (const block of message.content as Array<ContentBlockParam & CacheControlBlock>) {
|
||||
if (block.cache_control) total++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function enforceCacheControlLimit(params: MessageCreateParamsStreaming, maxBreakpoints: number): void {
|
||||
const total = countCacheControlBreakpoints(params);
|
||||
if (total <= maxBreakpoints) return;
|
||||
const excessCounter = { value: total - maxBreakpoints };
|
||||
const systemBlocks =
|
||||
params.system && Array.isArray(params.system)
|
||||
? (params.system as Array<AnthropicSystemBlock & CacheControlBlock>)
|
||||
: [];
|
||||
const toolBlocks = (params.tools ?? []) as Array<AnthropicWireTool & CacheControlBlock>;
|
||||
const lastSystemIndex = findLastCacheControlIndex(systemBlocks);
|
||||
const lastToolIndex = findLastCacheControlIndex(toolBlocks);
|
||||
if (systemBlocks.length > 0) {
|
||||
stripCacheControlExceptIndex(systemBlocks, lastSystemIndex, excessCounter);
|
||||
}
|
||||
if (excessCounter.value <= 0) return;
|
||||
if (toolBlocks.length > 0) {
|
||||
stripCacheControlExceptIndex(toolBlocks, lastToolIndex, excessCounter);
|
||||
}
|
||||
if (excessCounter.value <= 0) return;
|
||||
stripMessageCacheControl(params.messages, excessCounter);
|
||||
if (excessCounter.value <= 0) return;
|
||||
if (systemBlocks.length > 0) {
|
||||
stripAllCacheControl(systemBlocks, excessCounter);
|
||||
}
|
||||
if (excessCounter.value <= 0) return;
|
||||
if (toolBlocks.length > 0) {
|
||||
stripAllCacheControl(toolBlocks, excessCounter);
|
||||
}
|
||||
}
|
||||
|
||||
function usesAdaptiveThinkingTagOnly(model: Model<"anthropic-messages">): boolean {
|
||||
const thinking = model.thinking;
|
||||
if (thinking?.mode !== "anthropic-adaptive") return false;
|
||||
@@ -3445,7 +3295,7 @@ function buildParams(
|
||||
forceDemoteUnsignedThinking && model.compat.replayUnsignedThinking
|
||||
? { ...model, compat: { ...model.compat, replayUnsignedThinking: false } }
|
||||
: model;
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention, isOAuthToken);
|
||||
const { cacheControl } = getCacheControl(model, options?.cacheRetention);
|
||||
|
||||
// Pre-compute system blocks so they occupy the right slot in the serialized body.
|
||||
const shouldInjectClaudeCodeInstruction = isOAuthToken && !model.id.startsWith("claude-3-5-haiku");
|
||||
@@ -3582,7 +3432,7 @@ function buildParams(
|
||||
...(systemBlocks && { system: systemBlocks }),
|
||||
...(tools !== undefined && { tools }),
|
||||
...(metadata && { metadata }),
|
||||
max_tokens: Math.min(maxOutputTokens, options?.maxTokens || modelMaxTokens),
|
||||
max_tokens: Math.min(maxOutputTokens, options?.maxTokens ?? modelMaxTokens),
|
||||
...(thinking && { thinking }),
|
||||
...(contextManagement && { context_management: contextManagement }),
|
||||
...(outputConfig && { output_config: outputConfig }),
|
||||
@@ -3647,8 +3497,6 @@ function buildParams(
|
||||
disableThinkingIfToolChoiceForced(params, model);
|
||||
ensureMaxTokensForThinking(params, maxOutputTokens);
|
||||
applyPromptCaching(params, cacheControl);
|
||||
enforceCacheControlLimit(params, 4);
|
||||
normalizeCacheControlTtlOrdering(params);
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* 1. Static credentials from the environment
|
||||
* (`AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` [+ `AWS_SESSION_TOKEN`]).
|
||||
* 2. Web identity (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`).
|
||||
* 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO):
|
||||
* - static keys, SSO, or `credential_process`.
|
||||
* 3. Profile in `~/.aws/credentials` (and `~/.aws/config` for SSO/roles):
|
||||
* - static keys, SSO, `credential_process`, or `role_arn` role chaining
|
||||
* (`source_profile` recursion, `web_identity_token_file`, `credential_source`).
|
||||
* 4. ECS/container credentials from `AWS_CONTAINER_CREDENTIALS_*`.
|
||||
* 5. EC2 IMDSv2 when metadata is enabled.
|
||||
*
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
shouldLoadAwsSharedConfig,
|
||||
} from "../utils/aws-profile";
|
||||
import { isLocalOrMetadataHost } from "../utils/proxy";
|
||||
import type { AwsCredentials } from "./aws-sigv4";
|
||||
import { type AwsCredentials, signRequest } from "./aws-sigv4";
|
||||
|
||||
export interface ResolvedCredentials extends AwsCredentials {
|
||||
/** Absolute expiration timestamp in ms. `undefined` for non-expiring static creds. */
|
||||
@@ -60,8 +61,8 @@ const SHARED_RESOLVE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function requireDynamicCredentialExpiration(
|
||||
value: string | undefined,
|
||||
source: "AWS web identity" | "AWS container credential",
|
||||
kind: "web-identity" | "container",
|
||||
source: string,
|
||||
kind: AIError.AwsCredentialsErrorKind,
|
||||
): number {
|
||||
const expiresAt = value ? Date.parse(value) : Number.NaN;
|
||||
if (Number.isFinite(expiresAt)) return expiresAt;
|
||||
@@ -179,7 +180,16 @@ async function readIniFile(p: string): Promise<AwsIniFile | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Profile / SSO ----------
|
||||
// ---------- Profile / SSO / role chaining ----------
|
||||
|
||||
/** Shared-config view and resolution context threaded through role-chain recursion. */
|
||||
interface ProfileResolveContext {
|
||||
credentialsIni: AwsIniFile | undefined;
|
||||
configIni: AwsIniFile | undefined;
|
||||
region: string;
|
||||
signal: AbortSignal | undefined;
|
||||
fetchImpl: FetchImpl;
|
||||
}
|
||||
|
||||
async function readProfileCredentials(
|
||||
profile: string,
|
||||
@@ -195,11 +205,36 @@ async function readProfileCredentials(
|
||||
const credentialsIni = await readIniFile(credentialsPath);
|
||||
const configIni = loadSharedConfig ? await readIniFile(configPath) : undefined;
|
||||
|
||||
// Static credentials live in ~/.aws/credentials; SSO config lives in
|
||||
return resolveProfileChain(profile, { credentialsIni, configIni, region, signal, fetchImpl }, new Set());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one profile, following `role_arn` chains. A `role_arn` profile derives
|
||||
* base credentials from `source_profile` (recursive), `web_identity_token_file`,
|
||||
* or `credential_source`, then exchanges them via STS. Non-role profiles resolve
|
||||
* directly from static keys, SSO, or `credential_process`. `seen` guards against
|
||||
* `source_profile` cycles.
|
||||
*/
|
||||
async function resolveProfileChain(
|
||||
profile: string,
|
||||
ctx: ProfileResolveContext,
|
||||
seen: Set<string>,
|
||||
): Promise<ResolvedCredentials | undefined> {
|
||||
if (seen.has(profile)) {
|
||||
throw new AIError.AwsCredentialsError(`AWS profile role chain contains a cycle at '${profile}'.`, "profile");
|
||||
}
|
||||
seen.add(profile);
|
||||
|
||||
// Static credentials live in ~/.aws/credentials; SSO/role config lives in
|
||||
// ~/.aws/config under `[profile foo]`. Merge into a single view.
|
||||
const merged: Record<string, string> = { ...(configIni?.[profile] ?? {}), ...(credentialsIni?.[profile] ?? {}) };
|
||||
const merged: Record<string, string> = {
|
||||
...(ctx.configIni?.[profile] ?? {}),
|
||||
...(ctx.credentialsIni?.[profile] ?? {}),
|
||||
};
|
||||
if (Object.keys(merged).length === 0) return undefined;
|
||||
|
||||
if (merged.role_arn) return assumeRoleFromProfile(profile, merged, ctx, seen);
|
||||
|
||||
if (merged.aws_access_key_id && merged.aws_secret_access_key) {
|
||||
const out: ResolvedCredentials = {
|
||||
accessKeyId: merged.aws_access_key_id,
|
||||
@@ -215,16 +250,158 @@ async function readProfileCredentials(
|
||||
}
|
||||
|
||||
if (merged.sso_account_id && merged.sso_role_name) {
|
||||
return readSsoCredentials(merged, configIni, region, signal, fetchImpl);
|
||||
return readSsoCredentials(merged, ctx.configIni, ctx.region, ctx.signal, ctx.fetchImpl);
|
||||
}
|
||||
|
||||
if (merged.credential_process) {
|
||||
return readCredentialProcess(profile, merged.credential_process, signal);
|
||||
return readCredentialProcess(profile, merged.credential_process, ctx.signal);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve base credentials for a `role_arn` profile and exchange them for the
|
||||
* target role. `web_identity_token_file` is a self-contained
|
||||
* AssumeRoleWithWebIdentity; otherwise the base comes from `source_profile`
|
||||
* (recursive) or `credential_source`, followed by an STS `AssumeRole`.
|
||||
*/
|
||||
async function assumeRoleFromProfile(
|
||||
profile: string,
|
||||
merged: Record<string, string>,
|
||||
ctx: ProfileResolveContext,
|
||||
seen: Set<string>,
|
||||
): Promise<ResolvedCredentials> {
|
||||
const roleArn = merged.role_arn;
|
||||
const region = ctx.region;
|
||||
|
||||
if (merged.web_identity_token_file) {
|
||||
return assumeRoleWithWebIdentity(
|
||||
{ roleArn, tokenFile: merged.web_identity_token_file, sessionName: merged.role_session_name },
|
||||
region,
|
||||
ctx.signal,
|
||||
ctx.fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
if (merged.mfa_serial) {
|
||||
// MFA-gated roles need an interactive token code, which a non-interactive
|
||||
// resolver cannot supply. Fail with a clear message instead of a confusing
|
||||
// STS AccessDenied.
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS profile '${profile}' requires MFA (mfa_serial), which is not supported for non-interactive credential resolution.`,
|
||||
"profile",
|
||||
);
|
||||
}
|
||||
|
||||
let base: ResolvedCredentials | undefined;
|
||||
if (merged.source_profile) {
|
||||
base = await resolveProfileChain(merged.source_profile, ctx, seen);
|
||||
if (!base) {
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS profile '${profile}' references source_profile '${merged.source_profile}', which has no usable credentials.`,
|
||||
"profile",
|
||||
);
|
||||
}
|
||||
} else if (merged.credential_source) {
|
||||
base = await resolveCredentialSource(merged.credential_source, region, ctx.signal, ctx.fetchImpl);
|
||||
if (!base) {
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS profile '${profile}' credential_source '${merged.credential_source}' produced no credentials.`,
|
||||
"profile",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS profile '${profile}' sets role_arn without source_profile, credential_source, or web_identity_token_file.`,
|
||||
"profile",
|
||||
);
|
||||
}
|
||||
|
||||
return stsAssumeRole(
|
||||
base,
|
||||
roleArn,
|
||||
region,
|
||||
{
|
||||
sessionName: merged.role_session_name,
|
||||
durationSeconds: merged.duration_seconds,
|
||||
externalId: merged.external_id,
|
||||
},
|
||||
ctx.signal,
|
||||
ctx.fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the base credentials named by a profile `credential_source` directive. */
|
||||
async function resolveCredentialSource(
|
||||
source: string,
|
||||
_region: string,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchImpl,
|
||||
): Promise<ResolvedCredentials | undefined> {
|
||||
switch (source) {
|
||||
case "Environment":
|
||||
return readEnvCredentials();
|
||||
case "Ec2InstanceMetadata":
|
||||
return $env.AWS_EC2_METADATA_DISABLED?.toLowerCase() === "true"
|
||||
? undefined
|
||||
: readImdsCredentials(signal, fetchImpl);
|
||||
case "EcsContainer":
|
||||
return readContainerCredentials(signal, fetchImpl);
|
||||
default:
|
||||
throw new AIError.AwsCredentialsError(`Unsupported AWS credential_source '${source}'.`, "profile");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange base credentials for a target role via STS `AssumeRole`. The request
|
||||
* is SigV4-signed with the base credentials.
|
||||
*/
|
||||
async function stsAssumeRole(
|
||||
base: ResolvedCredentials,
|
||||
roleArn: string,
|
||||
region: string,
|
||||
opts: { sessionName?: string; durationSeconds?: string; externalId?: string },
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchImpl,
|
||||
): Promise<ResolvedCredentials> {
|
||||
const body = new URLSearchParams({
|
||||
Action: "AssumeRole",
|
||||
Version: "2011-06-15",
|
||||
RoleArn: roleArn,
|
||||
RoleSessionName: opts.sessionName || `omp-${process.pid}`,
|
||||
});
|
||||
if (opts.durationSeconds) body.set("DurationSeconds", opts.durationSeconds);
|
||||
if (opts.externalId) body.set("ExternalId", opts.externalId);
|
||||
const payload = new TextEncoder().encode(body.toString());
|
||||
const endpoint = new URL(stsEndpoint(region));
|
||||
const contentType = "application/x-www-form-urlencoded";
|
||||
const signed = await signRequest({
|
||||
method: "POST",
|
||||
host: endpoint.host,
|
||||
path: endpoint.pathname,
|
||||
body: payload,
|
||||
region,
|
||||
service: "sts",
|
||||
credentials: base,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers: { ...signed, "content-type": contentType },
|
||||
body: payload,
|
||||
signal,
|
||||
});
|
||||
const xml = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`AWS AssumeRole failed: ${response.status} ${xmlTag(xml, "Message") ?? xml.slice(0, 200)}`,
|
||||
"assume-role",
|
||||
);
|
||||
}
|
||||
return parseStsCredentials(xml, "AWS AssumeRole", "assume-role");
|
||||
}
|
||||
|
||||
interface SsoCachedToken {
|
||||
accessToken?: string;
|
||||
expiresAt?: string;
|
||||
@@ -543,6 +720,18 @@ function stsEndpoint(region: string): string {
|
||||
return `https://sts.${region}.${dnsSuffix}/`;
|
||||
}
|
||||
|
||||
/** Parse `<Credentials>` from an STS AssumeRole/WithWebIdentity XML response. */
|
||||
function parseStsCredentials(xml: string, source: string, kind: AIError.AwsCredentialsErrorKind): ResolvedCredentials {
|
||||
const accessKeyId = xmlTag(xml, "AccessKeyId");
|
||||
const secretAccessKey = xmlTag(xml, "SecretAccessKey");
|
||||
const sessionToken = xmlTag(xml, "SessionToken");
|
||||
if (!accessKeyId || !secretAccessKey || !sessionToken) {
|
||||
throw new AIError.AwsCredentialsError(`${source} response is missing credentials.`, kind);
|
||||
}
|
||||
const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), source, kind);
|
||||
return { accessKeyId, secretAccessKey, sessionToken, expiresAt };
|
||||
}
|
||||
|
||||
async function readWebIdentityCredentials(
|
||||
region: string,
|
||||
signal: AbortSignal | undefined,
|
||||
@@ -551,9 +740,28 @@ async function readWebIdentityCredentials(
|
||||
const tokenFile = $env.AWS_WEB_IDENTITY_TOKEN_FILE;
|
||||
const roleArn = $env.AWS_ROLE_ARN;
|
||||
if (!tokenFile || !roleArn) return undefined;
|
||||
return assumeRoleWithWebIdentity(
|
||||
{ roleArn, tokenFile, sessionName: $env.AWS_ROLE_SESSION_NAME },
|
||||
region,
|
||||
signal,
|
||||
fetchImpl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a web-identity token file for role credentials via STS
|
||||
* `AssumeRoleWithWebIdentity`. Used by the env chain (`AWS_WEB_IDENTITY_TOKEN_FILE`)
|
||||
* and by `role_arn` + `web_identity_token_file` profiles.
|
||||
*/
|
||||
async function assumeRoleWithWebIdentity(
|
||||
params: { roleArn: string; tokenFile: string; sessionName?: string },
|
||||
region: string,
|
||||
signal: AbortSignal | undefined,
|
||||
fetchImpl: FetchImpl,
|
||||
): Promise<ResolvedCredentials> {
|
||||
let token: string;
|
||||
try {
|
||||
token = (await Bun.file(tokenFile).text()).trim();
|
||||
token = (await Bun.file(params.tokenFile).text()).trim();
|
||||
} catch (err) {
|
||||
throw new AIError.AwsCredentialsError(
|
||||
`Unable to read AWS web identity token file: ${String(err)}`,
|
||||
@@ -569,8 +777,8 @@ async function readWebIdentityCredentials(
|
||||
const body = new URLSearchParams({
|
||||
Action: "AssumeRoleWithWebIdentity",
|
||||
Version: "2011-06-15",
|
||||
RoleArn: roleArn,
|
||||
RoleSessionName: $env.AWS_ROLE_SESSION_NAME || `omp-${process.pid}`,
|
||||
RoleArn: params.roleArn,
|
||||
RoleSessionName: params.sessionName || `omp-${process.pid}`,
|
||||
WebIdentityToken: token,
|
||||
});
|
||||
const response = await fetchImpl(stsEndpoint(region), {
|
||||
@@ -586,22 +794,7 @@ async function readWebIdentityCredentials(
|
||||
"web-identity",
|
||||
);
|
||||
}
|
||||
const accessKeyId = xmlTag(xml, "AccessKeyId");
|
||||
const secretAccessKey = xmlTag(xml, "SecretAccessKey");
|
||||
const sessionToken = xmlTag(xml, "SessionToken");
|
||||
if (!accessKeyId || !secretAccessKey || !sessionToken) {
|
||||
throw new AIError.AwsCredentialsError(
|
||||
"AWS AssumeRoleWithWebIdentity response is missing credentials.",
|
||||
"web-identity",
|
||||
);
|
||||
}
|
||||
const expiresAt = requireDynamicCredentialExpiration(xmlTag(xml, "Expiration"), "AWS web identity", "web-identity");
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
expiresAt,
|
||||
};
|
||||
return parseStsCredentials(xml, "AWS web identity", "web-identity");
|
||||
}
|
||||
|
||||
// ---------- ECS/container credentials ----------
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface AzureOpenAIResponsesOptions extends StreamOptions {
|
||||
azureDeploymentName?: string;
|
||||
toolChoice?: ToolChoice;
|
||||
serviceTier?: ServiceTier;
|
||||
disableReasoning?: boolean;
|
||||
}
|
||||
|
||||
type AzureOpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
|
||||
|
||||
@@ -163,3 +163,25 @@ export function piLimit(limit: number | undefined): number | undefined {
|
||||
export function piTimeout(timeout: number | undefined): number | undefined {
|
||||
return timeout !== undefined && timeout >= 0 ? timeout : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop keys whose value is `undefined` so optional local-tool kwargs stay
|
||||
* absent rather than present-as-undefined.
|
||||
*
|
||||
* The Cursor exec bridge historically wrote forms like
|
||||
* `cwd: workingDirectory || undefined` and
|
||||
* `case: caseInsensitive === true ? false : undefined`. ArkType rejects a
|
||||
* present `undefined` on an optional field (`was undefined`) even though
|
||||
* omitting the key is valid — which flooded Cursor sessions with bash/grep
|
||||
* validation errors for otherwise fine frames.
|
||||
*/
|
||||
export function omitUndefinedArgs<T extends Record<string, unknown>>(
|
||||
args: T,
|
||||
): { [K in keyof T]?: Exclude<T[K], undefined> } {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(args)) {
|
||||
const value = args[key];
|
||||
if (value !== undefined) out[key] = value;
|
||||
}
|
||||
return out as { [K in keyof T]?: Exclude<T[K], undefined> };
|
||||
}
|
||||
|
||||
@@ -210,6 +210,7 @@ import {
|
||||
buildPiWriteError,
|
||||
buildPiWriteRejected,
|
||||
buildPiWriteResult,
|
||||
omitUndefinedArgs,
|
||||
piEscapeRegexLiteral,
|
||||
piGrepSkip,
|
||||
piJoinPath,
|
||||
@@ -223,6 +224,67 @@ import {
|
||||
export const CURSOR_API_URL = "https://api2.cursor.sh";
|
||||
export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b";
|
||||
|
||||
/**
|
||||
* HTTP/1 connection-specific headers that HTTP/2 forbids. Node's `http2.request()`
|
||||
* throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on these rather than dropping
|
||||
* them, so a caller sending one would kill the request outright.
|
||||
*/
|
||||
const HTTP2_FORBIDDEN_HEADERS = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"http2-settings",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Header names the Cursor request sets for itself. A caller copy in ANY casing
|
||||
* has to go: the spread below adds the fixed lower-case name regardless, and two
|
||||
* spellings of one field are a duplicate rather than an override.
|
||||
*/
|
||||
const CURSOR_RESERVED_HEADERS = new Set([
|
||||
"content-type",
|
||||
"connect-protocol-version",
|
||||
"te",
|
||||
"authorization",
|
||||
"x-ghost-mode",
|
||||
"x-cursor-client-version",
|
||||
"x-cursor-client-type",
|
||||
"x-request-id",
|
||||
// Transport-owned even though this request never sets it: node's http2 client
|
||||
// suppresses the `:authority` it derives from the URL when a plain `host`
|
||||
// header is present, so a caller value here silently retargets the request at
|
||||
// a different virtual host.
|
||||
"host",
|
||||
// The Connect body is streamed after the headers (initial frame, heartbeats,
|
||||
// tool responses), so no caller-supplied length can describe it and an HTTP/2
|
||||
// peer resets the stream once the body diverges.
|
||||
"content-length",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Reduce caller-supplied headers to what this HTTP/2 request can legally carry.
|
||||
*
|
||||
* Everything is lower-cased, because HTTP/2 field names are lower-case and node
|
||||
* compares them that way. A caller `Authorization` next to the fixed
|
||||
* `authorization` does not lose to it, it DUPLICATES it, and node throws
|
||||
* `ERR_HTTP2_HEADER_SINGLE_VALUE` before the request goes out. Same for a `TE`
|
||||
* that is not `trailers`. Node throws on all three classes here rather than
|
||||
* ignoring them, so a miss turns a harmless header into a dead request.
|
||||
*/
|
||||
function sanitizeCursorCallerHeaders(headers: Record<string, string> | undefined): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(headers ?? {})) {
|
||||
const field = name.toLowerCase();
|
||||
if (field.startsWith(":")) continue;
|
||||
if (HTTP2_FORBIDDEN_HEADERS.has(field)) continue;
|
||||
if (CURSOR_RESERVED_HEADERS.has(field)) continue;
|
||||
sanitized[field] = value;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
@@ -545,7 +607,22 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
||||
|
||||
const baseUrl = model.baseUrl || CURSOR_API_URL;
|
||||
const requestPath = "/agent.v1.AgentService/Run";
|
||||
// Caller headers are additive, and are spread FIRST so the protocol
|
||||
// framing, auth, and request id below always win. Cursor built this map
|
||||
// from scratch and never read `options.headers`, so tracing/attribution
|
||||
// headers set by a caller (or a `before_provider_headers` extension) were
|
||||
// silently dropped here while working on other providers.
|
||||
//
|
||||
// Two classes are stripped because node's http2 client THROWS on them
|
||||
// rather than ignoring them, which would turn a harmless header into a
|
||||
// dead request: pseudo-headers, which belong to the transport, and the
|
||||
// HTTP/1 connection-specific headers HTTP/2 forbids outright
|
||||
// (ERR_HTTP2_INVALID_CONNECTION_HEADERS). `te` needs no filtering here —
|
||||
// HTTP/2 allows it only as `trailers`, which is exactly what the fixed
|
||||
// set below re-applies over anything a caller sent.
|
||||
const callerHeaders = sanitizeCursorCallerHeaders(options?.headers);
|
||||
const requestHeaders = {
|
||||
...callerHeaders,
|
||||
":method": "POST",
|
||||
":path": requestPath,
|
||||
"content-type": "application/connect+proto",
|
||||
@@ -3592,11 +3669,14 @@ export function synthesizeCursorExecToolCall(
|
||||
): void {
|
||||
endCurrentTextBlock(output, stream, state);
|
||||
endCurrentThinkingBlock(output, stream, state);
|
||||
// Exec-frame translators often write `optional: value || undefined`. A
|
||||
// present `undefined` fails ArkType optional-field validation; drop those
|
||||
// keys so the transcript block matches what a model-native call would omit.
|
||||
const block: ToolCallState = {
|
||||
type: "toolCall",
|
||||
id: toolCallId,
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
arguments: omitUndefinedArgs(args),
|
||||
[kStreamingBlockIndex]: output.content.length,
|
||||
[kStreamingBlockKind]: "cursor-exec",
|
||||
[kCursorExecResolved]: true,
|
||||
|
||||
@@ -74,6 +74,7 @@ import type { ToolResultMessage } from "../../types";
|
||||
* and their translation are consumed together.
|
||||
*/
|
||||
export {
|
||||
omitUndefinedArgs,
|
||||
piEscapeRegexLiteral,
|
||||
piGrepSkip,
|
||||
piJoinPath,
|
||||
|
||||
@@ -125,9 +125,9 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
||||
const toolBlocks = new Map<string, ToolCall>();
|
||||
const toolPartialJson = new Map<string, string>();
|
||||
// Last-parsed argument-buffer length per tool-call id — bounds the
|
||||
// mid-stream parse work to O(N) via `parseStreamingJsonThrottled`; the
|
||||
// authoritative final parse still runs unconditionally in the toolcall_end
|
||||
// loop below.
|
||||
// mid-stream parse work to O(N log N) via `parseStreamingJsonThrottled`;
|
||||
// the authoritative final parse still runs unconditionally in the
|
||||
// toolcall_end loop below.
|
||||
const toolLastParseLen = new Map<string, number>();
|
||||
let activeToolCallId: string | undefined;
|
||||
let latestStopReason = StopReason.UNSPECIFIED;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
TOOL-ONLY TURN. This turn accepts a tool call and nothing else; a text reply here is discarded unread and you will be re-prompted. Emit the tool call now.
|
||||
@@ -31,11 +31,12 @@ import { normalizeSystemPrompts } from "../utils";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream";
|
||||
import { extractGoogleValidationUrl, formatGoogleValidationRequiredMessage } from "../utils/google-validation";
|
||||
import type { RawHttpRequestDump } from "../utils/http-inspector";
|
||||
import { armPreResponseTimeout, getStreamFirstEventTimeoutMs } from "../utils/idle-iterator";
|
||||
import { armPreResponseTimeout, getStreamFirstEventTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
|
||||
// Refresh is the sole responsibility of AuthStorage (broker-aware, single-flighted);
|
||||
// the stream provider trusts the access token threaded through `options.apiKey`.
|
||||
import { normalizeSchemaForCCA } from "../utils/schema";
|
||||
import { StreamMarkupHealing, type StreamMarkupHealingEvent } from "../utils/stream-markup-healing";
|
||||
import forcedToolDirective from "./google-antigravity-forced-tool.md" with { type: "text" };
|
||||
import type { Content, FunctionCallingConfigMode, ThinkingConfig } from "./google-shared";
|
||||
import {
|
||||
convertMessages,
|
||||
@@ -325,6 +326,9 @@ export {
|
||||
// Retry configuration
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1000;
|
||||
const FLASH_FIRST_EVENT_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 300_000;
|
||||
const FIRST_EVENT_TIMEOUT_ERROR = "Cloud Code Assist stream timed out while waiting for the first event";
|
||||
const RATE_LIMIT_BUDGET_MS = 5 * 60 * 1000;
|
||||
const CLAUDE_THINKING_BETA_HEADER = "interleaved-thinking-2025-05-14";
|
||||
const GOOGLE_GEMINI_REFRESH_SKEW_MS = 60_000;
|
||||
@@ -616,12 +620,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
headers: requestHeaders,
|
||||
};
|
||||
|
||||
// Direct callers that skip `register-builtins` (which installs the
|
||||
// iterator-level watchdog) need a pre-response timer alongside
|
||||
// `timeout: false`; otherwise a stalled Cloud Code Assist proxy
|
||||
// would hang forever. Floor matches the lazy wrapper's 5min default.
|
||||
// The provider owns the first-event watchdog so a silent successful
|
||||
// response can fail over to the alternate Antigravity endpoint before
|
||||
// anything user-visible has streamed. Flash should not inherit the
|
||||
// five-minute allowance reserved for cold Pro reasoning starts.
|
||||
const firstEventTimeoutMs =
|
||||
options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(undefined, 300_000);
|
||||
options?.streamFirstEventTimeoutMs ??
|
||||
getStreamFirstEventTimeoutMs(
|
||||
undefined,
|
||||
model.id.includes("flash") ? FLASH_FIRST_EVENT_TIMEOUT_MS : DEFAULT_FIRST_EVENT_TIMEOUT_MS,
|
||||
);
|
||||
const callerSignal = options?.signal;
|
||||
const toolNames = new Set(context.tools?.map(t => t.name) ?? []);
|
||||
const isFlashLeakModel = model.id.includes("flash");
|
||||
@@ -653,7 +661,9 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
sawFinishReason = false;
|
||||
};
|
||||
|
||||
const streamResponse = async (activeResponse: Response): Promise<boolean> => {
|
||||
const streamResponse = async (
|
||||
activeResponse: Response,
|
||||
): Promise<{ meaningful: boolean; strippedPlanningLeak: boolean }> => {
|
||||
if (!activeResponse.body) {
|
||||
throw new AIError.ProviderResponseError("No response body", {
|
||||
provider: model.provider,
|
||||
@@ -673,6 +683,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
let isBuffering = false;
|
||||
let textBuffer = "";
|
||||
let bufferedTextSignature: string | undefined;
|
||||
let strippedPlanningLeak = false;
|
||||
|
||||
const endCurrentBlock = (): void => {
|
||||
if (!currentBlock) return;
|
||||
@@ -755,11 +766,24 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
}
|
||||
};
|
||||
|
||||
for await (const chunk of readSseJson<CloudCodeAssistResponseChunk>(
|
||||
activeResponse.body!,
|
||||
options?.signal,
|
||||
event => options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
|
||||
)) {
|
||||
const responseAbortController = new AbortController();
|
||||
const responseSignal = options?.signal
|
||||
? AbortSignal.any([options.signal, responseAbortController.signal])
|
||||
: responseAbortController.signal;
|
||||
const chunks = iterateWithIdleTimeout(
|
||||
readSseJson<CloudCodeAssistResponseChunk>(activeResponse.body, responseSignal, event =>
|
||||
options?.onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, model),
|
||||
),
|
||||
{
|
||||
firstItemTimeoutMs: firstEventTimeoutMs,
|
||||
errorMessage: FIRST_EVENT_TIMEOUT_ERROR,
|
||||
firstItemErrorMessage: FIRST_EVENT_TIMEOUT_ERROR,
|
||||
onFirstItemTimeout: () =>
|
||||
responseAbortController.abort(new AIError.StreamTimeoutError(FIRST_EVENT_TIMEOUT_ERROR)),
|
||||
abortSignal: options?.signal,
|
||||
},
|
||||
);
|
||||
for await (const chunk of chunks) {
|
||||
if (chunk.error) {
|
||||
const detail = chunk.error.message || chunk.error.status || "unknown error";
|
||||
const message = `Cloud Code Assist stream error: ${detail}`;
|
||||
@@ -815,6 +839,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
if (isBuffering) {
|
||||
const buffered = consumePlanningBuffer(textBuffer, toolNames);
|
||||
if (buffered.kind !== "incomplete") {
|
||||
if (buffered.kind === "leak") strippedPlanningLeak = true;
|
||||
const visibleSignature = bufferedTextSignature;
|
||||
isBuffering = false;
|
||||
textBuffer = "";
|
||||
@@ -895,6 +920,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
|
||||
|
||||
if (buffered.kind !== "incomplete") {
|
||||
if (buffered.kind === "leak") strippedPlanningLeak = true;
|
||||
feedVisibleText(buffered.visibleText, bufferedTextSignature);
|
||||
}
|
||||
bufferedTextSignature = undefined;
|
||||
@@ -905,7 +931,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
flushVisibleText(bufferedTextSignature);
|
||||
endCurrentBlock();
|
||||
|
||||
return hasMeaningfulGoogleContent(output);
|
||||
return {
|
||||
meaningful: hasMeaningfulGoogleContent(output),
|
||||
strippedPlanningLeak,
|
||||
};
|
||||
};
|
||||
|
||||
let receivedContent = false;
|
||||
@@ -998,8 +1027,14 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
||||
}
|
||||
|
||||
const streamed = await streamResponse(currentResponse);
|
||||
if (output.stopReason !== "stop" || streamed) {
|
||||
receivedContent = streamed;
|
||||
// Only accept an empty STOP as valid silence once every fallback
|
||||
// endpoint is exhausted: an earlier endpoint returning empty
|
||||
// successful streams must still fail over (Antigravity auto mode)
|
||||
// rather than be recorded as a real silent review.
|
||||
const acceptedSilence =
|
||||
options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint;
|
||||
if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) {
|
||||
receivedContent = streamed.meaningful || acceptedSilence;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1313,6 +1348,13 @@ export function buildRequest(
|
||||
},
|
||||
};
|
||||
}
|
||||
// Cloud Code Assist drops `toolConfig` on Antigravity's Gemini routes:
|
||||
// the backend answers in text under `mode: "ANY"` and still emits calls
|
||||
// under `"NONE"`. Claude routes implement it, so only Gemini needs the
|
||||
// forced choice restated in the transcript.
|
||||
if (isAntigravity && !isClaudeModel(model.id) && request.toolConfig?.functionCallingConfig.mode === "ANY") {
|
||||
contents.push({ role: "user", parts: [{ text: forcedToolDirective }] });
|
||||
}
|
||||
}
|
||||
// Antigravity's default tool mode is VALIDATED (verified for Gemini and
|
||||
// Claude); an explicit non-auto tool choice above wins.
|
||||
|
||||
@@ -858,13 +858,18 @@ export function buildGoogleGenerateContentParams<T extends "google-generative-ai
|
||||
config.toolConfig = undefined;
|
||||
}
|
||||
|
||||
if (options.thinking?.enabled && model.reasoning) {
|
||||
const cfg: ThinkingConfig = { includeThoughts: !options.hideThinkingSummary };
|
||||
if (options.thinking.level !== undefined) {
|
||||
// GoogleThinkingLevel mirrors the SDK's `ThinkingLevel` string enum values 1:1.
|
||||
cfg.thinkingLevel = options.thinking.level as ThinkingLevel;
|
||||
} else if (options.thinking.budgetTokens !== undefined) {
|
||||
cfg.thinkingBudget = options.thinking.budgetTokens;
|
||||
const thinking = options.thinking;
|
||||
if (
|
||||
thinking &&
|
||||
model.reasoning &&
|
||||
(thinking.enabled || thinking.level !== undefined || thinking.budgetTokens !== undefined)
|
||||
) {
|
||||
const cfg: ThinkingConfig = { includeThoughts: thinking.enabled && !options.hideThinkingSummary };
|
||||
if (thinking.level !== undefined) {
|
||||
// GoogleThinkingLevel mirrors the SDK's ThinkingLevel string enum values 1:1.
|
||||
cfg.thinkingLevel = thinking.level as ThinkingLevel;
|
||||
} else if (thinking.budgetTokens !== undefined) {
|
||||
cfg.thinkingBudget = thinking.budgetTokens;
|
||||
}
|
||||
config.thinkingConfig = cfg;
|
||||
}
|
||||
@@ -1031,7 +1036,13 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
|
||||
},
|
||||
});
|
||||
|
||||
if (output.stopReason !== "stop" || hasMeaningfulGoogleContent(output)) break;
|
||||
if (
|
||||
output.stopReason !== "stop" ||
|
||||
hasMeaningfulGoogleContent(output) ||
|
||||
options?.acceptEmptyResponse === true
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (emptyAttempt >= MAX_EMPTY_STREAM_RETRIES) {
|
||||
throw new AIError.ProviderResponseError(
|
||||
`Google API returned an empty response (finishReason STOP with no content) after ${MAX_EMPTY_STREAM_RETRIES + 1} attempts`,
|
||||
|
||||
@@ -328,15 +328,27 @@ function createChatBody(model: Model<"ollama-chat">, context: Context, options:
|
||||
const toolChoice = mapToolChoice(options?.toolChoice);
|
||||
const selectedTools = selectToolsForToolChoice(context.tools, options?.toolChoice);
|
||||
const tools = convertTools(selectedTools);
|
||||
const runtimeOptions: { num_predict?: number; temperature?: number; top_p?: number } = {};
|
||||
let hasRuntimeOptions = false;
|
||||
if (options?.maxTokens !== undefined && !model.omitMaxOutputTokens) {
|
||||
runtimeOptions.num_predict = resolveNumPredict(model, options.maxTokens);
|
||||
hasRuntimeOptions = true;
|
||||
}
|
||||
if (options?.temperature !== undefined) {
|
||||
runtimeOptions.temperature = options.temperature;
|
||||
hasRuntimeOptions = true;
|
||||
}
|
||||
if (options?.topP !== undefined) {
|
||||
runtimeOptions.top_p = options.topP;
|
||||
hasRuntimeOptions = true;
|
||||
}
|
||||
return {
|
||||
model: model.id,
|
||||
messages: convertMessages(model, context),
|
||||
...(tools ? { tools } : {}),
|
||||
...(think !== undefined ? { think } : {}),
|
||||
...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}),
|
||||
...(options?.maxTokens !== undefined && !model.omitMaxOutputTokens
|
||||
? { options: { num_predict: resolveNumPredict(model, options.maxTokens) } }
|
||||
: {}),
|
||||
...(hasRuntimeOptions ? { options: runtimeOptions } : {}),
|
||||
stream: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as os from "node:os";
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { type } from "@oh-my-pi/omptype";
|
||||
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
||||
@@ -19,8 +18,8 @@ import {
|
||||
parseStreamingJson,
|
||||
readSseJson,
|
||||
structuredCloneJSON,
|
||||
USER_AGENT,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import * as AIError from "../error";
|
||||
import { getEnvApiKey, isOfficialCodexApiUrl } from "../stream";
|
||||
import type {
|
||||
@@ -1530,6 +1529,7 @@ export async function buildTransformedCodexRequestBody(
|
||||
}
|
||||
const codexOptions: CodexRequestOptions = {
|
||||
reasoningEffort: options?.reasoning,
|
||||
reasoningOff: options?.forceReasoningOff,
|
||||
reasoningSummary: options?.reasoningSummary,
|
||||
reasoningContext: options?.reasoningContext,
|
||||
textVerbosity: options?.textVerbosity,
|
||||
@@ -4250,7 +4250,7 @@ function createCodexHeaders(
|
||||
headers.set(OPENAI_HEADERS.BETA, betaHeader);
|
||||
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
|
||||
headers.set(OPENAI_HEADERS.VERSION, codexClientVersion);
|
||||
headers.set("User-Agent", `pi/${packageJson.version} (${os.platform()} ${os.release()}; ${os.arch()})`);
|
||||
headers.set("User-Agent", USER_AGENT);
|
||||
if (sessionId) {
|
||||
headers.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
|
||||
headers.set(OPENAI_HEADERS.SESSION_ID, sessionId);
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ReasoningConfig {
|
||||
export interface CodexRequestOptions {
|
||||
/** User-facing effort; maps 1:1 onto the wire tier of the same name. */
|
||||
reasoningEffort?: CodexCallerEffort | "none";
|
||||
/** Suppress native reasoning by sending `reasoning.effort: "none"`. */
|
||||
reasoningOff?: boolean;
|
||||
reasoningSummary?: ReasoningConfig["summary"] | null;
|
||||
/** Explicit `reasoning.context` override. Omitted by default; Responses Lite forces `all_turns` as required by that transport. */
|
||||
reasoningContext?: CodexReasoningContext;
|
||||
@@ -109,6 +111,22 @@ export function resolveCodexResponsesLite(
|
||||
return model.useResponsesLite === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to request `stream_options.reasoning_summary_delivery =
|
||||
* "sequential_cutoff"` (codex-rs `concurrent_reasoning_summaries`), enabled by
|
||||
* `PI_CODEX_CONCURRENT_SUMMARIES=1`.
|
||||
*
|
||||
* Off by default because the mode cancels summary sections still in flight when
|
||||
* the reasoning item closes: measured over 12 interleaved turns it halved
|
||||
* visible thinking (0.83 vs 1.67 summary parts, 37 vs 69 chars per turn) and
|
||||
* produced no summary at all on 3 of 12 turns. codex-rs ships it disabled too
|
||||
* (`Stage::UnderDevelopment`, `default_enabled: false`).
|
||||
*/
|
||||
function concurrentSummariesEnabled(): boolean {
|
||||
const env = $env.PI_CODEX_CONCURRENT_SUMMARIES?.trim().toLowerCase();
|
||||
return env === "1" || env === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a user-facing effort to the model's ladder, then remap to the wire
|
||||
* tier. User efforts map 1:1 onto wire tiers; the effort map only covers
|
||||
@@ -145,12 +163,14 @@ function getReasoningConfig(
|
||||
const config: ReasoningConfig = {
|
||||
effort: effort === "none" ? "none" : mapCodexWireEffort(model, effort),
|
||||
};
|
||||
if (
|
||||
options.reasoningSummary !== undefined &&
|
||||
options.reasoningSummary !== null &&
|
||||
supportsCodexReasoningSummary(model.id)
|
||||
) {
|
||||
config.summary = options.reasoningSummary;
|
||||
// The backend only emits reasoning summaries when `reasoning.summary` is
|
||||
// present: omitting it yields zero `response.reasoning_summary_text.*`
|
||||
// events (measured against gpt-5.5, gpt-5.6-sol and gpt-5.6-terra). So
|
||||
// `undefined` means "default on" — matching `applyResponsesCompatPolicy`
|
||||
// on the plain Responses path — and only an explicit `null` (the caller
|
||||
// hiding thinking) opts out.
|
||||
if (options.reasoningSummary !== null && supportsCodexReasoningSummary(model.id)) {
|
||||
config.summary = options.reasoningSummary ?? "auto";
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -436,20 +456,25 @@ export async function transformRequestBody(
|
||||
applyCodexResponsesLiteShape(body);
|
||||
}
|
||||
|
||||
if (options.reasoningEffort !== undefined || responsesLite) {
|
||||
const reasoningConfig =
|
||||
options.reasoningEffort !== undefined ? getReasoningConfig(model, options.reasoningEffort, options) : {};
|
||||
if (options.reasoningOff || options.reasoningEffort !== undefined || responsesLite) {
|
||||
const reasoningConfig: Partial<ReasoningConfig> = options.reasoningOff
|
||||
? { effort: "none" }
|
||||
: options.reasoningEffort !== undefined
|
||||
? getReasoningConfig(model, options.reasoningEffort, options)
|
||||
: {};
|
||||
body.reasoning = {
|
||||
...body.reasoning,
|
||||
...reasoningConfig,
|
||||
};
|
||||
// Responses Lite requires `all_turns`; the full transport leaves context to the server unless explicitly set.
|
||||
const context = responsesLite ? "all_turns" : options.reasoningContext;
|
||||
if (context !== undefined) {
|
||||
if (context === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
|
||||
// Lite requires `all_turns` even for opaque/codenamed model ids. Only explicit
|
||||
// full-transport overrides are gated by the known model wire generation.
|
||||
if (responsesLite) {
|
||||
body.reasoning.context = "all_turns";
|
||||
} else if (options.reasoningContext !== undefined) {
|
||||
if (options.reasoningContext === "all_turns" && !supportsAllTurnsReasoningContext(model.id)) {
|
||||
delete body.reasoning.context;
|
||||
} else {
|
||||
body.reasoning.context = context;
|
||||
body.reasoning.context = options.reasoningContext;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -458,16 +483,17 @@ export async function transformRequestBody(
|
||||
// Catalog pro aliases (`gpt-5.6-*-pro`): applied after the effort branch so
|
||||
// the mode is sent even when no effort is set (the branch above deletes
|
||||
// `body.reasoning` in that case) — mode and effort are independent fields.
|
||||
if (model.reasoningMode) {
|
||||
if (model.reasoningMode && !options.reasoningOff) {
|
||||
body.reasoning = { ...body.reasoning, mode: model.reasoningMode };
|
||||
}
|
||||
|
||||
// Concurrent reasoning summaries (codex-rs `concurrent_reasoning_summaries`
|
||||
// feature): `sequential_cutoff` lets the server stream output without
|
||||
// blocking on summary generation. Only meaningful when a summary is
|
||||
// requested; codex-rs additionally gates on its OpenAI provider check,
|
||||
// which is inherent here.
|
||||
if (body.reasoning?.summary !== undefined) {
|
||||
// Concurrent reasoning summaries (codex-rs `concurrent_reasoning_summaries`):
|
||||
// `sequential_cutoff` lets the server stream output without blocking on
|
||||
// summary generation, delivering each completed section as an atomic
|
||||
// `response.reasoning_summary_text.done`. Opt-in only — see
|
||||
// {@link concurrentSummariesEnabled} for why. Requires a requested summary;
|
||||
// codex-rs additionally gates on its OpenAI provider check, inherent here.
|
||||
if (body.reasoning?.summary !== undefined && concurrentSummariesEnabled()) {
|
||||
body.stream_options = { reasoning_summary_delivery: "sequential_cutoff" };
|
||||
} else {
|
||||
delete body.stream_options;
|
||||
|
||||
@@ -132,7 +132,13 @@ function collectMessageParts(error: unknown, captured: CapturedHttpErrorResponse
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value/i;
|
||||
/**
|
||||
* Text that identifies a 400 as being about the reasoning-effort field.
|
||||
* OpenAI-compatible gateways (cliproxy, …) never name the field — they reject
|
||||
* the value alone with `level "none" not supported, valid levels: low, …` — so
|
||||
* the allowed-level phrasing counts as a mention too.
|
||||
*/
|
||||
const REASONING_EFFORT_FIELD_PATTERN = /reasoning[_. ]effort|reasoning value|(?:valid|supported|allowed) levels?/i;
|
||||
|
||||
function mentionsReasoningEffort(error: unknown, captured: CapturedHttpErrorResponse | undefined): boolean {
|
||||
const param = capturedStringField(captured, "param");
|
||||
@@ -168,10 +174,13 @@ function isInvalidReasoningEffortError(
|
||||
if (/(?:unsupported|not supported)[^\n]*(?:reasoning[_. ]effort|reasoning value)/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
return new RegExp(
|
||||
`(?:invalid|unsupported|not supported)[^\\n]*["'\`]${escapeRegExp(currentEffort)}["'\`]`,
|
||||
"i",
|
||||
).test(message);
|
||||
// Gateways put the rejected value first (`level "none" not supported`), the
|
||||
// official API puts the verdict first (`Unsupported value: 'none'`).
|
||||
const quoted = `["'\`]${escapeRegExp(currentEffort)}["'\`]`;
|
||||
return (
|
||||
new RegExp(`(?:invalid|unsupported|not supported)[^\\n]*${quoted}`, "i").test(message) ||
|
||||
new RegExp(`${quoted}[^\\n]*(?:invalid|unsupported|not supported)`, "i").test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
@@ -186,9 +195,12 @@ function parseKnownReasoningValues(text: string): Set<string> {
|
||||
values.add(quotedMatch[1]!.toLowerCase());
|
||||
quotedMatch = quotedPattern.exec(text);
|
||||
}
|
||||
const allowedMatch = /(?:must be|one of|allowed values?|supported values?(?: are)?|expected)([^.\n]+)/i.exec(text);
|
||||
const allowedMatch =
|
||||
/(?:must be|one of|allowed values?|supported values?(?: are)?|expected|(?:valid|supported|allowed) levels?(?: are)?)[^.\n]+/i.exec(
|
||||
text,
|
||||
);
|
||||
if (allowedMatch) {
|
||||
const allowedText = allowedMatch[1]!;
|
||||
const allowedText = allowedMatch[0]!;
|
||||
const barePattern = /\b(none|minimal|low|medium|high|xhigh|max)\b/gi;
|
||||
let bareMatch = barePattern.exec(allowedText);
|
||||
while (bareMatch !== null) {
|
||||
@@ -201,7 +213,8 @@ function parseKnownReasoningValues(text: string): Set<string> {
|
||||
|
||||
function parseAllowedReasoningValues(message: string, currentEffort: string): Set<string> | undefined {
|
||||
const values = parseKnownReasoningValues(message);
|
||||
const hasAllowedCue = /must be|one of|allowed values?|supported values?|expected/i.test(message);
|
||||
const hasAllowedCue =
|
||||
/must be|one of|allowed values?|supported values?|expected|(?:valid|supported|allowed) levels?/i.test(message);
|
||||
values.delete(currentEffort.toLowerCase());
|
||||
if (!hasAllowedCue && values.size === 0) return undefined;
|
||||
return values;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { scheduler } from "node:timers/promises";
|
||||
import { hostMatchesUrl } from "@oh-my-pi/pi-catalog/hosts";
|
||||
import { bareModelId, parseOpenAIModel, semverGte } from "@oh-my-pi/pi-catalog/identity";
|
||||
import { $flag, logger, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
||||
import * as AIError from "../error";
|
||||
import { getEnvApiKey } from "../stream";
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
createInitialResponsesAssistantMessage,
|
||||
createOpenAIStrictToolsState,
|
||||
disableStrictToolsForScope,
|
||||
getJuiceValue,
|
||||
getOpenAIPromptCacheKey,
|
||||
getOpenAIResponsesRoutingSessionId,
|
||||
getOpenAIStrictToolsScope,
|
||||
@@ -298,14 +300,23 @@ interface OpenAIResponsesChainedParams {
|
||||
*/
|
||||
function buildOpenAIResponsesChainedParams(
|
||||
params: OpenAIResponsesSamplingParams,
|
||||
trailingScaffoldingItems: number,
|
||||
chain: OpenAIResponsesChainState,
|
||||
): OpenAIResponsesChainedParams {
|
||||
const historyParams =
|
||||
trailingScaffoldingItems > 0 && Array.isArray(params.input)
|
||||
? { ...params, input: params.input.slice(0, params.input.length - trailingScaffoldingItems) }
|
||||
: params;
|
||||
const deltaInput = chain.canAppend
|
||||
? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems, params)
|
||||
? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems, historyParams)
|
||||
: null;
|
||||
if (deltaInput && deltaInput.length > 0 && chain.lastResponseId) {
|
||||
const scaffolding =
|
||||
historyParams !== params && Array.isArray(params.input)
|
||||
? params.input.slice(params.input.length - trailingScaffoldingItems)
|
||||
: [];
|
||||
return {
|
||||
params: { ...params, previous_response_id: chain.lastResponseId, input: deltaInput },
|
||||
params: { ...params, previous_response_id: chain.lastResponseId, input: [...deltaInput, ...scaffolding] },
|
||||
previousResponseId: chain.lastResponseId,
|
||||
};
|
||||
}
|
||||
@@ -462,8 +473,9 @@ const streamOpenAIResponsesOnce = (
|
||||
false,
|
||||
chainState?.canAppend ? chainState.lastParams?.input : undefined,
|
||||
);
|
||||
const params = builtParams.params;
|
||||
const { params, trailingScaffoldingItems } = builtParams;
|
||||
let activeParams = params;
|
||||
let activeTrailingScaffoldingItems = trailingScaffoldingItems;
|
||||
const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
|
||||
const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
|
||||
const attemptedReasoningEffortFallbacks = new Set<string>();
|
||||
@@ -490,7 +502,9 @@ const streamOpenAIResponsesOnce = (
|
||||
}
|
||||
applyReasoningEffortFallbackForRequest(params);
|
||||
let chained: OpenAIResponsesChainedParams =
|
||||
chainState && !chainState.disabled ? buildOpenAIResponsesChainedParams(params, chainState) : { params };
|
||||
chainState && !chainState.disabled
|
||||
? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
|
||||
: { params };
|
||||
sentPreviousResponseId = chained.previousResponseId;
|
||||
const idleTimeoutMs =
|
||||
options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
|
||||
@@ -586,7 +600,9 @@ const streamOpenAIResponsesOnce = (
|
||||
const reasoningEffortFallback =
|
||||
activeReasoningEffortFallbackKey && activeRequestParams && !requestSignal.aborted
|
||||
? resolveOpenAIReasoningEffortFallback(error, capturedErrorResponse, activeRequestParams, {
|
||||
explicitDisable: options?.disableReasoning === true && options.reasoning === undefined,
|
||||
explicitDisable:
|
||||
options?.forceReasoningOff === true ||
|
||||
(options?.disableReasoning === true && options.reasoning === undefined),
|
||||
})
|
||||
: undefined;
|
||||
if (reasoningEffortFallback !== undefined && activeReasoningEffortFallbackKey) {
|
||||
@@ -632,7 +648,11 @@ const streamOpenAIResponsesOnce = (
|
||||
if (chainState && !chainState.disabled) fallbackParams.store = true;
|
||||
let fallbackChained: OpenAIResponsesChainedParams =
|
||||
chainState && !chainState.disabled
|
||||
? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
|
||||
? buildOpenAIResponsesChainedParams(
|
||||
fallbackParams,
|
||||
fallbackBuilt.trailingScaffoldingItems,
|
||||
chainState,
|
||||
)
|
||||
: { params: fallbackParams };
|
||||
sentPreviousResponseId = fallbackChained.previousResponseId;
|
||||
fallbackChained = {
|
||||
@@ -642,7 +662,7 @@ const streamOpenAIResponsesOnce = (
|
||||
chained = fallbackChained;
|
||||
activeRawRequestDump.body = chained.params;
|
||||
activeParams = fallbackParams;
|
||||
activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
|
||||
activeTrailingScaffoldingItems = fallbackBuilt.trailingScaffoldingItems;
|
||||
continue;
|
||||
}
|
||||
if (!chainState || !sentPreviousResponseId || requestSignal.aborted) {
|
||||
@@ -688,6 +708,7 @@ const streamOpenAIResponsesOnce = (
|
||||
chained = { params: retryParams };
|
||||
activeRawRequestDump.body = retryParams;
|
||||
activeParams = currentParams;
|
||||
activeTrailingScaffoldingItems = currentBuilt.trailingScaffoldingItems;
|
||||
activeStrictToolsApplied = currentBuilt.strictToolsApplied;
|
||||
}
|
||||
}
|
||||
@@ -824,7 +845,17 @@ const streamOpenAIResponsesOnce = (
|
||||
if (replayableResponseItems) {
|
||||
if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
|
||||
if (chainState) {
|
||||
chainState.lastParams = structuredCloneJSON(activeParams);
|
||||
chainState.lastParams = structuredCloneJSON(
|
||||
activeTrailingScaffoldingItems > 0 && Array.isArray(activeParams.input)
|
||||
? {
|
||||
...activeParams,
|
||||
input: activeParams.input.slice(
|
||||
0,
|
||||
activeParams.input.length - activeTrailingScaffoldingItems,
|
||||
),
|
||||
}
|
||||
: activeParams,
|
||||
);
|
||||
chainState.lastPromptCacheBreakpointPolicy = promptCacheBreakpointPolicy;
|
||||
if (output.responseId) {
|
||||
chainState.lastResponseId = output.responseId;
|
||||
@@ -843,7 +874,14 @@ const streamOpenAIResponsesOnce = (
|
||||
// baseline, but `lastParams` still records the successful wire controls
|
||||
// without re-enabling `previous_response_id` chaining.
|
||||
chainState.canAppend = false;
|
||||
chainState.lastParams = structuredCloneJSON(activeParams);
|
||||
chainState.lastParams = structuredCloneJSON(
|
||||
activeTrailingScaffoldingItems > 0 && Array.isArray(activeParams.input)
|
||||
? {
|
||||
...activeParams,
|
||||
input: activeParams.input.slice(0, activeParams.input.length - activeTrailingScaffoldingItems),
|
||||
}
|
||||
: activeParams,
|
||||
);
|
||||
chainState.lastPromptCacheBreakpointPolicy = promptCacheBreakpointPolicy;
|
||||
chainState.lastResponseId = undefined;
|
||||
chainState.lastResponseItems = undefined;
|
||||
@@ -899,6 +937,17 @@ function isOfficialOpenAIResponsesEndpoint(model: Model<"openai-responses">): bo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-5.6+ family check for Responses routes. The model id classifies the
|
||||
* reasoning family regardless of the provider/host serving it — a cliproxy or
|
||||
* other OpenAI-compatible gateway carrying `gpt-5.6-sol` gets the same
|
||||
* scaffolding as the official endpoint.
|
||||
*/
|
||||
function isGpt56PlusResponsesModel(model: Model<"openai-responses">): boolean {
|
||||
const parsed = parseOpenAIModel(bareModelId(model.requestModelId ?? model.id));
|
||||
return parsed !== null && semverGte(parsed.version, "5.6");
|
||||
}
|
||||
|
||||
function isResponsesPromptCacheableContentBlock(block: unknown): block is ResponseInputContent {
|
||||
if (typeof block !== "object" || block === null || !("type" in block)) return false;
|
||||
return block.type === "input_text" || block.type === "input_image" || block.type === "input_file";
|
||||
@@ -1090,7 +1139,7 @@ export function buildParams(
|
||||
strictToolsScope?: OpenAIStrictToolsScope,
|
||||
disableStrictToolsOverride = false,
|
||||
statefulCacheBaseline?: ResponseInput,
|
||||
): { params: OpenAIResponsesSamplingParams; strictToolsApplied: boolean } {
|
||||
): { params: OpenAIResponsesSamplingParams; trailingScaffoldingItems: number; strictToolsApplied: boolean } {
|
||||
const policy = resolveOpenAICompatPolicy(model, {
|
||||
endpoint: "responses",
|
||||
reasoning: options?.reasoning,
|
||||
@@ -1113,6 +1162,10 @@ export function buildParams(
|
||||
filterReasoning: policy.reasoning.filterReasoningHistory,
|
||||
},
|
||||
includeThinkingSignatures: shouldReplayNativeHistory && !policy.reasoning.filterReasoningHistory,
|
||||
requiresReasoningReplayForAllTurns:
|
||||
policy.reasoning.enabled && policy.reasoning.requiresReasoningContentForAllAssistantTurns,
|
||||
requiresReasoningReplayForToolCalls:
|
||||
policy.reasoning.enabled && policy.reasoning.requiresReasoningContentForToolCalls,
|
||||
repairOrphanOutputs: true,
|
||||
});
|
||||
|
||||
@@ -1240,6 +1293,7 @@ export function buildParams(
|
||||
: options?.reasoningSummary;
|
||||
applyResponsesCompatPolicy(params, reasoningPolicy, {
|
||||
reasoningSummary,
|
||||
forceReasoningOff: options?.forceReasoningOff,
|
||||
mapEffort: effort =>
|
||||
model.compat.reasoningEffortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
|
||||
model.thinking?.effortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
|
||||
@@ -1249,7 +1303,7 @@ export function buildParams(
|
||||
// mode survives every policy branch (disabled/omitted effort included) while
|
||||
// keeping whatever effort/summary the policy produced — mode and effort are
|
||||
// independent wire fields.
|
||||
if (model.reasoningMode) {
|
||||
if (model.reasoningMode && !options?.forceReasoningOff) {
|
||||
params.reasoning = { ...params.reasoning, mode: model.reasoningMode };
|
||||
}
|
||||
|
||||
@@ -1262,7 +1316,18 @@ export function buildParams(
|
||||
applyOpenAIExtraBody(params, options?.extraBody);
|
||||
applyOpenAIResponsesPromptCachePolicy(params, model, options, statefulCacheBaseline);
|
||||
|
||||
return { params, strictToolsApplied };
|
||||
let trailingScaffoldingItems = 0;
|
||||
if (options?.forceReasoningOff && isGpt56PlusResponsesModel(model)) {
|
||||
const effort = options.reasoning ?? "medium";
|
||||
const juice = getJuiceValue(effort);
|
||||
messages.push({
|
||||
role: "developer",
|
||||
content: [{ type: "input_text", text: `# Juice: ${juice} !important` }],
|
||||
});
|
||||
trailingScaffoldingItems = 1;
|
||||
}
|
||||
|
||||
return { params, trailingScaffoldingItems, strictToolsApplied };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user