jeo-code 0.7.3 → 0.7.5
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 +26 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/context-files.ts +34 -1
- package/src/agent/engine.ts +4 -1
- package/src/agent/memory.ts +61 -3
- package/src/autopilot.ts +16 -1
- package/src/cli.ts +1 -4
- package/src/commands/launch/code-slash.ts +99 -0
- package/src/commands/launch/git-slash.ts +30 -0
- package/src/commands/launch/slash-views.ts +20 -0
- package/src/commands/launch.ts +47 -96
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ 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.7.5] - 2026-06-23
|
|
10
|
+
_Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs._
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Project-context guidance files are cached by `mtimeMs:size` signature instead of re-read every spawn.** `readContextFile` (in `src/agent/context-files.ts`) now keeps a 256-entry LRU of raw file text keyed by a stat signature. The cheap `fs.stat` still runs every call, so an edit (new mtime/size) or deletion is caught immediately; truncation is reapplied per call from the raw text, so a differing budget never pollutes the cache. `loadProjectContext` runs once per subagent spawn (team/ralph/autopilot), so this removes the bulk of repeated AGENTS.md/guidance reads.
|
|
14
|
+
- **OKF memory concepts are parse-cached per file under the same stat-signature scheme.** `loadConcepts` (in `src/agent/memory.ts`) walks `.jeo/memory/` and parses every concept file; the new 256-entry cache skips the read+parse for unchanged files. The directory walk and per-file `stat` still run (new files appear, deleted files drop out), and every write path (`migrateLegacyMemory`, both `distillSessionMemory` branches) calls `invalidateConceptCache()` so a freshly written concept is never served stale.
|
|
15
|
+
- **Native tool schemas are derived once per turn, not rebuilt every step.** `runAgentLoop` (in `src/agent/engine.ts`) hoists `nativeToolSchemasFor(...)` out of the step loop since the active toolset is constant for the whole turn, eliminating a per-step list rebuild/reallocation.
|
|
16
|
+
- **Autopilot keeps the running `best` score in memory across iterations.** `cmdLoop` (in `src/autopilot.ts`) seeds `best` from `currentBest(s)` once and folds each kept step forward via the new exported `foldBest(goal, best, score)` helper, instead of re-reading and re-parsing the entire append-only log on every step. `foldBest` mirrors `currentBest`'s reduction exactly (NaN never becomes best; `min`/`max`/last-value semantics by goal) so the in-memory value can never diverge from a fresh log re-scan.
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
- `bun run typecheck` clean; the touched suites pass — `test/context-files.test.ts`, `test/memory-distill-okf.test.ts`, and `test/autopilot-engine.test.ts` (43 pass / 0 fail) exercise cache hits, post-edit/post-delete invalidation, and `foldBest`/`currentBest` parity.
|
|
20
|
+
|
|
21
|
+
## [0.7.4] - 2026-06-22
|
|
22
|
+
_Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop._
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Per-session model selection no longer leaks between concurrent sessions.** The per-turn model now falls back to the model resolved at THIS session's start (a frozen snapshot) instead of re-reading the live global `defaultModel`. Previously a second `jeo` session running `/model` persisted the global default (`rememberModelPatch`), and the first session's next turn would silently pick up that write mid-run. Running sessions are now model-isolated — they never influence each other.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **Read-only code-inspection slash handlers are extracted into a pure module.** `/view`, `/diff`, `/find`, and `/search` move to `src/commands/launch/code-slash.ts` as self-contained async functions that take the raw input + cwd (and theme, for `/diff`) and return the lines to print — no closure-bound side effects. The REPL dispatch is now a thin `for (const line of await handle…) console.log(line)` adapter.
|
|
29
|
+
- **The `/undo` git slash and `/history` view are extracted for direct testing.** `handleUndoSlash` (in `src/commands/launch/git-slash.ts`) keeps its guard rail — it refuses any commit lacking the `[jeo] auto-commit:` prefix and any non-git tree — verifiable against a throwaway repo. `historyViewLines` (in `src/commands/launch/slash-views.ts`) makes the `/history [N|all]` banner/separator math and turn-count parsing pure over the in-memory transcript + terminal width.
|
|
30
|
+
|
|
31
|
+
### Verified
|
|
32
|
+
- `bun run typecheck` clean; full suite **1848 pass / 0 fail** (225 files), including the new `test/launch-code-slash.test.ts`, `test/launch-git-slash.test.ts`, and `test/session-model-isolation.test.ts`, plus expanded `test/launch-slash-views.test.ts` (`/history`) and `test/tui-welcome.test.ts` (banner width tracks cols across a resize).
|
|
33
|
+
|
|
34
|
+
|
|
9
35
|
## [0.7.3] - 2026-06-22
|
|
10
36
|
_Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback._
|
|
11
37
|
|
package/README.ja.md
CHANGED
|
@@ -193,11 +193,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
193
193
|
## 変更履歴 (Changelog)
|
|
194
194
|
|
|
195
195
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
196
|
+
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
197
|
+
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
196
198
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
197
199
|
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
198
200
|
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
199
|
-
- **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
|
-
- **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -193,11 +193,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
193
193
|
## 변경 이력 (Changelog)
|
|
194
194
|
|
|
195
195
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
196
|
+
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
197
|
+
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
196
198
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
197
199
|
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
198
200
|
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
199
|
-
- **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
|
-
- **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -195,11 +195,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
195
195
|
## Changelog
|
|
196
196
|
|
|
197
197
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
198
|
+
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
199
|
+
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
198
200
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
199
201
|
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
202
|
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
|
-
- **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
202
|
-
- **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
203
203
|
|
|
204
204
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
205
205
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -193,11 +193,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
193
193
|
## 更新日志 (Changelog)
|
|
194
194
|
|
|
195
195
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
196
|
+
- **[0.7.5]** (2026-06-23) — Startup & loop latency: redundant re-reads that fired on every subagent spawn and every loop step are now memoized behind cheap `stat`-signature caches, so cold paths stay correct (edits/deletes still picked up immediately) while warm paths skip the disk and the re-parse. Targets the per-turn and per-spawn overhead that dominates perceived slowness in team/ralph/autopilot fan-outs.
|
|
197
|
+
- **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
|
|
196
198
|
- **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
|
|
197
199
|
- **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
198
200
|
- **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
|
|
199
|
-
- **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
200
|
-
- **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
|
|
201
201
|
|
|
202
202
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
203
203
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
|
@@ -26,12 +26,36 @@ const GUIDANCE_CONTEXT_CHARS = TOTAL_CONTEXT_CHARS - BASE_CONTEXT_CHARS;
|
|
|
26
26
|
const MAX_AGENT_GUIDANCE_FILES = 20;
|
|
27
27
|
const AGENT_GUIDANCE_EXTENSIONS = new Set([".md", ".json", ".jsonc", ".yaml", ".yml", ".toml"]);
|
|
28
28
|
|
|
29
|
+
// Per-file RAW content cache: absolute path → mtimeMs:size signature + raw text.
|
|
30
|
+
// loadProjectContext runs once per subagent spawn (team/ralph/autopilot); this skips
|
|
31
|
+
// re-reading every unchanged AGENTS.md / guidance file. The cheap fs.stat still runs
|
|
32
|
+
// each call, so an edit (new mtime/size) or deletion is caught immediately. Truncation
|
|
33
|
+
// is applied per call from the raw text, so a differing budget never pollutes the cache.
|
|
34
|
+
const fileContentCache = new Map<string, { sig: string; content: string }>();
|
|
35
|
+
const FILE_CONTENT_CACHE_CAP = 256;
|
|
36
|
+
|
|
29
37
|
async function readContextFile(filePath: string, displayPath: string, remainingChars: number): Promise<ProjectContextFile | null> {
|
|
30
38
|
if (remainingChars <= 0) return null;
|
|
31
39
|
try {
|
|
32
40
|
const stat = await fs.stat(filePath);
|
|
33
41
|
if (!stat.isFile() || stat.size <= 0) return null;
|
|
34
|
-
const
|
|
42
|
+
const sig = `${stat.mtimeMs}:${stat.size}`;
|
|
43
|
+
let content: string;
|
|
44
|
+
const cached = fileContentCache.get(filePath);
|
|
45
|
+
if (cached && cached.sig === sig) {
|
|
46
|
+
// LRU refresh so a hot file is evicted last.
|
|
47
|
+
fileContentCache.delete(filePath);
|
|
48
|
+
fileContentCache.set(filePath, cached);
|
|
49
|
+
content = cached.content;
|
|
50
|
+
} else {
|
|
51
|
+
content = await fs.readFile(filePath, "utf-8");
|
|
52
|
+
// Evict the oldest entry (Map preserves insertion order) once at capacity.
|
|
53
|
+
if (fileContentCache.size >= FILE_CONTENT_CACHE_CAP) {
|
|
54
|
+
const oldest = fileContentCache.keys().next().value;
|
|
55
|
+
if (oldest !== undefined) fileContentCache.delete(oldest);
|
|
56
|
+
}
|
|
57
|
+
fileContentCache.set(filePath, { sig, content });
|
|
58
|
+
}
|
|
35
59
|
if (content.length <= 0) return null;
|
|
36
60
|
const cap = Math.min(PER_CONTEXT_FILE_CHARS, remainingChars);
|
|
37
61
|
const finalContent = content.length > cap ? content.slice(0, cap) + "\n…(truncated)" : content;
|
|
@@ -83,6 +107,7 @@ const EXPLICIT_GUIDANCE_FILES = [".agents/oma-config.yaml", ".agents/oma-config.
|
|
|
83
107
|
const workspaceScanCache = new Map<string, WorkspaceScan>();
|
|
84
108
|
const WORKSPACE_SCAN_CACHE_CAP = 32;
|
|
85
109
|
|
|
110
|
+
|
|
86
111
|
function segsEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
87
112
|
if (a.length !== b.length) return false;
|
|
88
113
|
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
@@ -188,8 +213,12 @@ async function getWorkspaceScan(cwd: string): Promise<WorkspaceScan> {
|
|
|
188
213
|
export function invalidateWorkspaceScan(cwd?: string): void {
|
|
189
214
|
if (cwd === undefined) {
|
|
190
215
|
workspaceScanCache.clear();
|
|
216
|
+
fileContentCache.clear();
|
|
191
217
|
return;
|
|
192
218
|
}
|
|
219
|
+
// Per-cwd: drop just the downward scan. The per-file content cache is mtime/size
|
|
220
|
+
// self-invalidating, so it needs no per-cwd eviction (a path's stale entry is
|
|
221
|
+
// replaced on the next read once its mtime/size changes).
|
|
193
222
|
workspaceScanCache.delete(path.resolve(cwd));
|
|
194
223
|
}
|
|
195
224
|
|
|
@@ -216,6 +245,10 @@ export async function discoverAgentGuidanceFiles(cwd = process.cwd()): Promise<A
|
|
|
216
245
|
}
|
|
217
246
|
|
|
218
247
|
|
|
248
|
+
// No assembled-result cache: the parent walk + downward scan (cached by workspaceScanCache)
|
|
249
|
+
// are cheap, and readContextFile skips re-reading unchanged file CONTENTS via its mtime/size
|
|
250
|
+
// cache. This keeps the result disk-truthful — a guidance/AGENTS.md edit is reflected on the
|
|
251
|
+
// next spawn — while still avoiding the dominant per-spawn cost (re-reading every file).
|
|
219
252
|
export async function loadProjectContext(cwd = process.cwd()): Promise<ProjectContextFile[]> {
|
|
220
253
|
const result: ProjectContextFile[] = [];
|
|
221
254
|
let baseChars = 0;
|
package/src/agent/engine.ts
CHANGED
|
@@ -450,6 +450,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
450
450
|
// model's text as the final answer instead of burning the whole step budget.
|
|
451
451
|
const MAX_PARSE_BOUNCES = GUARD_LIMITS.MAX_PARSE_BOUNCES;
|
|
452
452
|
let parseFailures = 0;
|
|
453
|
+
// The active toolset is constant for the whole turn, so derive the native tool
|
|
454
|
+
// schemas ONCE instead of rebuilding/reallocating the list on every step.
|
|
455
|
+
const turnToolSchemas = nativeToolSchemasFor(Object.keys(tools));
|
|
453
456
|
while (true) {
|
|
454
457
|
if (turnBudgetMs > 0 && Date.now() - turnStartedAt > turnBudgetMs) {
|
|
455
458
|
stopKind = "time";
|
|
@@ -536,7 +539,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
536
539
|
// use these and re-serialize the structured call to canonical JSON; the
|
|
537
540
|
// antigravity/ollama fallback ignores them. Only on the main step — never
|
|
538
541
|
// the prose wrap-up call below.
|
|
539
|
-
tools:
|
|
542
|
+
tools: turnToolSchemas,
|
|
540
543
|
model: opts.model,
|
|
541
544
|
maxTokens: opts.maxTokens,
|
|
542
545
|
reasoningEffort: opts.reasoningEffort,
|
package/src/agent/memory.ts
CHANGED
|
@@ -176,6 +176,7 @@ export async function migrateLegacyMemory(cwd: string): Promise<MigrationResult>
|
|
|
176
176
|
// Preserve the legacy doc as a rollback backup, off the active read path.
|
|
177
177
|
const backupPath = `${memoryFilePath(cwd)}.bak`;
|
|
178
178
|
await fs.rename(memoryFilePath(cwd), backupPath).catch(() => {});
|
|
179
|
+
invalidateConceptCache();
|
|
179
180
|
return { migrated: true, conceptCount: written.length, backupPath };
|
|
180
181
|
}
|
|
181
182
|
|
|
@@ -205,6 +206,25 @@ export interface Concept {
|
|
|
205
206
|
relPath: string;
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
/** Per-file parse cache keyed by absolute path → (mtimeMs+size signature, parsed
|
|
210
|
+
* Concept or null for a frontmatter-less/unparseable file). loadConcepts walks
|
|
211
|
+
* `.jeo/memory/` and read+parses every concept file; team/ralph/autopilot call
|
|
212
|
+
* memoryPromptSection (→ loadConcepts) once per subagent spawn, re-paying that
|
|
213
|
+
* read+parse each time. The cheap directory walk + per-file stat still run (so new
|
|
214
|
+
* files appear and deleted files drop out immediately — disk is the source of
|
|
215
|
+
* truth), but an UNCHANGED file (same mtime+size) skips the read+parse. A changed
|
|
216
|
+
* file's signature differs, so edits are picked up without explicit invalidation. */
|
|
217
|
+
interface ParsedConceptEntry { sig: string; concept: Concept | null }
|
|
218
|
+
const parsedConceptCache = new Map<string, ParsedConceptEntry>();
|
|
219
|
+
const PARSED_CONCEPT_CACHE_CAP = 512;
|
|
220
|
+
|
|
221
|
+
/** Drop the per-file concept parse cache so the next loadConcepts re-reads disk.
|
|
222
|
+
* The cache is already mtime/size self-invalidating; this is a hard reset for
|
|
223
|
+
* tests and callers that mutate files without changing their stat signature. */
|
|
224
|
+
export function invalidateConceptCache(): void {
|
|
225
|
+
parsedConceptCache.clear();
|
|
226
|
+
}
|
|
227
|
+
|
|
208
228
|
/** Read every concept document in the bundle into structured `Concept`s. Reserved
|
|
209
229
|
* files (index.md/log.md) and raw/ payloads are skipped; unparseable or
|
|
210
230
|
* frontmatter-less files are ignored (lenient consumption). */
|
|
@@ -226,15 +246,38 @@ async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
|
|
|
226
246
|
for (const file of files) {
|
|
227
247
|
const relPath = path.relative(bundleDir, file).replace(/\\/g, "/");
|
|
228
248
|
if (isReservedFile(relPath)) continue;
|
|
249
|
+
|
|
250
|
+
// Skip the read+parse when the file is byte-for-byte unchanged since we last
|
|
251
|
+
// saw it (same mtime+size). A missing stat falls through to a fresh read.
|
|
252
|
+
let sig: string | null = null;
|
|
253
|
+
try {
|
|
254
|
+
const st = await fs.stat(file);
|
|
255
|
+
sig = `${st.mtimeMs}:${st.size}`;
|
|
256
|
+
} catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const cached = parsedConceptCache.get(file);
|
|
260
|
+
if (cached && cached.sig === sig) {
|
|
261
|
+
// LRU refresh so a hot file is evicted last.
|
|
262
|
+
parsedConceptCache.delete(file);
|
|
263
|
+
parsedConceptCache.set(file, cached);
|
|
264
|
+
if (cached.concept) concepts.push(cached.concept);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
229
268
|
let parsed;
|
|
230
269
|
try {
|
|
231
270
|
parsed = parseConcept(await fs.readFile(file, "utf-8"));
|
|
232
271
|
} catch {
|
|
272
|
+
cacheParsedConcept(file, sig, null);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!parsed.hasFrontmatter) {
|
|
276
|
+
cacheParsedConcept(file, sig, null);
|
|
233
277
|
continue;
|
|
234
278
|
}
|
|
235
|
-
if (!parsed.hasFrontmatter) continue;
|
|
236
279
|
const fm = parsed.frontmatter;
|
|
237
|
-
|
|
280
|
+
const concept: Concept = {
|
|
238
281
|
type: (fm.type as string) || "RepoFact",
|
|
239
282
|
title: (fm.title as string) || path.basename(file, ".md"),
|
|
240
283
|
description: (fm.description as string) || "",
|
|
@@ -242,11 +285,24 @@ async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
|
|
|
242
285
|
tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
|
|
243
286
|
confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
|
|
244
287
|
relPath,
|
|
245
|
-
}
|
|
288
|
+
};
|
|
289
|
+
cacheParsedConcept(file, sig, concept);
|
|
290
|
+
concepts.push(concept);
|
|
246
291
|
}
|
|
247
292
|
return concepts;
|
|
248
293
|
}
|
|
249
294
|
|
|
295
|
+
/** Store a per-file parse result under its stat signature, evicting the oldest
|
|
296
|
+
* entry once at capacity (Map preserves insertion order → FIFO/LRU). */
|
|
297
|
+
function cacheParsedConcept(file: string, sig: string, concept: Concept | null): void {
|
|
298
|
+
parsedConceptCache.delete(file);
|
|
299
|
+
if (parsedConceptCache.size >= PARSED_CONCEPT_CACHE_CAP) {
|
|
300
|
+
const oldest = parsedConceptCache.keys().next().value;
|
|
301
|
+
if (oldest !== undefined) parsedConceptCache.delete(oldest);
|
|
302
|
+
}
|
|
303
|
+
parsedConceptCache.set(file, { sig, concept });
|
|
304
|
+
}
|
|
305
|
+
|
|
250
306
|
/** Tokenize a free-text query into distinct lowercased keywords (len ≥ 3). */
|
|
251
307
|
function tokenize(query?: string): string[] {
|
|
252
308
|
if (!query) return [];
|
|
@@ -735,6 +791,7 @@ export async function distillSessionMemory(
|
|
|
735
791
|
await fs.rename(memoryFilePath(cwd), `${memoryFilePath(cwd)}.bak`).catch(() => {});
|
|
736
792
|
}
|
|
737
793
|
await cleanupStalePendingFiles(bundleDir);
|
|
794
|
+
invalidateConceptCache();
|
|
738
795
|
return { updated: true };
|
|
739
796
|
} else {
|
|
740
797
|
// JSON extraction failed (text-only models often wrap or drop the JSON).
|
|
@@ -754,6 +811,7 @@ export async function distillSessionMemory(
|
|
|
754
811
|
const tmp = `${file}.tmp-${process.pid}`;
|
|
755
812
|
await fs.writeFile(tmp, doc + "\n", "utf-8");
|
|
756
813
|
await fs.rename(tmp, file);
|
|
814
|
+
invalidateConceptCache();
|
|
757
815
|
return { updated: true };
|
|
758
816
|
}
|
|
759
817
|
} catch (err: any) {
|
package/src/autopilot.ts
CHANGED
|
@@ -142,6 +142,17 @@ function isImprovement(goal: Goal, score: number, best: number | undefined): boo
|
|
|
142
142
|
return true; // gate handled via passed, not score
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** Fold one KEPT step's score into the running best, mirroring currentBest's
|
|
146
|
+
* reduction so an in-memory best (loop hot path) never diverges from a fresh
|
|
147
|
+
* log re-scan. NaN scores never become the best; gate tracks the last value. */
|
|
148
|
+
export function foldBest(goal: Goal, best: number | undefined, score: number): number | undefined {
|
|
149
|
+
if (Number.isNaN(score)) return best;
|
|
150
|
+
if (best === undefined) return score;
|
|
151
|
+
if (goal === "min") return Math.min(best, score);
|
|
152
|
+
if (goal === "max") return Math.max(best, score);
|
|
153
|
+
return score;
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
/**
|
|
146
157
|
* Single source of truth for the ratchet keep/revert decision. Shared by step,
|
|
147
158
|
* loop, and status so they can never diverge.
|
|
@@ -246,6 +257,10 @@ function cmdLoop(flags: Record<string, string>): void {
|
|
|
246
257
|
}
|
|
247
258
|
|
|
248
259
|
let sinceImprove = 0;
|
|
260
|
+
// Keep `best` in memory across iterations instead of re-reading and re-parsing
|
|
261
|
+
// the whole append-only log on every step (currentBest scans LOG each call).
|
|
262
|
+
// Folded forward on each kept step to match a fresh currentBest() exactly.
|
|
263
|
+
let best = currentBest(s);
|
|
249
264
|
for (let i = 1; i <= max; i++) {
|
|
250
265
|
// mutate: runner makes exactly one change
|
|
251
266
|
let runnerOk = true;
|
|
@@ -260,7 +275,6 @@ function cmdLoop(flags: Record<string, string>): void {
|
|
|
260
275
|
return;
|
|
261
276
|
}
|
|
262
277
|
|
|
263
|
-
const best = currentBest(s);
|
|
264
278
|
const { score, passed, output } = runEval(s);
|
|
265
279
|
const decision = decideStep(s.goal, score, passed, best);
|
|
266
280
|
|
|
@@ -274,6 +288,7 @@ function cmdLoop(flags: Record<string, string>): void {
|
|
|
274
288
|
appendLog({ type: "step", iteration: i, change: `loop#${i}`, score, passed, decision, prevBest: best ?? null, output });
|
|
275
289
|
// A keep is forward progress (min/max: provably an improvement; gate: a pass).
|
|
276
290
|
// Anything else extends the no-progress streak toward convergence.
|
|
291
|
+
if (decision === "keep") best = foldBest(s.goal, best, score);
|
|
277
292
|
sinceImprove = decision === "keep" ? 0 : sinceImprove + 1;
|
|
278
293
|
console.log(`jeo autopilot: loop ${i}/${max} ${decision.toUpperCase()} score=${fmt(score)} (sinceImprove=${sinceImprove})`);
|
|
279
294
|
|
package/src/cli.ts
CHANGED
|
@@ -35,8 +35,5 @@ try {
|
|
|
35
35
|
const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
|
|
36
36
|
if (code !== 0) process.exit(code);
|
|
37
37
|
} catch (err) {
|
|
38
|
-
|
|
39
|
-
restoreTerminalState();
|
|
40
|
-
process.stderr.write(`error: ${(err as Error)?.message ?? String(err)}\n`);
|
|
41
|
-
process.exit(1);
|
|
38
|
+
fatal(err);
|
|
42
39
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only code-inspection slash handlers extracted from launch.ts.
|
|
3
|
+
*
|
|
4
|
+
* Each handler is a self-contained async function that takes the raw slash
|
|
5
|
+
* input plus the working directory (and, for `/diff`, the active theme) and
|
|
6
|
+
* returns the lines to print. They perform no closure-bound side effects, so
|
|
7
|
+
* the REPL dispatch just prints whatever lines come back — matching the
|
|
8
|
+
* `{ lines }` contract used by the other extracted slash handlers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
import { findTool, searchTool } from "../../agent/tools";
|
|
15
|
+
import {
|
|
16
|
+
detectLanguage,
|
|
17
|
+
languageLabel,
|
|
18
|
+
parseLineRange,
|
|
19
|
+
sliceLines,
|
|
20
|
+
formatCodeBlock,
|
|
21
|
+
formatDiff,
|
|
22
|
+
} from "../../tui/components/code-view";
|
|
23
|
+
import { categoryBadge } from "../../tui/components/category-index";
|
|
24
|
+
import { size as terminalSize } from "../../tui/terminal";
|
|
25
|
+
import type { EvolutionTheme } from "../../tui/components/themes";
|
|
26
|
+
|
|
27
|
+
/** `/view <file> [start-end]` — render a file (optionally a line range) as a code block. */
|
|
28
|
+
export async function handleViewSlash(input: string, cwd: string): Promise<string[]> {
|
|
29
|
+
const tokens = input.substring(5).trim().split(/\s+/).filter(Boolean);
|
|
30
|
+
const file = tokens[0];
|
|
31
|
+
if (!file) {
|
|
32
|
+
return ["Usage: /view <file> [start-end] (e.g. /view src/cli.ts 1-40)"];
|
|
33
|
+
}
|
|
34
|
+
let content: string;
|
|
35
|
+
try {
|
|
36
|
+
content = await fs.promises.readFile(path.resolve(cwd, file), "utf-8");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
return [`! cannot read ${file}: ${(err as Error).message}`];
|
|
39
|
+
}
|
|
40
|
+
const range = tokens[1] ? parseLineRange(tokens[1]) : undefined;
|
|
41
|
+
if (tokens[1] && !range) {
|
|
42
|
+
return [`Invalid range '${tokens[1]}'. Use start-end | start- | start.`];
|
|
43
|
+
}
|
|
44
|
+
const lang = detectLanguage(file);
|
|
45
|
+
const { lines, startLine } = sliceLines(content, range ?? undefined);
|
|
46
|
+
const { cols } = terminalSize();
|
|
47
|
+
const out: string[] = [
|
|
48
|
+
`${categoryBadge("file")} ${chalk.bold(`${file}`)}${chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`)}`,
|
|
49
|
+
];
|
|
50
|
+
for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
|
|
51
|
+
out.push(line);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `/diff [path]` — render `git diff` (optionally pathspec-scoped) with theme coloring. */
|
|
57
|
+
export async function handleDiffSlash(input: string, cwd: string, theme: EvolutionTheme): Promise<string[]> {
|
|
58
|
+
const target = input.substring(5).trim();
|
|
59
|
+
const proc = Bun.spawnSync(["git", "diff", ...(target ? ["--", target] : [])], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
60
|
+
if (proc.exitCode !== 0 && !proc.stdout.length) {
|
|
61
|
+
return [`! git diff failed: ${proc.stderr.toString().trim() || "not a git repo?"}`];
|
|
62
|
+
}
|
|
63
|
+
const text = proc.stdout.toString();
|
|
64
|
+
if (!text.trim()) {
|
|
65
|
+
return ["(no unstaged changes)"];
|
|
66
|
+
}
|
|
67
|
+
const { cols } = terminalSize();
|
|
68
|
+
const out: string[] = [`${categoryBadge("diff")} git diff${target ? ` -- ${target}` : ""}`];
|
|
69
|
+
for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400, theme })) out.push(line);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `/find <glob>` — list files matching a glob via the shared findTool. */
|
|
74
|
+
export async function handleFindSlash(input: string, cwd: string): Promise<string[]> {
|
|
75
|
+
const glob = input.substring(5).trim();
|
|
76
|
+
if (!glob) {
|
|
77
|
+
return ["Usage: /find <glob> (e.g. /find src/**/*.ts)"];
|
|
78
|
+
}
|
|
79
|
+
const res = await findTool(glob, cwd);
|
|
80
|
+
return [
|
|
81
|
+
`${categoryBadge("search")} find files matching '${glob}':`,
|
|
82
|
+
res.success ? (res.output || "(no matches)") : `! ${res.error}`,
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `/search <pattern> [glob]` — grep the tree via the shared searchTool. */
|
|
87
|
+
export async function handleSearchSlash(input: string, cwd: string): Promise<string[]> {
|
|
88
|
+
const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
|
|
89
|
+
const pattern = tokens[0];
|
|
90
|
+
const glob = tokens[1] ?? "*";
|
|
91
|
+
if (!pattern) {
|
|
92
|
+
return ["Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)"];
|
|
93
|
+
}
|
|
94
|
+
const res = await searchTool(pattern, glob, cwd);
|
|
95
|
+
return [
|
|
96
|
+
`${categoryBadge("search")} search pattern '${pattern}' in '${glob}':`,
|
|
97
|
+
res.success ? (res.output || "(no matches)") : `! ${res.error}`,
|
|
98
|
+
];
|
|
99
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Self-contained git-backed REPL slash handlers. Each returns the exact line
|
|
2
|
+
// array the inline handler used to `console.log`, so the git side effects and
|
|
3
|
+
// their guard rails stay verifiable against a throwaway repo without driving the
|
|
4
|
+
// whole launch REPL. State comes in explicitly (cwd) — no closure capture.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/undo` — revert the most recent commit, but ONLY when jeo made it (the
|
|
8
|
+
* `[jeo] auto-commit:` message prefix). Refuses to touch human commits or
|
|
9
|
+
* non-git trees. Performs the `git reset --hard HEAD~1` as a side effect and
|
|
10
|
+
* returns the status lines to print.
|
|
11
|
+
*/
|
|
12
|
+
export function handleUndoSlash(cwd: string): string[] {
|
|
13
|
+
try {
|
|
14
|
+
const logRes = Bun.spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
15
|
+
if (logRes.exitCode !== 0) {
|
|
16
|
+
return ["! undo failed: could not read git log (not a git repo?)"];
|
|
17
|
+
}
|
|
18
|
+
const msg = logRes.stdout.toString().trim();
|
|
19
|
+
if (!msg.startsWith("[jeo] auto-commit:")) {
|
|
20
|
+
return ["! the last commit was not made by jeo (no '[jeo] auto-commit:' prefix). Use git manually to revert."];
|
|
21
|
+
}
|
|
22
|
+
const resetRes = Bun.spawnSync(["git", "reset", "--hard", "HEAD~1"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
23
|
+
if (resetRes.exitCode === 0) {
|
|
24
|
+
return ["(undid the last jeo auto-commit and restored the working tree)"];
|
|
25
|
+
}
|
|
26
|
+
return [`! undo failed: ${resetRes.stderr.toString().trim()}`];
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return [`! undo failed: ${(err as Error).message}`];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// they can be unit-tested without driving the whole launch REPL. They hold NO
|
|
4
4
|
// mutable session state — every input is passed in explicitly.
|
|
5
5
|
import type { Message } from "../../agent/loop";
|
|
6
|
+
import { formatTranscript } from "../../tui/components/transcript";
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
/** Static keyboard-shortcut reference for `/hotkeys` (no inputs, no state). */
|
|
8
10
|
export function hotkeysLines(): string[] {
|
|
@@ -51,3 +53,21 @@ export function contextUsageLines(
|
|
|
51
53
|
lines.push(" Free context with /compact or /clear.");
|
|
52
54
|
return lines;
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
/** Re-print the worked history into scrollback for `/history [N|all]`. Pure over
|
|
58
|
+
* the in-memory transcript plus the terminal width, so the banner/separator math
|
|
59
|
+
* and turn-count parsing are verifiable without driving the REPL.
|
|
60
|
+
* `arg` is the raw text after `/history` (e.g. " 10", " all", ""). */
|
|
61
|
+
export function historyViewLines(history: Message[], arg: string, columns: number | undefined): string[] {
|
|
62
|
+
const a = arg.trim().toLowerCase();
|
|
63
|
+
const maxTurns = a === "all" ? undefined : Math.max(1, Number.parseInt(a, 10) || 5);
|
|
64
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (columns ?? 80) - 1)));
|
|
65
|
+
return [
|
|
66
|
+
sep,
|
|
67
|
+
`history · last ${maxTurns ?? "all"} turn(s) (/history all for everything)`,
|
|
68
|
+
sep,
|
|
69
|
+
...formatTranscript(history, { maxTurns, color: true, unicode: true }),
|
|
70
|
+
sep,
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
package/src/commands/launch.ts
CHANGED
|
@@ -60,7 +60,8 @@ import {
|
|
|
60
60
|
import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
|
|
61
61
|
|
|
62
62
|
import { loginPicker, renderLoginPicker, onboardingPicker, renderOnboardingPicker, apiKeyPicker, renderApiKeyPicker, subscriptionLoginPicker, type OnboardingAction } from "../tui/components/provider-picker";
|
|
63
|
-
import {
|
|
63
|
+
import { sanitizeForTerminal } from "../tui/components/code-view";
|
|
64
|
+
|
|
64
65
|
import { categoryBadge } from "../tui/components/category-index";
|
|
65
66
|
import { renderInputFrame, type HighlightRange } from "../tui/components/input-box";
|
|
66
67
|
|
|
@@ -78,7 +79,8 @@ import { stripMarkdown } from "../tui/components/markdown-text";
|
|
|
78
79
|
import { summarizeForgeInvocation } from "../tui/components/forge";
|
|
79
80
|
import { formatDuration, formatUsage } from "../tui/components/duration";
|
|
80
81
|
|
|
81
|
-
import {
|
|
82
|
+
import { bashTool } from "../agent/tools";
|
|
83
|
+
|
|
82
84
|
import { loadProjectContext, withProjectContext } from "../agent/context-files";
|
|
83
85
|
import { maybeCompact, historyTokens } from "../agent/compaction";
|
|
84
86
|
import * as path from "node:path";
|
|
@@ -167,7 +169,9 @@ import {
|
|
|
167
169
|
import {
|
|
168
170
|
hotkeysLines,
|
|
169
171
|
contextUsageLines,
|
|
172
|
+
historyViewLines,
|
|
170
173
|
} from "./launch/slash-views";
|
|
174
|
+
|
|
171
175
|
import {
|
|
172
176
|
handleUsage,
|
|
173
177
|
handleTools,
|
|
@@ -175,6 +179,15 @@ import {
|
|
|
175
179
|
handleContext,
|
|
176
180
|
type SlashContext,
|
|
177
181
|
} from "./launch/slash-handlers";
|
|
182
|
+
import {
|
|
183
|
+
handleViewSlash,
|
|
184
|
+
handleDiffSlash,
|
|
185
|
+
handleFindSlash,
|
|
186
|
+
handleSearchSlash,
|
|
187
|
+
} from "./launch/code-slash";
|
|
188
|
+
import { handleUndoSlash } from "./launch/git-slash";
|
|
189
|
+
|
|
190
|
+
|
|
178
191
|
|
|
179
192
|
export {
|
|
180
193
|
type LaunchFlags,
|
|
@@ -692,7 +705,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
692
705
|
opts?: { userCard?: string | null },
|
|
693
706
|
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
|
|
694
707
|
const turnConfig = await readGlobalConfig();
|
|
695
|
-
|
|
708
|
+
// Fall back to the model resolved at THIS session's start (frozen snapshot), NOT
|
|
709
|
+
// the live global default. A concurrent `jeo` session running `/model` persists the
|
|
710
|
+
// global default (rememberModelPatch), so re-reading turnConfig.defaultModel here let
|
|
711
|
+
// that write silently switch THIS running session's model mid-run. Per-session model
|
|
712
|
+
// selection must stay session-local: running sessions never influence each other.
|
|
713
|
+
const activeModel = sessionModel || defaultModel;
|
|
696
714
|
const contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
697
715
|
|
|
698
716
|
// Resolve provider + dirty count up front — both are cheap and feed the live
|
|
@@ -730,7 +748,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
730
748
|
tui.start();
|
|
731
749
|
}
|
|
732
750
|
const compRes = await maybeCompact(history, {
|
|
733
|
-
model:
|
|
751
|
+
model: activeModel,
|
|
734
752
|
contextTokens,
|
|
735
753
|
});
|
|
736
754
|
if (compRes.error) {
|
|
@@ -896,7 +914,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
896
914
|
}
|
|
897
915
|
|
|
898
916
|
if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
|
|
899
|
-
const verdict = await verifyGoal(goalState.condition, history,
|
|
917
|
+
const verdict = await verifyGoal(goalState.condition, history, activeModel);
|
|
900
918
|
|
|
901
919
|
goalState.verdicts.push({
|
|
902
920
|
at: Date.now(),
|
|
@@ -938,7 +956,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
938
956
|
const opik = createOpikTracer({
|
|
939
957
|
name: userInput.trim().slice(0, 80) || "jeo turn",
|
|
940
958
|
input: userInput,
|
|
941
|
-
metadata: { model:
|
|
959
|
+
metadata: { model: activeModel, cwd },
|
|
942
960
|
tags: ["jeo", "launch"],
|
|
943
961
|
});
|
|
944
962
|
opik.startTurn();
|
|
@@ -946,7 +964,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
946
964
|
cwd,
|
|
947
965
|
tools,
|
|
948
966
|
maxSteps: flags.maxSteps,
|
|
949
|
-
model:
|
|
967
|
+
model: activeModel,
|
|
950
968
|
maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
|
|
951
969
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
952
970
|
signal: ac.signal,
|
|
@@ -965,7 +983,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
965
983
|
tools,
|
|
966
984
|
maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
|
|
967
985
|
budget: { maxExtensions: 0 },
|
|
968
|
-
model:
|
|
986
|
+
model: activeModel,
|
|
969
987
|
maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
|
|
970
988
|
reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
|
|
971
989
|
signal: ac.signal,
|
|
@@ -1225,6 +1243,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1225
1243
|
accent: accentPaint(welcomeTheme),
|
|
1226
1244
|
accentShadow: accentShadowPaint(welcomeTheme),
|
|
1227
1245
|
};
|
|
1246
|
+
// welcomeData.cols is a launch-time snapshot; re-measure on every banner re-render
|
|
1247
|
+
// (/clear, /resume) so the box fits the resized terminal instead of the old width.
|
|
1248
|
+
const freshWelcomeLines = (): string[] =>
|
|
1249
|
+
renderWelcome({ ...welcomeData, cols: terminalSize().cols });
|
|
1228
1250
|
// gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
|
|
1229
1251
|
// never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
|
|
1230
1252
|
if (process.stdout.isTTY) process.stdout.write(clearScreen());
|
|
@@ -2999,16 +3021,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2999
3021
|
if (process.stdout.isTTY) {
|
|
3000
3022
|
disarmPreview();
|
|
3001
3023
|
process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
|
|
3002
|
-
console.log(
|
|
3024
|
+
console.log(freshWelcomeLines().join("\n"));
|
|
3003
3025
|
}
|
|
3004
3026
|
console.log("(history cleared — back to the start screen)");
|
|
3005
3027
|
continue;
|
|
3006
3028
|
}
|
|
3007
3029
|
if (input === "/compact") {
|
|
3008
|
-
|
|
3009
|
-
|
|
3030
|
+
// Session-start default snapshot (not the live global default) — keeps a
|
|
3031
|
+
// concurrent session's `/model` write from changing this session's context budget.
|
|
3032
|
+
const activeModel = sessionModel || defaultModel;
|
|
3010
3033
|
const contextTokens = catalogMetadata(activeModel)?.contextTokens;
|
|
3011
|
-
const res = await maybeCompact(history, { model:
|
|
3034
|
+
const res = await maybeCompact(history, { model: activeModel, force: true, contextTokens });
|
|
3012
3035
|
if (res.error) {
|
|
3013
3036
|
console.error(chalk.red(res.error));
|
|
3014
3037
|
} else if (res.compacted && sessionId && res.replacesThrough !== undefined) {
|
|
@@ -3106,7 +3129,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3106
3129
|
if (process.stdout.isTTY) {
|
|
3107
3130
|
disarmPreview();
|
|
3108
3131
|
process.stdout.write(clearScreen());
|
|
3109
|
-
console.log(
|
|
3132
|
+
console.log(freshWelcomeLines().join("\n"));
|
|
3110
3133
|
}
|
|
3111
3134
|
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
3112
3135
|
logLines([
|
|
@@ -3267,13 +3290,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3267
3290
|
if (input === "/history" || input.startsWith("/history ")) {
|
|
3268
3291
|
// Re-print the worked history into scrollback (tmux-friendly review of
|
|
3269
3292
|
// past prompts / tool steps / replies without terminal scrollback access).
|
|
3270
|
-
|
|
3271
|
-
const maxTurns = arg === "all" ? undefined : Math.max(1, Number.parseInt(arg, 10) || 5);
|
|
3272
|
-
const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
|
|
3273
|
-
logLines([sep, `history · last ${maxTurns ?? "all"} turn(s) (/history all for everything)`, sep,
|
|
3274
|
-
...formatTranscript(history, { maxTurns, color: true, unicode: true }), sep]);
|
|
3293
|
+
logLines(historyViewLines(history, input.slice("/history".length), process.stdout.columns));
|
|
3275
3294
|
continue;
|
|
3276
3295
|
}
|
|
3296
|
+
|
|
3277
3297
|
if (input === "/export" || input.startsWith("/export ")) {
|
|
3278
3298
|
if (!sessionId) {
|
|
3279
3299
|
console.log("(sessions are disabled — nothing to export)");
|
|
@@ -3335,7 +3355,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3335
3355
|
process.stdout.write("btw> ");
|
|
3336
3356
|
let streamed = "";
|
|
3337
3357
|
const answer = await callLlm(side, {
|
|
3338
|
-
model: sessionModel,
|
|
3358
|
+
model: sessionModel || defaultModel,
|
|
3339
3359
|
maxTokens: thinkingMaxTokens("low"),
|
|
3340
3360
|
onToken: delta => {
|
|
3341
3361
|
streamed += delta;
|
|
@@ -3350,28 +3370,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3350
3370
|
continue;
|
|
3351
3371
|
}
|
|
3352
3372
|
if (input === "/undo") {
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
if (logRes.exitCode === 0) {
|
|
3356
|
-
const msg = logRes.stdout.toString().trim();
|
|
3357
|
-
if (msg.startsWith("[jeo] auto-commit:")) {
|
|
3358
|
-
const resetRes = Bun.spawnSync(["git", "reset", "--hard", "HEAD~1"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
3359
|
-
if (resetRes.exitCode === 0) {
|
|
3360
|
-
console.log("(undid the last jeo auto-commit and restored the working tree)");
|
|
3361
|
-
} else {
|
|
3362
|
-
console.log(`! undo failed: ${resetRes.stderr.toString().trim()}`);
|
|
3363
|
-
}
|
|
3364
|
-
} else {
|
|
3365
|
-
console.log("! the last commit was not made by jeo (no '[jeo] auto-commit:' prefix). Use git manually to revert.");
|
|
3366
|
-
}
|
|
3367
|
-
} else {
|
|
3368
|
-
console.log("! undo failed: could not read git log (not a git repo?)");
|
|
3369
|
-
}
|
|
3370
|
-
} catch (err) {
|
|
3371
|
-
console.log(`! undo failed: ${(err as Error).message}`);
|
|
3372
|
-
}
|
|
3373
|
+
logLines(handleUndoSlash(cwd));
|
|
3374
|
+
|
|
3373
3375
|
continue;
|
|
3374
3376
|
}
|
|
3377
|
+
|
|
3375
3378
|
if (input === "/goal" || input.startsWith("/goal ")) {
|
|
3376
3379
|
const arg = input.substring(5).trim();
|
|
3377
3380
|
if (!arg) {
|
|
@@ -4227,75 +4230,23 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4227
4230
|
continue;
|
|
4228
4231
|
}
|
|
4229
4232
|
if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
|
|
4230
|
-
const
|
|
4231
|
-
const file = tokens[0];
|
|
4232
|
-
if (!file) {
|
|
4233
|
-
console.log("Usage: /view <file> [start-end] (e.g. /view src/cli.ts 1-40)");
|
|
4234
|
-
continue;
|
|
4235
|
-
}
|
|
4236
|
-
let content: string;
|
|
4237
|
-
try {
|
|
4238
|
-
content = await fs.promises.readFile(path.resolve(cwd, file), "utf-8");
|
|
4239
|
-
} catch (err) {
|
|
4240
|
-
console.log(`! cannot read ${file}: ${(err as Error).message}`);
|
|
4241
|
-
continue;
|
|
4242
|
-
}
|
|
4243
|
-
const range = tokens[1] ? parseLineRange(tokens[1]) : undefined;
|
|
4244
|
-
if (tokens[1] && !range) {
|
|
4245
|
-
console.log(`Invalid range '${tokens[1]}'. Use start-end | start- | start.`);
|
|
4246
|
-
continue;
|
|
4247
|
-
}
|
|
4248
|
-
const lang = detectLanguage(file);
|
|
4249
|
-
const { lines, startLine } = sliceLines(content, range ?? undefined);
|
|
4250
|
-
const { cols } = await import("../tui/terminal").then(m => m.size());
|
|
4251
|
-
console.log(`${categoryBadge("file")} ${chalk.bold(`${file}`)}${chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`)}`);
|
|
4252
|
-
for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
|
|
4253
|
-
console.log(line);
|
|
4254
|
-
}
|
|
4233
|
+
for (const line of await handleViewSlash(input, cwd)) console.log(line);
|
|
4255
4234
|
continue;
|
|
4256
4235
|
}
|
|
4257
4236
|
if (input.startsWith("/diff") && (input === "/diff" || input[5] === " ")) {
|
|
4258
|
-
const
|
|
4259
|
-
const proc = Bun.spawnSync(["git", "diff", ...(target ? ["--", target] : [])], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
4260
|
-
if (proc.exitCode !== 0 && !proc.stdout.length) {
|
|
4261
|
-
console.log(`! git diff failed: ${proc.stderr.toString().trim() || "not a git repo?"}`);
|
|
4262
|
-
continue;
|
|
4263
|
-
}
|
|
4264
|
-
const text = proc.stdout.toString();
|
|
4265
|
-
if (!text.trim()) {
|
|
4266
|
-
console.log("(no unstaged changes)");
|
|
4267
|
-
continue;
|
|
4268
|
-
}
|
|
4269
|
-
const { cols } = await import("../tui/terminal").then(m => m.size());
|
|
4270
|
-
console.log(`${categoryBadge("diff")} git diff${target ? ` -- ${target}` : ""}`);
|
|
4271
|
-
for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400, theme: uiTheme })) console.log(line);
|
|
4237
|
+
for (const line of await handleDiffSlash(input, cwd, uiTheme)) console.log(line);
|
|
4272
4238
|
continue;
|
|
4273
4239
|
}
|
|
4274
4240
|
if (input.startsWith("/find") && (input === "/find" || input[5] === " ")) {
|
|
4275
|
-
const
|
|
4276
|
-
if (!glob) {
|
|
4277
|
-
console.log("Usage: /find <glob> (e.g. /find src/**/*.ts)");
|
|
4278
|
-
continue;
|
|
4279
|
-
}
|
|
4280
|
-
console.log(`${categoryBadge("search")} find files matching '${glob}':`);
|
|
4281
|
-
const res = await findTool(glob, cwd);
|
|
4282
|
-
console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
|
|
4241
|
+
for (const line of await handleFindSlash(input, cwd)) console.log(line);
|
|
4283
4242
|
continue;
|
|
4284
4243
|
}
|
|
4285
4244
|
if (input.startsWith("/search") && (input === "/search" || input[7] === " ")) {
|
|
4286
|
-
const
|
|
4287
|
-
const pattern = tokens[0];
|
|
4288
|
-
const glob = tokens[1] ?? "*";
|
|
4289
|
-
if (!pattern) {
|
|
4290
|
-
console.log("Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)");
|
|
4291
|
-
continue;
|
|
4292
|
-
}
|
|
4293
|
-
console.log(`${categoryBadge("search")} search pattern '${pattern}' in '${glob}':`);
|
|
4294
|
-
const res = await searchTool(pattern, glob, cwd);
|
|
4295
|
-
console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
|
|
4245
|
+
for (const line of await handleSearchSlash(input, cwd)) console.log(line);
|
|
4296
4246
|
continue;
|
|
4297
4247
|
}
|
|
4298
4248
|
|
|
4249
|
+
|
|
4299
4250
|
const skillEntrypoint = input.startsWith("/skill:") ? "/skill:" : input.startsWith("/skill") && (input === "/skill" || input[6] === " ") ? "/skill" : "";
|
|
4300
4251
|
if (skillEntrypoint) {
|
|
4301
4252
|
if (flags.noSkills) {
|