jeo-code 0.8.11 → 0.8.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +4 -4
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +23 -7
- package/src/agent/json.ts +32 -16
- package/src/agent/memory.ts +43 -2
- package/src/agent/tool-output.ts +9 -6
- package/src/ai/model-catalog.ts +39 -1
- package/src/ai/model-discovery.ts +5 -1
- package/src/ai/model-manager.ts +7 -6
- package/src/ai/providers/antigravity.ts +40 -7
- package/src/ai/providers/errors.ts +6 -2
- package/src/ai/providers/gemini.ts +29 -2
- package/src/commands/launch/model-slash.ts +5 -1
- package/src/commands/launch.ts +89 -25
- package/src/skills/catalog.ts +61 -31
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.8.13] - 2026-07-09
|
|
10
|
+
_"테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Routing vetoes now prefer equivalent credentialed tier models** (`src/commands/launch.ts`) — credential-missing, OAuth-not-servable, and local-provider-unreachable vetoes now scan `tierModelPool()` for the same prompt tier, exclude the rejected target, validate provider readiness, model servability, image support, and local-provider reachability, then switch to a session-stable equivalent before falling back to the session/default model. A misconfigured `roles.smol: "gpt-4o-mini"` with only Anthropic credentials now routes to `claude-haiku-4-5` instead of either failing on OpenAI or wasting a Sonnet turn.
|
|
14
|
+
- **`/route why` now explains equivalent fallback decisions** (`src/commands/launch.ts`) — when a veto switches to an alternate model, the route decision keeps the actual model used and carries a warning that names the rejected model, the reason, and the equivalent replacement.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Equivalent-fallback regression coverage** (`test/launch-prompt-routing.test.ts`) — updated the credential, OpenAI OAuth servability, and local-provider reachability veto tests to assert the equivalent model path, `warnOnce` behavior, and `/route why` explanation.
|
|
18
|
+
|
|
19
|
+
### Verified
|
|
20
|
+
- Focused routing suite: `bun test test/launch-prompt-routing.test.ts` — 23 pass / 0 fail.
|
|
21
|
+
|
|
22
|
+
## [0.8.12] - 2026-07-08
|
|
23
|
+
_"제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time._
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Added the missing `gpt-5.4-mini` to the OAuth allow-list** (`src/ai/model-catalog.ts`) — immediate fix for the exact reported failure, with a full capability row (272K context, 128K max output, full thinking support, image input) added to `MODEL_CATALOG`.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **Self-healing live Codex model gate** (`src/ai/model-catalog.ts`, `src/ai/model-discovery.ts`, `src/ai/model-manager.ts`, `src/commands/launch/model-slash.ts`) — new `isCodexModel()` checks the maintained `CODEX_MODELS` list PLUS any model id this session's OWN OAuth discovery call already observed live (`recordLiveCodexModels`, additive-only, session-lifetime). `listProviderModels` now records every `oauth`-sourced OpenAI model list on a successful call (picker open, `jeo doctor`, or any model resolution) — an `api_key`-sourced result never widens the gate, since it says nothing about what OAuth can serve. `oauthServesModel` (the gate behind `resolveCall`, `effectiveCredentialForProvider`, and `modelServableWithConfig`) and the `/model` pin-warning now both consult `isCodexModel` instead of the raw static list — closes this bug class permanently: any future Codex model OpenAI ships becomes usable the moment the account's own endpoint confirms it, with zero release lag. The OAuth-rejection error message is now built dynamically from `CODEX_MODELS` instead of a hard-coded two-model string.
|
|
30
|
+
|
|
31
|
+
### Verified
|
|
32
|
+
- Independently mutation-tested (5 mutations across all 4 touched files): all 5 caught by tests; found and closed 1 genuine gap in `recordLiveCodexModels`'s additive-across-separate-calls contract (a clear-then-add REPLACE implementation was indistinguishable from ADD until a test called it twice with disjoint id lists).
|
|
33
|
+
- Live-verified: `jeo --model gpt-5.4-mini -p "hello"` failed with the OAuth-rejection error before the fix, succeeds after.
|
|
34
|
+
- Full suite 2802/2802 pass, typecheck clean.
|
|
35
|
+
|
|
9
36
|
## [0.8.11] - 2026-07-08
|
|
10
37
|
_"프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered._
|
|
11
38
|
|
package/README.ja.md
CHANGED
|
@@ -262,11 +262,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
262
262
|
## 変更履歴 (Changelog)
|
|
263
263
|
|
|
264
264
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
265
|
+
- **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
|
|
266
|
+
- **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
|
|
265
267
|
- **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
|
|
266
268
|
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
267
269
|
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
268
|
-
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
269
|
-
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
270
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -263,11 +263,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
263
263
|
## 변경 이력 (Changelog)
|
|
264
264
|
|
|
265
265
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
266
|
+
- **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
|
|
267
|
+
- **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
|
|
266
268
|
- **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
|
|
267
269
|
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
268
270
|
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
269
|
-
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
270
|
-
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
271
271
|
|
|
272
272
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
273
273
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ Run `jeo` inside a repository and it reads files, edits them, runs commands, and
|
|
|
38
38
|
|
|
39
39
|
## Highlights
|
|
40
40
|
|
|
41
|
-
- **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY
|
|
41
|
+
- **Multi-provider, one loop** — Anthropic / OpenAI (+Codex) / Gemini / Antigravity / Ollama / LM Studio, plus 20+ OpenAI- and Anthropic-compatible clouds (Groq, DeepSeek, Mistral, OpenRouter, xAI, Kimi, z.ai, …), all behind one uniform JSON tool loop. OAuth login happens from the input box (`/provider login`), every model pick persists as the new default, and prompt routing only auto-selects usable credentialed paths: Gemini OAuth goes through the provider-qualified `antigravity/*` agent set (Gemini 3.5 Flash tiers, Gemini 3.1 Pro, Claude Sonnet/Opus 4.6), never public `google/gemini-*` rows that require `GEMINI_API_KEY`; if a configured route points at an unready provider, jeo switches to an equivalent credentialed tier model before falling back to the default.
|
|
42
42
|
- **Edit integrity** — read output carries content anchors (`42ab|`); anchored edits are verified against the current file, re-mapped when lines shifted, and rejected with fresh content instead of corrupting.
|
|
43
43
|
- **Self-correcting verification loop** — configure a post-edit hook (tsc / eslint / tests) and the agent *sees* the diagnostics and fixes them in-loop; a red hook blocks `done` until resolved.
|
|
44
44
|
- **Real gates, no theater** — `ralplan` consensus is a repo-grounded critic subagent whose `[OKAY]` verdict is persisted and *required* by `jeo approve`; `ultragoal` reports honestly (a suite run is a global signal, never fabricated per-criterion passes).
|
|
@@ -80,7 +80,7 @@ Inside the `jeo` REPL (Tab autocompletes; `/` opens the palette).
|
|
|
80
80
|
| `/provider login <name>` · `/logout` | OAuth login/logout from the input box |
|
|
81
81
|
| `/agents [role]` · `/subagent` | Per-role (executor/planner/architect/critic) model · thinking · step config |
|
|
82
82
|
| `/thinking [level]` | Show/set default reasoning budget (low…xhigh) |
|
|
83
|
-
| `/route [status\|on\|off\|why]` | Toggle prompt-based model routing for this session · explain the last routing decision (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve) |
|
|
83
|
+
| `/route [status\|on\|off\|why]` | Toggle prompt-based model routing for this session · explain the last routing decision (auto-routes each turn to a tier-appropriate model among the models your configured credentials — OAuth or API key — actually serve, and switches to an equivalent tier model when a configured route is unready) |
|
|
84
84
|
| `/fast [on\|off\|status]` | Toggle fast thinking mode when the active model advertises low reasoning |
|
|
85
85
|
| `/skill` · `$<skill> [intent]` | List/run workflow skills (`$team "task"` style) |
|
|
86
86
|
| `/view` · `/diff` · `/find` · `/search` | Code view, git diff, file/pattern search |
|
|
@@ -265,11 +265,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
265
265
|
## Changelog
|
|
266
266
|
|
|
267
267
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
268
|
+
- **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
|
|
269
|
+
- **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
|
|
268
270
|
- **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
|
|
269
271
|
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
270
272
|
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
271
|
-
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
272
|
-
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
273
273
|
|
|
274
274
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
275
275
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -262,11 +262,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
262
262
|
## 更新日志 (Changelog)
|
|
263
263
|
|
|
264
264
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
265
|
+
- **[0.8.13]** (2026-07-09) — "테스트검증 jeo --tmux 로 진행해줘" — traced a routing recovery weakness that survived the earlier credential, servability, and local reachability vetoes: once a configured route target was rejected, jeo threw away the whole routing decision and dropped to the session/default model even when the same prompt tier had another credentialed, servable model available. That kept turns working, but it also silently undid routing's cost/latency goal and made `/route why` less useful because the recorded decision was a fallback note instead of the actual model used.
|
|
266
|
+
- **[0.8.12]** (2026-07-08) — "제오코드에서 oauth 지피티 모델 연결이 안되는데 원인파악해서 개선해" — root-caused a structural drift bug: OpenAI's live Codex models endpoint (`chatgpt.com/backend-api/codex/models`, the SAME endpoint the `/model` picker discovers from) had shipped `gpt-5.4-mini` alongside `gpt-5.5`/`gpt-5.4`, but jeo's static OAuth allow-list (`CODEX_MODELS`) hadn't caught up — the picker correctly listed the model (straight from that live endpoint), but selecting or calling it then hit a hard-coded `CODEX_MODELS.includes(...)` gate that rejected it with "OpenAI OAuth credential only supports Codex models", even though the account's own OAuth token genuinely serves it. This is a whole class of bug: it recurs every time OpenAI ships a new Codex model between jeo releases, and previously required a jeo update to fix each time.
|
|
265
267
|
- **[0.8.11]** (2026-07-08) — "프롬프트 라우팅 동작이 Error: Unable to connect. Is the computer able to access the url? Error: Was there a typo in the url or port? 와 같은 메시지 남기고 동작안하는데 원인을 파악하고 근본문제해결하자" — traced the raw, provider-less connection error to two compounding gaps. (1) `describeProvider` reports local providers (ollama/lmstudio) as `ready: true` UNCONDITIONALLY — "keyless" only means no credential is required, never that the server is actually reachable — so the routing veto gate (which exists precisely to keep a misconfigured routing target from making a turn worse than routing being off) had no way to catch a `routing.tiers`/`roles` pin to a downed local server. (2) Bun's fetch/undici throws a bare `Error("Unable to connect. Is the computer able to access the url?")` with `.code === "ConnectionRefused"` for BOTH a refused connection and an unresolvable host — no HTTP status, no provider name, no URL — and this fell through every existing error classifier (`defaultRetryable`, `friendlyProviderError`) to reach the user completely unfiltered.
|
|
266
268
|
- **[0.8.10]** (2026-07-08) — "지피티 모델 연결이 안되는데 원인파악해서 개선해" — OpenAI's Codex/Responses backend (used by every `gpt-5.5`/`gpt-5.4` OAuth call, and by API-key reasoning models) can emit an in-band `response.failed`/`error` SSE EVENT on an otherwise-200 stream (documented codes: `server_error`, `rate_limit_exceeded` — OpenAI's own guidance is "retry with exponential backoff"). This was thrown as an unclassified bare `Error`, which propagated straight out of the engine's model call with NO retry — a transient OpenAI backend hiccup killed the whole turn outright, surfacing as "GPT doesn't connect" during real interactive use even though `jeo doctor` and a fresh one-shot call both looked healthy (the failure only manifests mid-stream, after the connection has already succeeded).
|
|
267
269
|
- **[0.8.9]** (2026-07-08) — "프롬프트 라우팅 속도 문제가 있는거같은데 근본원인 알려줘" — prompt routing incurred significant latency due to a design oversight in LLM-based escalation: when heuristic confidence falls below threshold (standard conceptual questions with no code blocks/file paths conflict-trigger 0.35 confidence), it makes a blocking, synchronous LLM classifier call. If the user's global `thinkingLevel` is enabled (medium/high/xhigh), the cheap classifier model (Haiku 4.5 / GPT-4o-mini) also ran with reasoning enabled, wasting 500ms–1500ms on internal thoughts for a simple 1-word JSON response. The same reasoning-latency leak existed on all other background/internal LLM calls (compaction summarizer, goal verifier, memory distiller), blocking user turns on compaction boundaries.
|
|
268
|
-
- **[0.8.8]** (2026-07-08) — "실동작검증을 통해서 유효성 평가하고 개선해줘" — live-verified the v0.8.5 fallback mechanism using active Claude Code OAuth credentials on this machine (confirmed 200 OK for both Fable 5 and Opus 4.8 via the proxy). Identified two critical improvement vectors during verification: (1) server-side fallback was previously scoped strictly to API-key credentials, leaving OAuth users (who also hit `reasoning_extraction` refusals) with today's reactive-only recovery; (2) the sequential `postAnthropic` retry ladder could throw immediately on a combination of different error types (e.g. temperature error first, then fallback error, then success).
|
|
269
|
-
- **[0.8.7]** (2026-07-08) — Fix-forward for a CI-caught release failure in v0.8.6 (this same session): `test/launch-prompt-routing.test.ts` shipped with a dropped closing brace (a git 3-way merge misaligned on a duplicate `});` line during this session's own working-tree isolation, then a subsequent `git stash push --keep-index` silently reset the working tree to the pre-fix staged content — the fix was applied once, verified once, then invisibly reverted before the commit that actually shipped). The v0.8.6 GitHub Actions release workflow correctly caught the syntax error at `bun test` and failed BEFORE the npm publish step ran; `npm view jeo-code version` confirmed 0.8.5 remained latest throughout — nothing broken ever reached the registry. Per this repo's established pattern for a same-day CI-caught regression (v0.8.0 → v0.8.1), shipping forward rather than retargeting the already-publicly-failed v0.8.6 tag.
|
|
270
270
|
|
|
271
271
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
272
272
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import * as fs from "node:fs/promises";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import type { Message } from "./loop";
|
|
13
|
-
import {
|
|
13
|
+
import { extractJsonObjectWithSpan, tryExtractJsonObject } from "./json";
|
|
14
14
|
import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
|
|
15
15
|
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, bisectTool, type ToolResult } from "./tools";
|
|
16
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
@@ -469,7 +469,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
469
469
|
{ jsonMode: false, model: opts.model, maxTokens: opts.maxTokens, signal: opts.signal, sessionKey: opts.sessionKey },
|
|
470
470
|
);
|
|
471
471
|
const consolidated = wrapUp.trim();
|
|
472
|
-
|
|
472
|
+
// Defensive: the wrap-up call explicitly forbids tool/JSON output, but a
|
|
473
|
+
// degenerate model under a long, polluted turn can still emit tool-call-shaped
|
|
474
|
+
// JSON here. Never surface that raw to the user as if it were the answer —
|
|
475
|
+
// fall through to the plain stop message instead (same as an empty wrap-up).
|
|
476
|
+
const looksLikeToolCall = tryExtractJsonObject<any>(consolidated, { preferKeys: ["tool", "tools"] });
|
|
477
|
+
const isToolCallShaped = looksLikeToolCall && typeof looksLikeToolCall === "object" &&
|
|
478
|
+
(typeof looksLikeToolCall.tool === "string" || Array.isArray(looksLikeToolCall.tools));
|
|
479
|
+
if (consolidated && !isToolCallShaped) {
|
|
473
480
|
pushAssistantTurn(history, consolidated, "", []);
|
|
474
481
|
return finish({
|
|
475
482
|
done: false,
|
|
@@ -671,10 +678,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
671
678
|
responseText = await invokeCallLlm(history, {
|
|
672
679
|
jsonMode: true,
|
|
673
680
|
// NATIVE tool-calling: declare the ACTIVE toolset (read-only subagents
|
|
674
|
-
// expose only their non-mutating tools).
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
681
|
+
// expose only their non-mutating tools). NATIVE-capable adapters
|
|
682
|
+
// (anthropic, gemini, antigravity — see supportsNativeTools) send these
|
|
683
|
+
// on the wire and re-serialize the structured call to canonical JSON;
|
|
684
|
+
// ollama (no native tool support) ignores them and relies solely on the
|
|
685
|
+
// JSON-in-prose protocol above. Only on the main step — never the prose
|
|
686
|
+
// wrap-up call below.
|
|
678
687
|
tools: turnToolSchemas,
|
|
679
688
|
model: opts.model,
|
|
680
689
|
maxTokens: opts.maxTokens,
|
|
@@ -857,7 +866,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
857
866
|
|
|
858
867
|
let invocation: any;
|
|
859
868
|
try {
|
|
860
|
-
|
|
869
|
+
const extracted = extractJsonObjectWithSpan<any>(responseText, { preferKeys: ["tool", "tools"] });
|
|
870
|
+
invocation = extracted.value;
|
|
871
|
+
// Trim to ONLY the matched JSON — a degenerate model that appends
|
|
872
|
+
// garbled/repeated text after a valid tool call must not have that
|
|
873
|
+
// garbage (a) shown to the user as this turn's assistant record, or
|
|
874
|
+
// (b) echoed back into its own history on the next call, where it
|
|
875
|
+
// would reinforce the corruption instead of self-correcting.
|
|
876
|
+
responseText = extracted.matched;
|
|
861
877
|
} catch (err) {
|
|
862
878
|
ev.onAssistant?.(responseText, null);
|
|
863
879
|
// Prose salvage: a reply with no JSON object at all is a chat-style final
|
package/src/agent/json.ts
CHANGED
|
@@ -15,26 +15,38 @@ export function extractJsonObject<T = unknown>(
|
|
|
15
15
|
text: string,
|
|
16
16
|
opts?: { preferKeys?: string[] },
|
|
17
17
|
): T {
|
|
18
|
+
return extractJsonObjectWithSpan<T>(text, opts).value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Like {@link extractJsonObject}, but also reports the exact substring of `text`
|
|
23
|
+
* that was matched (verbatim, pre-repair) — the object itself, with none of the
|
|
24
|
+
* surrounding prose/fences/trailing commentary. Callers that persist the model's
|
|
25
|
+
* reply (e.g. into conversation history) use this to store ONLY the valid JSON:
|
|
26
|
+
* a degenerate model that appends garbled/repeated text after a valid tool call
|
|
27
|
+
* must not have that garbage echoed back into its own context on the next turn,
|
|
28
|
+
* where it would reinforce the corruption instead of self-correcting.
|
|
29
|
+
*/
|
|
30
|
+
export function extractJsonObjectWithSpan<T = unknown>(
|
|
31
|
+
text: string,
|
|
32
|
+
opts?: { preferKeys?: string[] },
|
|
33
|
+
): { value: T; matched: string } {
|
|
18
34
|
const raw = text.trim();
|
|
19
35
|
const preferKeys = opts?.preferKeys;
|
|
20
36
|
|
|
21
37
|
// Fast path: already pure JSON (optionally with a trailing comma to repair).
|
|
22
38
|
const fast = tryParse<T>(raw);
|
|
23
|
-
if (fast !== undefined) return fast;
|
|
39
|
+
if (fast !== undefined) return { value: fast, matched: raw };
|
|
24
40
|
|
|
25
41
|
// Strip common code fences and retry (pure, then trailing-comma-repaired).
|
|
26
42
|
const defenced = raw.replace(/```(?:json|JSON)?/g, "").trim();
|
|
27
43
|
const fromDefencedWhole = tryParse<T>(defenced);
|
|
28
|
-
if (fromDefencedWhole !== undefined) return fromDefencedWhole;
|
|
44
|
+
if (fromDefencedWhole !== undefined) return { value: fromDefencedWhole, matched: defenced };
|
|
29
45
|
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const parsedFromRaw = findAndParseBalancedObject<T>(raw, preferKeys);
|
|
35
|
-
if (parsedFromRaw !== undefined) {
|
|
36
|
-
return parsedFromRaw;
|
|
37
|
-
}
|
|
46
|
+
const fromDefenced = findAndParseBalancedObject<T>(defenced, preferKeys);
|
|
47
|
+
if (fromDefenced !== undefined) return fromDefenced;
|
|
48
|
+
const fromRaw = findAndParseBalancedObject<T>(raw, preferKeys);
|
|
49
|
+
if (fromRaw !== undefined) return fromRaw;
|
|
38
50
|
throw new Error(`No parseable JSON object found in model output: ${truncate(raw, 200)}`);
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -204,29 +216,33 @@ function extractBalancedObject(text: string, startIndex: number): string | null
|
|
|
204
216
|
* after a stray JSON-looking object in reasoning prose); fall back to the first
|
|
205
217
|
* parseable object when none carries a preferred key.
|
|
206
218
|
*/
|
|
207
|
-
function findAndParseBalancedObject<T>(
|
|
208
|
-
|
|
219
|
+
function findAndParseBalancedObject<T>(
|
|
220
|
+
text: string,
|
|
221
|
+
preferKeys?: string[],
|
|
222
|
+
): { value: T; matched: string } | undefined {
|
|
223
|
+
let first: { value: T; matched: string } | undefined;
|
|
209
224
|
for (let i = 0; i < text.length; i++) {
|
|
210
225
|
if (text[i] === "{") {
|
|
211
226
|
const candidate = extractBalancedObject(text, i);
|
|
212
227
|
if (candidate) {
|
|
213
228
|
const parsed = tryParse<T>(candidate);
|
|
214
229
|
if (parsed !== undefined) {
|
|
215
|
-
|
|
216
|
-
if (
|
|
230
|
+
const found = { value: parsed, matched: candidate };
|
|
231
|
+
if (first === undefined) first = found;
|
|
232
|
+
if (!preferKeys || preferKeys.length === 0) return found;
|
|
217
233
|
if (
|
|
218
234
|
parsed !== null &&
|
|
219
235
|
typeof parsed === "object" &&
|
|
220
236
|
preferKeys.some(k => k in (parsed as Record<string, unknown>))
|
|
221
237
|
) {
|
|
222
|
-
return
|
|
238
|
+
return found;
|
|
223
239
|
}
|
|
224
240
|
// else: keep scanning for an object that carries a preferred key
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
243
|
}
|
|
228
244
|
}
|
|
229
|
-
return
|
|
245
|
+
return first;
|
|
230
246
|
}
|
|
231
247
|
|
|
232
248
|
function truncate(s: string, n: number): string {
|
package/src/agent/memory.ts
CHANGED
|
@@ -229,11 +229,53 @@ interface ParsedConceptEntry { sig: string; concept: Concept | null }
|
|
|
229
229
|
const parsedConceptCache = new Map<string, ParsedConceptEntry>();
|
|
230
230
|
const PARSED_CONCEPT_CACHE_CAP = 512;
|
|
231
231
|
|
|
232
|
+
/** Single-slot cache for `buildCorpusStats`/`buildConceptGraph` over the LAST
|
|
233
|
+
* `concepts` array seen, keyed by reference identity. `memoryPromptSection` runs
|
|
234
|
+
* `selectWithinBudget` once per interactive turn (`refreshSessionMemory`) and once
|
|
235
|
+
* per subagent spawn (team/ralph/autopilot) — each call re-tokenized every concept's
|
|
236
|
+
* searchable text and re-scanned every body for cross-links, even when NOTHING in the
|
|
237
|
+
* bundle changed between calls. `loadConcepts` already returns the same cached `Concept`
|
|
238
|
+
* OBJECTS when a file's mtime/size is unchanged (see `parsedConceptCache` above), so an
|
|
239
|
+
* identical (same length + same object references) `concepts` array is the common case
|
|
240
|
+
* across consecutive calls in one session — this hits that case with a cheap O(n)
|
|
241
|
+
* reference-equality scan instead of paying the O(n · text length) rebuild. A genuinely
|
|
242
|
+
* changed bundle (new/edited/deleted concept) always produces a different array — either
|
|
243
|
+
* a different length or at least one different object reference — so correctness never
|
|
244
|
+
* depends on this cache; it only skips work that would reach the same answer. */
|
|
245
|
+
let statsGraphCacheConcepts: Concept[] | null = null;
|
|
246
|
+
let statsGraphCacheStats: CorpusStats | null = null;
|
|
247
|
+
let statsGraphCacheGraph: ConceptGraph | null = null;
|
|
248
|
+
|
|
249
|
+
function sameConceptsArray(a: Concept[], b: Concept[]): boolean {
|
|
250
|
+
if (a.length !== b.length) return false;
|
|
251
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Corpus stats + link graph for `concepts`, memoized against the last call (see above). */
|
|
256
|
+
function statsAndGraphFor(concepts: Concept[]): { stats: CorpusStats; graph: ConceptGraph } {
|
|
257
|
+
if (
|
|
258
|
+
statsGraphCacheConcepts && statsGraphCacheStats && statsGraphCacheGraph &&
|
|
259
|
+
sameConceptsArray(statsGraphCacheConcepts, concepts)
|
|
260
|
+
) {
|
|
261
|
+
return { stats: statsGraphCacheStats, graph: statsGraphCacheGraph };
|
|
262
|
+
}
|
|
263
|
+
const stats = buildCorpusStats(concepts);
|
|
264
|
+
const graph = buildConceptGraph(concepts);
|
|
265
|
+
statsGraphCacheConcepts = concepts;
|
|
266
|
+
statsGraphCacheStats = stats;
|
|
267
|
+
statsGraphCacheGraph = graph;
|
|
268
|
+
return { stats, graph };
|
|
269
|
+
}
|
|
270
|
+
|
|
232
271
|
/** Drop the per-file concept parse cache so the next loadConcepts re-reads disk.
|
|
233
272
|
* The cache is already mtime/size self-invalidating; this is a hard reset for
|
|
234
273
|
* tests and callers that mutate files without changing their stat signature. */
|
|
235
274
|
export function invalidateConceptCache(): void {
|
|
236
275
|
parsedConceptCache.clear();
|
|
276
|
+
statsGraphCacheConcepts = null;
|
|
277
|
+
statsGraphCacheStats = null;
|
|
278
|
+
statsGraphCacheGraph = null;
|
|
237
279
|
}
|
|
238
280
|
|
|
239
281
|
/** Read every concept document in the bundle into structured `Concept`s. Reserved
|
|
@@ -572,8 +614,7 @@ function selectWithinBudget(concepts: Concept[], query: string | undefined, budg
|
|
|
572
614
|
const sectionLenByType = new Map<string, number>(); // type -> rendered length of that type's section so far
|
|
573
615
|
let total = 0; // rendered length of `selected` under renderConcepts
|
|
574
616
|
|
|
575
|
-
const stats =
|
|
576
|
-
const graph = buildConceptGraph(concepts);
|
|
617
|
+
const { stats, graph } = statsAndGraphFor(concepts);
|
|
577
618
|
|
|
578
619
|
const take = (pool: Concept[]) => {
|
|
579
620
|
for (const c of priorityOrder(pool, query, stats, graph)) {
|
package/src/agent/tool-output.ts
CHANGED
|
@@ -60,15 +60,18 @@ export const TOOL_SPILL_THRESHOLD = TOOL_OUTPUT_MAX;
|
|
|
60
60
|
/** Most recent tool-result artifacts to keep; older ones are pruned on each spill. */
|
|
61
61
|
export const MAX_TOOL_ARTIFACTS = 50;
|
|
62
62
|
|
|
63
|
-
/** Best-effort retention: keep the newest `MAX_TOOL_ARTIFACTS` files in `dir`, delete the
|
|
63
|
+
/** Best-effort retention: keep the newest `MAX_TOOL_ARTIFACTS` files in `dir`, delete the
|
|
64
|
+
* rest. Filenames are always `${Date.now()}-${rand}-${tool}.txt` (spillToolResult is the
|
|
65
|
+
* sole writer into this directory) — the leading 13-digit millisecond timestamp is fixed-
|
|
66
|
+
* width until year 2286, so a plain lexicographic sort of the FILENAMES reproduces mtime
|
|
67
|
+
* order exactly. This replaces an `fs.stat` per file (up to MAX_TOOL_ARTIFACTS syscalls,
|
|
68
|
+
* paid on every spill once a long session's artifact dir sits at/near the cap) with a pure
|
|
69
|
+
* in-memory string sort — zero syscalls beyond the single `readdir` every call already paid. */
|
|
64
70
|
async function pruneToolArtifacts(dir: string): Promise<void> {
|
|
65
71
|
const files = await fs.readdir(dir).catch(() => [] as string[]);
|
|
66
72
|
if (files.length <= MAX_TOOL_ARTIFACTS) return;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
stamped.sort((a, b) => b.m - a.m); // newest first
|
|
71
|
-
for (const { f } of stamped.slice(MAX_TOOL_ARTIFACTS)) {
|
|
73
|
+
const newestFirst = [...files].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
|
74
|
+
for (const f of newestFirst.slice(MAX_TOOL_ARTIFACTS)) {
|
|
72
75
|
await fs.rm(path.join(dir, f), { force: true }).catch(() => {});
|
|
73
76
|
}
|
|
74
77
|
}
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -101,6 +101,7 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
|
|
|
101
101
|
// OpenAI (newest first: gpt-5.5 > gpt-5.4 > o4-mini/o3/gpt-4.1 > o3-mini > gpt-4o-mini > gpt-4o)
|
|
102
102
|
{ canonical: "gpt-5.5", provider: "openai", providerModel: "gpt-5.5", contextTokens: 400_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-04" },
|
|
103
103
|
{ canonical: "gpt-5.4", provider: "openai", providerModel: "gpt-5.4", contextTokens: 400_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-03" },
|
|
104
|
+
{ canonical: "gpt-5.4-mini", provider: "openai", providerModel: "gpt-5.4-mini", contextTokens: 272_000, maxOutputTokens: 128_000, thinking: FULL, images: true, releaseDate: "2026-03" },
|
|
104
105
|
{ canonical: "o4-mini", provider: "openai", providerModel: "o4-mini", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: true, releaseDate: "2025-04" },
|
|
105
106
|
{ canonical: "o3", provider: "openai", providerModel: "o3", contextTokens: 200_000, maxOutputTokens: 100_000, thinking: STD, images: true, releaseDate: "2025-04" },
|
|
106
107
|
{ canonical: "gpt-4.1", provider: "openai", providerModel: "gpt-4.1", contextTokens: 1_000_000, maxOutputTokens: 32_768, thinking: [], images: true, releaseDate: "2025-04" },
|
|
@@ -182,7 +183,44 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
|
|
|
182
183
|
* usable list endpoint, so an OAuth-only OpenAI login surfaces exactly these instead
|
|
183
184
|
* of the full chat-completions catalog. Verified live against a ChatGPT account.
|
|
184
185
|
*/
|
|
185
|
-
export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4"];
|
|
186
|
+
export const CODEX_MODELS: readonly string[] = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Session-lifetime cache of OpenAI Codex model ids OBSERVED live from the account's
|
|
190
|
+
* own `codex/models` response (see `listProviderModels` in model-discovery.ts, which
|
|
191
|
+
* calls `recordLiveCodexModels` on every successful OAuth discovery). `CODEX_MODELS`
|
|
192
|
+
* above is a maintained snapshot that WILL drift the moment OpenAI ships a new Codex
|
|
193
|
+
* model — that drift is exactly the bug class this closes: the picker shows a model
|
|
194
|
+
* (from the SAME live endpoint) that the static gate then hard-rejects at call time
|
|
195
|
+
* ("OAuth doesn't support this model") even though the account can serve it. Any
|
|
196
|
+
* successful discovery this session widens the gate immediately, no release needed;
|
|
197
|
+
* a fresh process starts empty and falls back to the static list until the first
|
|
198
|
+
* discovery call (picker open, `jeo doctor`, or model resolution) populates it.
|
|
199
|
+
*/
|
|
200
|
+
const liveCodexModels = new Set<string>();
|
|
201
|
+
|
|
202
|
+
/** Record model ids OpenAI's live Codex endpoint returned for the current account
|
|
203
|
+
* this session. Additive only — never removes a previously-observed id (a transient
|
|
204
|
+
* discovery hiccup should never narrow what a call is allowed to reach). */
|
|
205
|
+
export function recordLiveCodexModels(ids: readonly string[]): void {
|
|
206
|
+
for (const id of ids) liveCodexModels.add(id);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True when `model` is a Codex-servable id — either in the maintained static list,
|
|
210
|
+
* or observed live this session (see `recordLiveCodexModels`). Accepts a bare or
|
|
211
|
+
* `openai/`-qualified id. */
|
|
212
|
+
export function isCodexModel(model: string): boolean {
|
|
213
|
+
const wire = model.startsWith("openai/") ? model.slice(7) : model;
|
|
214
|
+
return CODEX_MODELS.includes(wire) || liveCodexModels.has(wire);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Test-only: clear the live-observed Codex model cache (mirrors `resetPromptRouterWarnings`
|
|
218
|
+
* / `resetAppearanceCache`). Bun runs test files in one process, so this module-level Set
|
|
219
|
+
* would otherwise leak an `oauth`-source `listProviderModels("openai", …)` call's observed
|
|
220
|
+
* ids into unrelated tests in the same run. */
|
|
221
|
+
export function resetLiveCodexModels(): void {
|
|
222
|
+
liveCodexModels.clear();
|
|
223
|
+
}
|
|
186
224
|
|
|
187
225
|
/**
|
|
188
226
|
* Model ids the Kimi Code OAuth backend (api.kimi.com/coding, Anthropic Messages
|
|
@@ -11,7 +11,7 @@ import { readGlobalConfig, type Config } from "../agent/state";
|
|
|
11
11
|
import { resolveCredential, type AuthProvider, type Credential } from "../auth";
|
|
12
12
|
import type { ProviderName } from "./types";
|
|
13
13
|
import { PROVIDER_NAMES } from "./provider-status";
|
|
14
|
-
import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS } from "./model-catalog";
|
|
14
|
+
import { catalogByProvider, CODEX_MODELS, KIMI_CODE_MODELS, recordLiveCodexModels } from "./model-catalog";
|
|
15
15
|
import { extractChatgptAccountId } from "./providers/openai-responses";
|
|
16
16
|
import { openaiCompatDef } from "./providers/openai-compatible-catalog";
|
|
17
17
|
|
|
@@ -405,6 +405,10 @@ export async function listProviderModels(
|
|
|
405
405
|
}
|
|
406
406
|
}
|
|
407
407
|
const models = [...new Set(ids)].sort().slice(0, limit);
|
|
408
|
+
// Widen the self-healing Codex gate (model-catalog.ts) the moment the account's
|
|
409
|
+
// OWN live endpoint confirms a model — closes the "picker shows it, call-time
|
|
410
|
+
// gate rejects it" drift window without waiting for a jeo release.
|
|
411
|
+
if (provider === "openai" && source === "oauth") recordLiveCodexModels(models);
|
|
408
412
|
return { provider, models, ok: true, source };
|
|
409
413
|
} catch (err) {
|
|
410
414
|
const msg = (err as Error)?.name === "TimeoutError" || (err as Error)?.name === "AbortError" ? "timeout" : "unreachable";
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -6,7 +6,7 @@ import "./register-providers"; // side-effect: registers built-in adapters into
|
|
|
6
6
|
import type { CallOptions, Message, ProviderAdapter, ProviderName } from "./types";
|
|
7
7
|
import { expandAlias, resolveModelId, effectiveAliasesFor, BUILTIN_ALIASES } from "./model-registry";
|
|
8
8
|
import { findCatalogEntry, type ModelCatalogEntry } from "./model-catalog-compat";
|
|
9
|
-
import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel } from "./model-catalog";
|
|
9
|
+
import { toProviderModel, CODEX_MODELS, KIMI_CODE_MODELS, findCatalogModel, isCodexModel } from "./model-catalog";
|
|
10
10
|
import { xaiCredential } from "./providers/xai";
|
|
11
11
|
import { OPENAI_COMPAT_NAMES, isOpenAICompatProvider } from "./providers/openai-compatible-catalog";
|
|
12
12
|
import { withRetry, defaultRetryable, withConnectionContext, isConnectionError, ConnectionContextError, type RetryOptions } from "../util/retry";
|
|
@@ -307,10 +307,11 @@ export function resolveRetryOptions(retry: Config["retry"], kind: "request" | "s
|
|
|
307
307
|
*/
|
|
308
308
|
function oauthServesModel(provider: AuthProvider, model: string): boolean {
|
|
309
309
|
if (provider === "openai") {
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
// `isCodexModel` checks the maintained CODEX_MODELS list PLUS any id observed
|
|
311
|
+
// live this session (model-catalog.ts) — closes the "picker shows a model the
|
|
312
|
+
// account's own endpoint just confirmed, call-time gate rejects it anyway" drift
|
|
313
|
+
// window that opens every time OpenAI ships a new Codex model between releases.
|
|
314
|
+
return isCodexModel(model);
|
|
314
315
|
}
|
|
315
316
|
if (provider === "kimi") {
|
|
316
317
|
const wire = model.startsWith("kimi/") ? model.slice(5) : model;
|
|
@@ -487,7 +488,7 @@ export async function resolveCall(options: Partial<CallOptions>, kind: "request"
|
|
|
487
488
|
const isLocalOpenAi = provider === "openai" && !!baseUrl;
|
|
488
489
|
if (provider === "openai" && effective.kind === "oauth" && !isLocalOpenAi && !oauthServesModel("openai", model)) {
|
|
489
490
|
throw new Error(
|
|
490
|
-
|
|
491
|
+
`OpenAI OAuth 자격증명은 Codex 모델(${CODEX_MODELS.join("/")})만 지원. OPENAI_API_KEY를 설정하거나 모델을 변경하세요`
|
|
491
492
|
);
|
|
492
493
|
}
|
|
493
494
|
if (provider === "kimi" && effective.kind === "oauth" && !oauthServesModel("kimi", model)) {
|
|
@@ -2,10 +2,10 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import type { Credential } from "../../auth";
|
|
3
3
|
import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
4
4
|
import { readSse } from "../sse";
|
|
5
|
-
import { providerHttpError } from "./errors";
|
|
5
|
+
import { providerHttpError, ProviderStreamError } from "./errors";
|
|
6
6
|
import { serializeToolCalls } from "../../agent/tool-schemas";
|
|
7
7
|
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
8
|
-
import { geminiThinkingConfig,
|
|
8
|
+
import { geminiThinkingConfig, type GeminiThinkingConfig } from "./gemini";
|
|
9
9
|
|
|
10
10
|
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
11
11
|
const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
@@ -95,11 +95,31 @@ export async function resolveAntigravityProjectId(
|
|
|
95
95
|
|
|
96
96
|
const discover = opts.discover ?? (async (token: string) => {
|
|
97
97
|
const { discoverGoogleProjectId, ANTIGRAVITY_DISCOVERY_METADATA } = await import("../../auth/flows/google-project");
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
// This function's SOLE callers are fetchAntigravity/ccaTurn — every discovery
|
|
99
|
+
// it performs exists to serve an Antigravity/Cloud-Code-Assist turn, so it must
|
|
100
|
+
// ALWAYS identify itself to Google as Antigravity (metadata + User-Agent),
|
|
101
|
+
// regardless of which OAuth STORAGE SLOT the token came from (a dedicated
|
|
102
|
+
// antigravity login vs a plain gemini-cli token used as fallback — the common
|
|
103
|
+
// case for users who only ran `jeo auth login gemini`, the documented default).
|
|
104
|
+
// Previously this branched on `credential.provider === "antigravity"` and sent
|
|
105
|
+
// gemini-cli identity (User-Agent `GeminiCLI/…`, metadata `pluginType: GEMINI`)
|
|
106
|
+
// for the fallback case. Root-caused live: Google's `loadCodeAssist` now REJECTS
|
|
107
|
+
// that identity for gemini-cli-individual accounts it has migrated to Antigravity
|
|
108
|
+
// (`ineligibleTiers[].reasonCode: "UNSUPPORTED_CLIENT"`, message "please migrate
|
|
109
|
+
// to the Antigravity suite of products") — the response omits `currentTier`/
|
|
110
|
+
// `cloudaicompanionProject` entirely, so discovery falls through into onboarding,
|
|
111
|
+
// which then fails after 5 polls with "did not return a provisioned project id"
|
|
112
|
+
// even though the SAME account resolves its EXISTING project instantly (0 polls)
|
|
113
|
+
// once the request honestly identifies as Antigravity. This was the actual
|
|
114
|
+
// observed "stops/hangs on Antigravity Gemini" symptom for any account without a
|
|
115
|
+
// dedicated antigravity OAuth login: a deterministic, fast-failing (not literally
|
|
116
|
+
// infinite) but 100%-reproducible discovery break, not a timeout/network hang.
|
|
117
|
+
return discoverGoogleProjectId(token, {
|
|
118
|
+
metadata: { ...ANTIGRAVITY_DISCOVERY_METADATA },
|
|
119
|
+
extraHeaders: { "User-Agent": getAntigravityUserAgent() },
|
|
120
|
+
alwaysOnboard: true,
|
|
121
|
+
signal: opts.signal,
|
|
122
|
+
});
|
|
103
123
|
});
|
|
104
124
|
let projectId: string;
|
|
105
125
|
try {
|
|
@@ -232,6 +252,17 @@ interface CcaChunk {
|
|
|
232
252
|
candidates?: { content?: { parts?: { text?: string; thought?: boolean; functionCall?: { name?: string; args?: Record<string, unknown> } }[] }; finishReason?: string }[];
|
|
233
253
|
usageMetadata?: CcaUsage;
|
|
234
254
|
};
|
|
255
|
+
/** A `google.rpc.Status`-shaped in-band error line, sibling of `response` — see
|
|
256
|
+
* `gemini.ts`'s identical field for the full rationale (silently swallowed here
|
|
257
|
+
* before this fix, which for a turn that had already streamed some text meant an
|
|
258
|
+
* early, error-free, TRUNCATED stop: "안티그라비티 모델이 중간에 멈추는" report). */
|
|
259
|
+
error?: { code?: number; message?: string; status?: string };
|
|
260
|
+
}
|
|
261
|
+
/** Throw a {@link ProviderStreamError} for an in-band error line — no-op when absent. */
|
|
262
|
+
function throwIfGoogleStreamError(chunk: CcaChunk): void {
|
|
263
|
+
const err = chunk.error;
|
|
264
|
+
if (!err) return;
|
|
265
|
+
throw new ProviderStreamError("Antigravity", err.message ?? "unknown error", err.status, err.code);
|
|
235
266
|
}
|
|
236
267
|
/** When Antigravity (Cloud Code Assist) returns HTTP 200 with no text, surface the
|
|
237
268
|
* real cause (safety block / RECITATION / etc.) instead of a silent, reason-free
|
|
@@ -293,6 +324,7 @@ export const antigravityAdapter: ProviderAdapter = {
|
|
|
293
324
|
for await (const data of readSse(response.body)) {
|
|
294
325
|
let chunk: CcaChunk;
|
|
295
326
|
try { chunk = JSON.parse(data); } catch { continue; }
|
|
327
|
+
throwIfGoogleStreamError(chunk);
|
|
296
328
|
const thought = thoughtOf(chunk);
|
|
297
329
|
if (thought) options.onReasoning?.(thought);
|
|
298
330
|
const delta = textOf(chunk);
|
|
@@ -321,6 +353,7 @@ export const antigravityAdapter: ProviderAdapter = {
|
|
|
321
353
|
for await (const data of readSse(response.body, options.onStreamActivity)) {
|
|
322
354
|
let chunk: CcaChunk;
|
|
323
355
|
try { chunk = JSON.parse(data); } catch { continue; }
|
|
356
|
+
throwIfGoogleStreamError(chunk);
|
|
324
357
|
const thought = thoughtOf(chunk);
|
|
325
358
|
if (thought) options.onReasoning?.(thought);
|
|
326
359
|
const delta = textOf(chunk);
|
|
@@ -39,10 +39,14 @@ export class ProviderStreamError extends Error {
|
|
|
39
39
|
readonly status: number;
|
|
40
40
|
readonly provider: string;
|
|
41
41
|
readonly code?: string;
|
|
42
|
-
|
|
42
|
+
/** `explicitStatus` lets a caller pass an ALREADY-numeric provider status (e.g. Google's
|
|
43
|
+
* `error.code` on a `google.rpc.Status` envelope — 429/500/503) instead of relying on the
|
|
44
|
+
* OpenAI-specific `code === "rate_limit_exceeded"` string heuristic below, which only
|
|
45
|
+
* recognizes that one literal and defaults everything else to 500. */
|
|
46
|
+
constructor(provider: string, message: string, code?: string, explicitStatus?: number) {
|
|
43
47
|
super(`${provider} stream failed${code ? ` (${code})` : ""}: ${message}`);
|
|
44
48
|
this.name = "ProviderStreamError";
|
|
45
|
-
this.status = code === "rate_limit_exceeded" ? 429 : 500;
|
|
49
|
+
this.status = explicitStatus ?? (code === "rate_limit_exceeded" ? 429 : 500);
|
|
46
50
|
this.provider = provider;
|
|
47
51
|
this.code = code;
|
|
48
52
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Credential } from "../../auth";
|
|
2
2
|
import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
3
3
|
import { readSse } from "../sse";
|
|
4
|
-
import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
|
|
4
|
+
import { providerHttpError, fetchWithArtifactFailSafe, ProviderStreamError } from "./errors";
|
|
5
5
|
import { jeoEnv } from "../../util/env";
|
|
6
6
|
import { serializeToolCalls } from "../../agent/tool-schemas";
|
|
7
7
|
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
@@ -258,11 +258,22 @@ interface GeminiChunk {
|
|
|
258
258
|
candidates?: { content?: { parts?: { text?: string; thought?: boolean; thoughtSignature?: string; functionCall?: { name?: string; args?: Record<string, unknown> } }[] }; finishReason?: string }[];
|
|
259
259
|
promptFeedback?: { blockReason?: string };
|
|
260
260
|
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; thoughtsTokenCount?: number };
|
|
261
|
+
/** A `google.rpc.Status`-shaped in-band error line: Google's streaming endpoints can emit
|
|
262
|
+
* this as its own SSE `data:` event on an otherwise-live 200 connection (the codebase
|
|
263
|
+
* already knows this envelope shape from `parseRetryFromBody`'s `"retryDelay"` parsing on
|
|
264
|
+
* the equivalent non-stream error body) instead of terminating the HTTP response with a
|
|
265
|
+
* non-2xx status. Previously silently ignored — neither `candidates` nor `promptFeedback`
|
|
266
|
+
* matches it, so it fell through every existing check with no error and no text, which for
|
|
267
|
+
* a turn that had already streamed SOME text meant the turn just ended early with a
|
|
268
|
+
* truncated reply and no error at all: the "모델이 중간에 멈추는" (model stops mid-way) report. */
|
|
269
|
+
error?: { code?: number; message?: string; status?: string };
|
|
261
270
|
}
|
|
262
271
|
|
|
263
|
-
/** Cloud Code Assist wraps each standard chunk under `response
|
|
272
|
+
/** Cloud Code Assist wraps each standard chunk under `response`; an in-band error (see
|
|
273
|
+
* `GeminiChunk.error`) arrives as a SIBLING of `response`, not nested inside it. */
|
|
264
274
|
interface CcaChunk {
|
|
265
275
|
response?: GeminiChunk;
|
|
276
|
+
error?: { code?: number; message?: string; status?: string };
|
|
266
277
|
}
|
|
267
278
|
|
|
268
279
|
function textOf(chunk: GeminiChunk): string {
|
|
@@ -317,6 +328,20 @@ function blockedReason(chunk: GeminiChunk): string | undefined {
|
|
|
317
328
|
return undefined;
|
|
318
329
|
}
|
|
319
330
|
|
|
331
|
+
/** Throw a {@link ProviderStreamError} for an in-band `google.rpc.Status` error line (see
|
|
332
|
+
* `GeminiChunk.error`/`CcaChunk.error`) — a no-op when the chunk carries none. Mirrors the
|
|
333
|
+
* OpenAI Responses `response.failed`/`error` fix: `error.status` (a string enum, e.g.
|
|
334
|
+
* `"RESOURCE_EXHAUSTED"`/`"UNAVAILABLE"`/`"INTERNAL"`) plays the same "classifier" role our
|
|
335
|
+
* `code` param plays for OpenAI, and `error.code` (Google's own numeric HTTP-equivalent) is
|
|
336
|
+
* passed straight through as the explicit status instead of re-derived from a string match. */
|
|
337
|
+
function throwIfGoogleStreamError(
|
|
338
|
+
provider: string,
|
|
339
|
+
err: { code?: number; message?: string; status?: string } | undefined,
|
|
340
|
+
): void {
|
|
341
|
+
if (!err) return;
|
|
342
|
+
throw new ProviderStreamError(provider, err.message ?? "unknown error", err.status, err.code);
|
|
343
|
+
}
|
|
344
|
+
|
|
320
345
|
/**
|
|
321
346
|
* Cloud Code Assist SSE turn for a Google OAuth credential: resolves the
|
|
322
347
|
* projectId (stored → env → lazy loadCodeAssist/onboardUser discovery), POSTs
|
|
@@ -343,6 +368,7 @@ async function* ccaTurn(messages: Message[], options: CallOptions, credential: C
|
|
|
343
368
|
} catch {
|
|
344
369
|
continue;
|
|
345
370
|
}
|
|
371
|
+
throwIfGoogleStreamError("Gemini (Cloud Code Assist)", chunk.error);
|
|
346
372
|
const inner = chunk.response;
|
|
347
373
|
if (!inner) continue;
|
|
348
374
|
const thought = thoughtOf(inner);
|
|
@@ -419,6 +445,7 @@ export const geminiAdapter: ProviderAdapter = {
|
|
|
419
445
|
} catch {
|
|
420
446
|
continue;
|
|
421
447
|
}
|
|
448
|
+
throwIfGoogleStreamError("Gemini", chunk.error);
|
|
422
449
|
const thought = thoughtOf(chunk);
|
|
423
450
|
if (thought) options.onReasoning?.(thought);
|
|
424
451
|
captureGeminiSignatures(chunk, options, seenSigs);
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
resolveSelection,
|
|
22
22
|
catalogMetadata,
|
|
23
23
|
CODEX_MODELS,
|
|
24
|
+
isCodexModel,
|
|
24
25
|
qualifyModelId,
|
|
25
26
|
} from "../../ai";
|
|
26
27
|
import type { ProviderModelsResult, PickEntry, ProviderName, ThinkLevel } from "../../ai";
|
|
@@ -287,7 +288,10 @@ export async function runModelSlash(input: string, ctx: ModelSlashCtx): Promise<
|
|
|
287
288
|
if (st && !st.ready) console.log(` ! ${provider} is not ready (${st.label}) — set ${st.envVar ?? "the provider key"} or run 'jeo setup'.`);
|
|
288
289
|
// ChatGPT OAuth only serves the Codex models; warn before the turn fails if the user
|
|
289
290
|
// pins a non-Codex id with no local base URL to fall back to (gjc-parity readiness guard).
|
|
290
|
-
|
|
291
|
+
// `isCodexModel` also accepts any id this session's OWN live discovery already
|
|
292
|
+
// confirmed (see model-catalog.ts) — a static-list-only check here would falsely
|
|
293
|
+
// warn about a model /model's own live picker just showed as valid.
|
|
294
|
+
if (arg && provider === "openai" && st?.kind === "oauth" && !isCodexModel(resolved)) {
|
|
291
295
|
const hasLocalBase = !!((await readGlobalConfig()).openaiBaseUrl || process.env.OPENAI_BASE_URL);
|
|
292
296
|
if (!hasLocalBase) {
|
|
293
297
|
console.log(` ! ChatGPT OAuth serves only Codex models (${CODEX_MODELS.join(", ")}); '${resolved}' will be rejected at runtime — pick one of those, or set OPENAI_API_KEY / OPENAI_BASE_URL.`);
|
package/src/commands/launch.ts
CHANGED
|
@@ -48,7 +48,8 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
|
|
|
48
48
|
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
49
49
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
50
50
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
|
|
51
|
-
import { routePrompt, deriveCacheSessionKey, warnOnce, type RouteDecision } from "../agent/prompt-router";
|
|
51
|
+
import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, type RouteDecision } from "../agent/prompt-router";
|
|
52
|
+
import { isRateLimitError, isUsageLimitError } from "../util/retry";
|
|
52
53
|
|
|
53
54
|
import { listAliases } from "../ai/model-registry";
|
|
54
55
|
import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
|
|
@@ -882,6 +883,42 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
882
883
|
// that write silently switch THIS running session's model mid-run. Per-session model
|
|
883
884
|
// selection must stay session-local: running sessions never influence each other.
|
|
884
885
|
const routingEnabled = sessionRouteOverride ?? !!turnConfig.routing?.enabled;
|
|
886
|
+
let routeCredentialNotice: string | undefined;
|
|
887
|
+
let routeVetoNote: string | undefined;
|
|
888
|
+
const equivalentRouteFallback = async (decision: RouteDecision, reason: string, exclude: readonly string[] = []): Promise<string | null> => {
|
|
889
|
+
const excluded = new Set([decision.model, ...exclude]);
|
|
890
|
+
const candidates = tierModelPool(decision.tier, turnConfig)
|
|
891
|
+
.filter(model => !excluded.has(model))
|
|
892
|
+
.filter(model => !images?.length || catalogMetadata(model)?.images !== false);
|
|
893
|
+
if (candidates.length === 0) return null;
|
|
894
|
+
const selected = selectFromPool(candidates, sessionId ? `${sessionId}:fallback:${decision.model}:${reason}` : undefined);
|
|
895
|
+
const start = Math.max(0, candidates.indexOf(selected));
|
|
896
|
+
const ordered = [...candidates.slice(start), ...candidates.slice(0, start)];
|
|
897
|
+
for (const candidate of ordered) {
|
|
898
|
+
const { resolved, provider } = await describeModel(candidate, turnConfig);
|
|
899
|
+
const status = await describeProvider(provider, turnConfig);
|
|
900
|
+
if (!status.ready || !modelServableWithConfig(provider, resolved, turnConfig)) continue;
|
|
901
|
+
if (provider === "ollama" || provider === "lmstudio") {
|
|
902
|
+
const localBaseUrl = provider === "ollama"
|
|
903
|
+
? (turnConfig.ollamaBaseUrl ?? "http://localhost:11434")
|
|
904
|
+
: (turnConfig.lmstudioBaseUrl ?? "http://localhost:1234/v1");
|
|
905
|
+
if (!await isLocalProviderReachable(provider, localBaseUrl)) continue;
|
|
906
|
+
}
|
|
907
|
+
return candidate;
|
|
908
|
+
}
|
|
909
|
+
return null;
|
|
910
|
+
};
|
|
911
|
+
const fallbackDecision = async (decision: RouteDecision, reason: string, noticeKey: string, notice: string): Promise<RouteDecision | null> => {
|
|
912
|
+
const fallback = await equivalentRouteFallback(decision, reason);
|
|
913
|
+
if (!fallback) {
|
|
914
|
+
routeVetoNote = `routed model '${decision.model}' ${reason} — fell back to '${sessionModel || defaultModel}'`;
|
|
915
|
+
routeCredentialNotice = warnOnce(noticeKey, notice);
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
routeVetoNote = `routed model '${decision.model}' ${reason} — switched to equivalent '${fallback}'`;
|
|
919
|
+
routeCredentialNotice = warnOnce(noticeKey, `${notice} Switching to equivalent '${fallback}' for this turn.`);
|
|
920
|
+
return { ...decision, model: fallback, warning: decision.warning ? `${decision.warning}; ${routeVetoNote}` : routeVetoNote };
|
|
921
|
+
};
|
|
885
922
|
let routed = routingEnabled && !sessionModel
|
|
886
923
|
? await routePrompt(userInput, turnConfig, { hasImages: !!images?.length, sessionId }).catch(() => null)
|
|
887
924
|
: null;
|
|
@@ -900,8 +937,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
900
937
|
// the top of prompt-router.ts. Nulling `routed` here (rather than patching its
|
|
901
938
|
// `.model`) lets the EXISTING `activeModel`/`lastRouteDecision` fallback lines
|
|
902
939
|
// below handle the rest unchanged.
|
|
903
|
-
let routeCredentialNotice: string | undefined;
|
|
904
|
-
let routeVetoNote: string | undefined;
|
|
905
940
|
if (routed) {
|
|
906
941
|
// describeModel expands aliases (e.g. a hand-written `roles.high: "gpt"`)
|
|
907
942
|
// exactly like the real call path, so the servability check below judges
|
|
@@ -909,23 +944,23 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
909
944
|
const { resolved: routedResolved, provider: routedProvider } = await describeModel(routed.model, turnConfig);
|
|
910
945
|
const status = await describeProvider(routedProvider, turnConfig);
|
|
911
946
|
if (!status.ready) {
|
|
912
|
-
|
|
913
|
-
|
|
947
|
+
routed = await fallbackDecision(
|
|
948
|
+
routed,
|
|
949
|
+
`(${routedProvider}) has no usable credential this turn`,
|
|
914
950
|
`prompt-router:provider-not-ready:${routedProvider}`,
|
|
915
|
-
`[route] routed to '${routed.model}' (${routedProvider}) but that provider has no usable credential
|
|
951
|
+
`[route] routed to '${routed.model}' (${routedProvider}) but that provider has no usable credential. Run 'jeo auth login ${routedProvider}' or reconfigure routing.tiers/roles for a provider you're logged into.`,
|
|
916
952
|
);
|
|
917
|
-
routed = null;
|
|
918
953
|
} else if (!modelServableWithConfig(routedProvider, routedResolved, turnConfig)) {
|
|
919
954
|
// Provider readiness is necessary but not sufficient: an OAuth login only
|
|
920
955
|
// serves specific models (auth matrix on modelServableWithConfig). Only
|
|
921
956
|
// user-pinned routing.tiers/roles models can hit this branch —
|
|
922
957
|
// auto-selected models already passed the same predicate.
|
|
923
|
-
|
|
924
|
-
|
|
958
|
+
routed = await fallbackDecision(
|
|
959
|
+
routed,
|
|
960
|
+
`(${routedProvider}) is not servable by the stored ${routedProvider} credential this turn`,
|
|
925
961
|
`prompt-router:model-not-servable:${routed.model}`,
|
|
926
|
-
`[route] routed to '${routed.model}' but the stored ${routedProvider} OAuth login cannot serve that model
|
|
962
|
+
`[route] routed to '${routed.model}' but the stored ${routedProvider} OAuth login cannot serve that model. Set ${routedProvider.toUpperCase()}_API_KEY or reconfigure routing.tiers/roles to a model your login serves.`,
|
|
927
963
|
);
|
|
928
|
-
routed = null;
|
|
929
964
|
} else if (routedProvider === "ollama" || routedProvider === "lmstudio") {
|
|
930
965
|
// Live reachability gate for LOCAL providers only: `describeProvider` reports
|
|
931
966
|
// ollama/lmstudio as `ready: true` unconditionally (keyless just means "no
|
|
@@ -945,19 +980,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
945
980
|
: (turnConfig.lmstudioBaseUrl ?? "http://localhost:1234/v1");
|
|
946
981
|
const reachable = await isLocalProviderReachable(routedProvider, localBaseUrl);
|
|
947
982
|
if (!reachable) {
|
|
948
|
-
|
|
949
|
-
|
|
983
|
+
routed = await fallbackDecision(
|
|
984
|
+
routed,
|
|
985
|
+
`(${routedProvider}) is unreachable at ${localBaseUrl} this turn`,
|
|
950
986
|
`prompt-router:provider-unreachable:${routedProvider}`,
|
|
951
|
-
`[route] routed to '${routed.model}' (${routedProvider}) but ${localBaseUrl} is unreachable
|
|
987
|
+
`[route] routed to '${routed.model}' (${routedProvider}) but ${localBaseUrl} is unreachable. Start the ${routedProvider} server, or reconfigure routing.tiers/roles for a provider that is running.`,
|
|
952
988
|
);
|
|
953
|
-
routed = null;
|
|
954
989
|
}
|
|
955
990
|
}
|
|
956
991
|
}
|
|
957
992
|
lastRouteDecision = routed ?? (routeVetoNote ? { note: routeVetoNote } : routingEnabled && !sessionModel ? { note: "routing produced no decision (fail-open)" } : { note: "routing not active this turn" });
|
|
958
993
|
// Explicit /model always wins: `routed` is only ever computed when `!sessionModel`.
|
|
959
|
-
|
|
960
|
-
|
|
994
|
+
let activeModel = routed?.model ?? (sessionModel || defaultModel);
|
|
995
|
+
let activeThinking = routed?.thinking ?? sessionThinking;
|
|
961
996
|
// Provider-side prompt caches are keyed PER MODEL — reusing the bare `sessionId`
|
|
962
997
|
// across a mid-session model switch (routePrompt changing activeModel turn to
|
|
963
998
|
// turn) sends the same cache-correlation key to a different model/provider,
|
|
@@ -967,8 +1002,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
967
1002
|
// `sessionId` is undefined only under `--no-session` — in that case there is no
|
|
968
1003
|
// provider-side cache lineage to protect anyway, so forward `undefined` unchanged
|
|
969
1004
|
// (matches the prior behavior of `sessionKey: sessionId` for that mode).
|
|
970
|
-
|
|
971
|
-
|
|
1005
|
+
let turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
|
|
1006
|
+
let contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
972
1007
|
|
|
973
1008
|
// Resolve provider + dirty count up front — both are cheap and feed the live
|
|
974
1009
|
// frame's footer. `turnConfig` is reused so describeModel does NOT re-read the
|
|
@@ -1273,18 +1308,47 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1273
1308
|
tags: ["jeo", "launch"],
|
|
1274
1309
|
});
|
|
1275
1310
|
opik.startTurn();
|
|
1276
|
-
|
|
1311
|
+
const runLoopWithModel = (model: string, thinking: ThinkLevel | undefined, cacheKey: string | undefined, maxSteps = flags.maxSteps, budget?: { maxExtensions: number }) => runAgentLoop(history, {
|
|
1277
1312
|
cwd,
|
|
1278
1313
|
tools,
|
|
1279
|
-
maxSteps
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1314
|
+
maxSteps,
|
|
1315
|
+
...(budget ? { budget } : {}),
|
|
1316
|
+
model,
|
|
1317
|
+
maxTokens: resolveMaxOutputTokens(model, thinking),
|
|
1318
|
+
reasoningEffort: thinking ? thinkingToReasoningEffort(thinking) : undefined,
|
|
1283
1319
|
signal: ac.signal,
|
|
1284
|
-
sessionKey:
|
|
1320
|
+
sessionKey: cacheKey,
|
|
1285
1321
|
steer: drainSteer,
|
|
1286
1322
|
events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
|
|
1287
1323
|
});
|
|
1324
|
+
const routeFailureReason = (doneReason: unknown): string | null => {
|
|
1325
|
+
const message = String(doneReason ?? "");
|
|
1326
|
+
if (!message) return null;
|
|
1327
|
+
const asError = new Error(message);
|
|
1328
|
+
if (isRateLimitError(asError)) return "hit a rate limit";
|
|
1329
|
+
if (isUsageLimitError(asError)) return "hit a usage/quota limit";
|
|
1330
|
+
if (/does not recognize the requested model|model .*?(?:not found|not available|unavailable|unsupported|retired|gated)|\b(?:400|404)\b/i.test(message)) {
|
|
1331
|
+
return "is unavailable";
|
|
1332
|
+
}
|
|
1333
|
+
return null;
|
|
1334
|
+
};
|
|
1335
|
+
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1336
|
+
const routedFailureReason = routed && !result.done ? routeFailureReason(result.doneReason) : null;
|
|
1337
|
+
if (routed && routedFailureReason) {
|
|
1338
|
+
const fallback = await equivalentRouteFallback(routed, routedFailureReason, [activeModel]);
|
|
1339
|
+
if (fallback) {
|
|
1340
|
+
const previousModel = activeModel;
|
|
1341
|
+
activeModel = fallback;
|
|
1342
|
+
activeThinking = routed.thinking ?? sessionThinking;
|
|
1343
|
+
turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
|
|
1344
|
+
contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
1345
|
+
lastRouteDecision = { ...routed, model: activeModel, warning: `routed model '${previousModel}' ${routedFailureReason}; switched to equivalent '${activeModel}'` };
|
|
1346
|
+
const notice = `[route] routed model '${previousModel}' ${routedFailureReason}; switching to equivalent '${activeModel}' for this turn.`;
|
|
1347
|
+
if (tui) tui.events().onNotice?.(notice);
|
|
1348
|
+
else console.log(notice);
|
|
1349
|
+
result = await runLoopWithModel(activeModel, activeThinking, turnSessionKey);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1288
1352
|
if (result.done && looksLikeSkillEcho(result.doneReason ?? "", resolvedSkills)) {
|
|
1289
1353
|
history.push({
|
|
1290
1354
|
role: "user",
|
package/src/skills/catalog.ts
CHANGED
|
@@ -188,6 +188,7 @@ export function skillsPromptSection(skills: SkillDoc[] = SKILLS): string {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
import * as fs from "node:fs/promises";
|
|
191
|
+
import type { Dirent } from "node:fs";
|
|
191
192
|
import * as path from "node:path";
|
|
192
193
|
import * as os from "node:os";
|
|
193
194
|
|
|
@@ -411,6 +412,54 @@ function isSupportedExternalSkill(doc: SkillDoc): boolean {
|
|
|
411
412
|
return !RESERVED_SKILL_NAMES.has(nameLower);
|
|
412
413
|
}
|
|
413
414
|
|
|
415
|
+
/** Resolve ONE directory entry from {@link skillDirs} to its `SkillDoc`, or `null`
|
|
416
|
+
* when the entry is not a skill (dotfile, non-`.md` file, directory with no
|
|
417
|
+
* `SKILL.md`, unreadable/broken symlink). Pure I/O + parse — no shared-state
|
|
418
|
+
* writes — so callers can run many of these concurrently. */
|
|
419
|
+
async function resolveSkillEntry(dir: string, entry: Dirent): Promise<SkillDoc | null> {
|
|
420
|
+
if (entry.name.startsWith(".")) return null;
|
|
421
|
+
let isFile = entry.isFile();
|
|
422
|
+
let isDir = entry.isDirectory();
|
|
423
|
+
// Vercel `npx skills add` links agent dirs (.claude/skills/x) to its canonical
|
|
424
|
+
// store — a symlinked skill reports NEITHER isFile nor isDirectory on the
|
|
425
|
+
// Dirent, which silently dropped every linked skill. Follow the link.
|
|
426
|
+
if (entry.isSymbolicLink()) {
|
|
427
|
+
try {
|
|
428
|
+
const st = await fs.stat(path.join(dir, entry.name));
|
|
429
|
+
isFile = st.isFile();
|
|
430
|
+
isDir = st.isDirectory();
|
|
431
|
+
} catch { return null; /* broken symlink */ }
|
|
432
|
+
}
|
|
433
|
+
if (isFile && entry.name.endsWith(".md")) {
|
|
434
|
+
const nm = entry.name.slice(0, -3);
|
|
435
|
+
const filePath = path.join(dir, entry.name);
|
|
436
|
+
try {
|
|
437
|
+
const parsed = parseSkillMarkdown(nm, await fs.readFile(filePath, "utf-8"), { preferMetaName: true });
|
|
438
|
+
parsed.sourcePath = filePath;
|
|
439
|
+
return isSupportedExternalSkill(parsed) ? parsed : null;
|
|
440
|
+
} catch { return null; /* unreadable file */ }
|
|
441
|
+
}
|
|
442
|
+
if (isDir) {
|
|
443
|
+
const skillPath = path.join(dir, entry.name, "SKILL.md");
|
|
444
|
+
try {
|
|
445
|
+
const parsed = parseSkillMarkdown(entry.name, await fs.readFile(skillPath, "utf-8"), { preferMetaName: true });
|
|
446
|
+
parsed.sourcePath = skillPath;
|
|
447
|
+
return isSupportedExternalSkill(parsed) ? parsed : null;
|
|
448
|
+
} catch { return null; /* dir without SKILL.md, or unreadable */ }
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Concurrency cap for {@link resolveSkillEntry} within one directory: high enough
|
|
454
|
+
* that a large skill directory (hundreds of entries — a real field case: `npx skills
|
|
455
|
+
* add`-style installs commonly accumulate 500+) resolves in one or two batches
|
|
456
|
+
* instead of paying per-file `fs` round-trip latency SEQUENTIALLY (534 entries at
|
|
457
|
+
* ~0.4ms/syscall was a measured ~220ms — a visible stall on every session start and
|
|
458
|
+
* every `/skill` listing), while staying well under the lowest realistic per-process
|
|
459
|
+
* file-descriptor ulimit (macOS defaults to 256) even with several directories'
|
|
460
|
+
* batches racing across the `skillDirs` loop below. */
|
|
461
|
+
const SKILL_ENTRY_LOAD_BATCH = 32;
|
|
462
|
+
|
|
414
463
|
/** Bundled skills merged with user skill docs from {@link skillDirs} (user overrides by name). */
|
|
415
464
|
export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[]> {
|
|
416
465
|
try {
|
|
@@ -424,39 +473,20 @@ export async function loadSkills(cwd: string = process.cwd()): Promise<SkillDoc[
|
|
|
424
473
|
}
|
|
425
474
|
} catch {}
|
|
426
475
|
const byName = new Map<string, SkillDoc>(SKILLS.map(s => [s.name.toLowerCase(), s]));
|
|
476
|
+
// Directories are walked in their documented lowest→highest precedence order
|
|
477
|
+
// (a later dir's skill overrides an earlier one with the same name) — that
|
|
478
|
+
// cross-directory override MUST stay sequential. Entries WITHIN one directory
|
|
479
|
+
// have no such contract (readdir order is not a precedence guarantee anyone
|
|
480
|
+
// depends on), so they resolve concurrently in bounded batches, then apply to
|
|
481
|
+
// `byName` in their original readdir order for a fully deterministic result.
|
|
427
482
|
for (const dir of skillDirs(cwd)) {
|
|
428
|
-
let entries:
|
|
483
|
+
let entries: Dirent[] = [];
|
|
429
484
|
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { continue; }
|
|
430
|
-
for (
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
// store — a symlinked skill reports NEITHER isFile nor isDirectory on the
|
|
436
|
-
// Dirent, which silently dropped every linked skill. Follow the link.
|
|
437
|
-
if (entry.isSymbolicLink()) {
|
|
438
|
-
try {
|
|
439
|
-
const st = await fs.stat(path.join(dir, entry.name));
|
|
440
|
-
isFile = st.isFile();
|
|
441
|
-
isDir = st.isDirectory();
|
|
442
|
-
} catch { continue; /* broken symlink */ }
|
|
443
|
-
}
|
|
444
|
-
if (isFile && entry.name.endsWith(".md")) {
|
|
445
|
-
const nm = entry.name.slice(0, -3);
|
|
446
|
-
try {
|
|
447
|
-
const parsed = parseSkillMarkdown(nm, await fs.readFile(path.join(dir, entry.name), "utf-8"), { preferMetaName: true });
|
|
448
|
-
parsed.sourcePath = path.join(dir, entry.name);
|
|
449
|
-
if (isSupportedExternalSkill(parsed)) byName.set(parsed.name.toLowerCase(), parsed);
|
|
450
|
-
} catch { /* skip unreadable file */ }
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
if (isDir) {
|
|
454
|
-
const skillPath = path.join(dir, entry.name, "SKILL.md");
|
|
455
|
-
try {
|
|
456
|
-
const parsed = parseSkillMarkdown(entry.name, await fs.readFile(skillPath, "utf-8"), { preferMetaName: true });
|
|
457
|
-
parsed.sourcePath = skillPath;
|
|
458
|
-
if (isSupportedExternalSkill(parsed)) byName.set(parsed.name.toLowerCase(), parsed);
|
|
459
|
-
} catch { /* skip dirs without SKILL.md or unreadable files */ }
|
|
485
|
+
for (let i = 0; i < entries.length; i += SKILL_ENTRY_LOAD_BATCH) {
|
|
486
|
+
const batch = entries.slice(i, i + SKILL_ENTRY_LOAD_BATCH);
|
|
487
|
+
const resolved = await Promise.all(batch.map(entry => resolveSkillEntry(dir, entry)));
|
|
488
|
+
for (const parsed of resolved) {
|
|
489
|
+
if (parsed) byName.set(parsed.name.toLowerCase(), parsed);
|
|
460
490
|
}
|
|
461
491
|
}
|
|
462
492
|
}
|