agent-sh 0.14.1 → 0.14.3

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.
Files changed (65) hide show
  1. package/dist/agent/agent-loop.d.ts +1 -1
  2. package/dist/agent/agent-loop.js +42 -31
  3. package/dist/agent/conversation-state.d.ts +3 -2
  4. package/dist/agent/conversation-state.js +20 -3
  5. package/dist/agent/events.d.ts +2 -0
  6. package/dist/agent/host-types.d.ts +3 -0
  7. package/dist/agent/index.js +2 -1
  8. package/dist/agent/subagent.d.ts +1 -1
  9. package/dist/agent/subagent.js +5 -1
  10. package/dist/agent/tool-protocol.d.ts +2 -2
  11. package/dist/agent/tool-protocol.js +5 -4
  12. package/dist/agent/tools/glob.d.ts +1 -1
  13. package/dist/agent/tools/glob.js +4 -2
  14. package/dist/agent/tools/grep.d.ts +1 -1
  15. package/dist/agent/tools/grep.js +4 -2
  16. package/dist/agent/tools/ls.d.ts +1 -1
  17. package/dist/agent/tools/ls.js +4 -2
  18. package/dist/agent/tools/read-file.d.ts +1 -1
  19. package/dist/agent/tools/read-file.js +30 -2
  20. package/dist/agent/types.d.ts +11 -1
  21. package/dist/agent/types.js +6 -1
  22. package/dist/cli/index.js +0 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/settings.d.ts +3 -0
  25. package/dist/core/settings.js +2 -2
  26. package/dist/shell/events.d.ts +2 -0
  27. package/dist/shell/index.d.ts +6 -0
  28. package/dist/shell/index.js +10 -10
  29. package/dist/shell/output-parser.d.ts +11 -22
  30. package/dist/shell/output-parser.js +16 -34
  31. package/dist/shell/shell-context.d.ts +3 -6
  32. package/dist/shell/shell-context.js +15 -7
  33. package/dist/shell/shell.d.ts +4 -0
  34. package/dist/shell/shell.js +18 -30
  35. package/dist/shell/strategies/types.d.ts +6 -0
  36. package/dist/shell/strategies/zsh.js +7 -0
  37. package/dist/shell/terminal.d.ts +33 -0
  38. package/dist/shell/terminal.js +62 -0
  39. package/examples/extensions/ash-scheme/index.ts +2170 -0
  40. package/examples/extensions/ash-scheme/package.json +11 -0
  41. package/examples/extensions/ash-scheme-render.ts +58 -0
  42. package/examples/extensions/ashi/README.md +36 -26
  43. package/examples/extensions/ashi/package.json +9 -1
  44. package/examples/extensions/ashi/src/capture.ts +1 -0
  45. package/examples/extensions/ashi/src/cli.ts +53 -11
  46. package/examples/extensions/ashi/src/commands.ts +2 -20
  47. package/examples/extensions/ashi/src/compaction.ts +25 -96
  48. package/examples/extensions/ashi/src/components.ts +64 -166
  49. package/examples/extensions/ashi/src/default-schema-renderers.ts +232 -0
  50. package/examples/extensions/ashi/src/display-config.ts +21 -22
  51. package/examples/extensions/ashi/src/frontend.ts +355 -118
  52. package/examples/extensions/ashi/src/hooks.ts +47 -63
  53. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  54. package/examples/extensions/ashi/src/schema.ts +386 -0
  55. package/examples/extensions/ashi/src/session-store.ts +115 -17
  56. package/examples/extensions/ashi/src/shell-mode.ts +52 -0
  57. package/examples/extensions/ashi/src/status-footer.ts +41 -6
  58. package/examples/extensions/ashi/src/theme.ts +2 -1
  59. package/examples/extensions/ashi-compact-llm.ts +93 -0
  60. package/examples/extensions/claude-code-bridge/index.ts +2 -0
  61. package/examples/extensions/opencode-bridge/index.ts +3 -0
  62. package/examples/extensions/opencode-provider.ts +252 -0
  63. package/examples/extensions/pi-bridge/index.ts +1 -0
  64. package/package.json +16 -1
  65. package/examples/extensions/ashi/src/default-renderers.ts +0 -171
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "ash-scheme",
3
+ "version": "0.1.0",
4
+ "description": "LIPS Scheme as a cognitive substrate: a scheme_eval tool with host bindings to bash, read-file, grep, glob, etc.",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "dependencies": {
8
+ "@jcubic/lips": "^0.20.3",
9
+ "agent-sh": "^0.14.0"
10
+ }
11
+ }
@@ -0,0 +1,58 @@
1
+ // Schema-style renderer for the scheme_eval tool. No pi-tui import, no ANSI,
2
+ // no highlighter dep. Status and streaming output are tracked by ashi; this
3
+ // model only declares tool-specific state (the source string).
4
+
5
+ import type { AgentContext } from "agent-sh/types";
6
+ import type { RenderModel, Segment, ToolDisplay } from "@guanyilun/ashi/render";
7
+
8
+ interface SchemeInit { source: string }
9
+
10
+ function parseRaw(raw: unknown): Record<string, unknown> {
11
+ if (typeof raw === "string") {
12
+ try { return JSON.parse(raw) as Record<string, unknown>; } catch { return {}; }
13
+ }
14
+ if (raw && typeof raw === "object") return raw as Record<string, unknown>;
15
+ return {};
16
+ }
17
+
18
+ function source(rawInput: unknown): string {
19
+ const r = parseRaw(rawInput);
20
+ return typeof r.source === "string" ? r.source : "";
21
+ }
22
+
23
+ function compact(s: string, max = 80): string {
24
+ const c = s.replace(/\s+/g, " ").trim();
25
+ return c.length > max ? c.slice(0, max - 1) + "…" : c;
26
+ }
27
+
28
+ const model: RenderModel<SchemeInit> = {
29
+ initial: ({ rawInput }) => ({ source: source(rawInput) }),
30
+ view: (s, env): ToolDisplay => {
31
+ const failed = !!s.status && s.status.exitCode !== 0 && s.status.exitCode !== null;
32
+ const title: Segment[] = [
33
+ { text: "scheme ", style: { bold: true, color: "toolTitle" } },
34
+ { text: env.expanded ? s.source : compact(s.source), highlight: "scheme" },
35
+ ];
36
+ return {
37
+ titleIcon: "scheme",
38
+ title,
39
+ status: s.status,
40
+ body: failed
41
+ ? { kind: "compound", parts: [
42
+ { kind: "code", lang: "scheme", text: s.source },
43
+ { kind: "text", segments: [
44
+ { text: `✗ ${s.output.trim()}`, style: { color: "error" } },
45
+ ] },
46
+ ] }
47
+ : { kind: "code", lang: "scheme", text: s.source },
48
+ expandable: true,
49
+ defaultExpanded: failed,
50
+ };
51
+ },
52
+ };
53
+
54
+ export default function activate(ctx: AgentContext): void {
55
+ for (const n of ["scheme", "scheme_eval"]) {
56
+ ctx.define(`ashi:render-tool:${n}`, () => model);
57
+ }
58
+ }
@@ -68,7 +68,7 @@ Ctrl+C Clear editor
68
68
  Ctrl+D Quit (when editor is empty)
69
69
  Ctrl+T Toggle thinking-block visibility (hidden by default)
70
70
  Shift+Tab Cycle thinking level (off → low → medium → high → …)
71
- Ctrl+O Expand/collapse the most recent tool result
71
+ Ctrl+O Expand/collapse all tool calls and results in chat
72
72
  ```
73
73
 
74
74
  The current thinking level is shown in the footer as `[level]` next to the model name.
@@ -122,11 +122,11 @@ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
122
122
  {
123
123
  "ashi": {
124
124
  "display": {
125
- "default": { "result": "preview", "previewLines": 8 },
125
+ "default": { "result": "preview", "previewLines": 5 },
126
126
  "read": { "result": "hidden" },
127
127
  "ls": { "result": "hidden" },
128
128
  "grep": { "result": "summary" },
129
- "bash": { "result": "preview", "previewLines": 12 },
129
+ "bash": { "result": "preview" },
130
130
  "edit": { "result": "preview" },
131
131
  "write": { "result": "preview" }
132
132
  }
@@ -138,54 +138,64 @@ Per-tool compactness lives under `ashi.display` in `~/.agent-sh/settings.json`:
138
138
 
139
139
  - `"hidden"` — call line only while streaming; line count (`↳ 42 lines`) after completion.
140
140
  - `"summary"` — 2-line tail while streaming; line count after completion.
141
- - `"preview"` — last `previewLines` lines of output (default 8), with a `... (N more lines)` hint when content overflows.
141
+ - `"preview"` — last `previewLines` lines of output (default 5), with a `... (N more lines)` hint when content overflows.
142
142
 
143
143
  For `edit_file` / `write_file`, the diff frame is treated as the output and follows the same gating: shown for `preview`, hidden for `hidden`/`summary` (the call line already carries `+12 -3` stats). The line-count hint is suppressed for diff-producing tools so edits stay quiet.
144
144
 
145
- Hit `Ctrl+O` to expand the most recent tool result inline regardless of mode. Press again to collapse.
145
+ Hit `Ctrl+O` to toggle expansion across all tool entries in chat — result bodies show their full output regardless of mode, and call lines with truncated labels (e.g. long `bash` commands) reveal their full text. Press again to collapse.
146
146
 
147
147
  Each tool inherits from `default` and is overridden by its own block. Unknown tool names fall through to `default`.
148
148
 
149
149
  ## Extension surface
150
150
 
151
- Other extensions can override how chat entries and tool results render without forking ashi. Hooks are exposed via `ctx.define` (defaults) + `ctx.advise` (override).
152
-
153
- Components returned from these hooks are widgets from [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) — the TUI framework ashi is built on.
151
+ Other extensions can customize how chat entries and tool results render without forking ashi.
154
152
 
155
153
  ### Chat hooks
156
154
 
155
+ These return [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) components directly:
156
+
157
157
  | Hook | Args | Returns |
158
158
  |---|---|---|
159
159
  | `ashi:render-user-message` | `{ text, state, invalidate }` | `Component` |
160
160
  | `ashi:render-assistant` | `{ text, state, invalidate }` | `Component` |
161
161
  | `ashi:render-thinking` | `{ text, hidden, state, invalidate }` | `Component` |
162
162
 
163
- ### Tool hooks (per-tool)
163
+ ### Tool hooks — declarative render schema
164
164
 
165
- Tool rendering is split into a call line (the input header) and a result body (streaming output + final state). Each side is dispatched by tool name with a `:default` fallback:
165
+ Tool rendering uses a declarative schema so extensions don't import pi-tui or touch ashi internals. Register a `RenderModel` under `ashi:render-tool:{name}` (with `:default` as the fallback):
166
166
 
167
- | Hook | Args | Returns |
168
- |---|---|---|
169
- | `ashi:render-tool-call:{name}` | `{ toolCallId, name, title, kind, displayDetail, rawInput, state, invalidate }` | `ToolCallView` |
170
- | `ashi:render-tool-call:default` | (same) | `ToolCallView` |
171
- | `ashi:render-tool-result:{name}` | `{ toolCallId, name, kind, rawInput, mode, previewLines, state, invalidate }` | `ToolResultView` |
172
- | `ashi:render-tool-result:default` | (same) | `ToolResultView` |
167
+ ```ts
168
+ import type { RenderModel, ToolDisplay } from "@guanyilun/ashi/render";
169
+
170
+ const myModel: RenderModel<{ command: string }> = {
171
+ initial: ({ rawInput }) => ({ command: JSON.parse(String(rawInput)).command ?? "" }),
172
+ view: (s): ToolDisplay => ({
173
+ title: [
174
+ { text: "$ ", style: { bold: true, color: "toolTitle" } },
175
+ { text: s.command, highlight: "bash" },
176
+ ],
177
+ status: s.status,
178
+ body: { kind: "stream", text: s.output },
179
+ expandable: true,
180
+ }),
181
+ };
173
182
 
174
- `state` is a per-call mutable bag; `invalidate()` requests a re-render.
183
+ export default function activate(ctx) {
184
+ ctx.define("ashi:render-tool:bash", () => myModel);
185
+ }
186
+ ```
175
187
 
176
- - `ToolCallView` extends `Component` with `setStatus({ exitCode, elapsedMs, summary })`called once on completion.
177
- - `ToolResultView` extends `Component` with `appendChunk(chunk)`, `setDiff(lines)`, `finalize({ exitCode, summary })`, and `toggleExpanded()` — ashi mutates the result view as output streams in and when the user hits `Ctrl+O`.
188
+ `view(state, env)` is a pure function returning a `ToolDisplay`. Ashi owns the pi-tui mapping, theming, streaming buffer policy (preview / summary / hidden modes from `ashi.display`), diff width memoization, and the Ctrl+O expand toggle. The framework auto-tracks `state.status`, `state.output` (streaming chunks), and `state.hasDiff` (for edit/write) — renderers read these without wiring their own reducers.
178
189
 
179
- `mode` and `previewLines` on result args come from `ashi.display.{name}` config so renderers can honor the user's compactness preference without re-implementing the resolution logic.
190
+ `ToolDisplay` body kinds: `text`, `code` (with syntax highlighting via `lang`), `stream` (preview/summary/hidden policy applied by ashi), `diff` (closure pushed by the frontend orchestrator), `lines`, `compound`. Custom state transitions can be declared via an optional `reducers` map.
180
191
 
181
- Example: override how `bash` calls render.
192
+ To ship a default display policy with your renderer (e.g. "this tool's output is large, default to `summary`"), set `display` on the model. User `settings.json` still wins:
182
193
 
183
194
  ```ts
184
- export default function activate(ctx) {
185
- ctx.advise("ashi:render-tool-call:bash", (next, args) => {
186
- return new MyFancyBashLine(args); // must implement ToolCallView
187
- });
188
- }
195
+ const myModel: RenderModel<...> = {
196
+ initial, view,
197
+ display: { result: "summary", previewLines: 3 },
198
+ };
189
199
  ```
190
200
 
191
201
  For non-render concerns (commands, settings, tools, providers) use the standard `agent-sh` extension API. See the [agent-sh extension docs](https://github.com/guanyilun/agent-sh/blob/main/docs/extensions.md).
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "@guanyilun/ashi",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Ash in an interactive TUI — agent-sh's built-in agent without the shell underneath",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {
8
8
  "ashi": "dist/cli.js"
9
9
  },
10
+ "exports": {
11
+ ".": "./dist/cli.js",
12
+ "./render": {
13
+ "types": "./dist/schema.d.ts",
14
+ "import": "./dist/schema.js"
15
+ }
16
+ },
10
17
  "files": [
11
18
  "dist",
12
19
  "README.md"
@@ -16,6 +23,7 @@
16
23
  "clean": "rm -rf dist",
17
24
  "build": "npm run clean && tsc",
18
25
  "start": "node dist/cli.js",
26
+ "test": "node --import tsx --test $(find tests -name '*.test.ts' -type f)",
19
27
  "prepublishOnly": "npm run build"
20
28
  },
21
29
  "keywords": [
@@ -38,6 +38,7 @@ export function registerCapture(
38
38
  const newMessages = messages.slice(liveEntryIds.length).map(enrich);
39
39
  const newIds = await getStore().current().appendMessages(newMessages);
40
40
  liveEntryIds = [...liveEntryIds, ...newIds];
41
+ getStore().markLastSession();
41
42
  };
42
43
 
43
44
  ctx.bus.on("agent:processing-done", () => { void flush(); });
@@ -7,25 +7,40 @@ import { loadBuiltinExtensions } from "agent-sh/extensions";
7
7
  import { loadExtensions } from "agent-sh/extension-loader";
8
8
  import { activateAgent } from "agent-sh/agent";
9
9
  import { getSettings } from "agent-sh/settings";
10
+ import { Shell } from "agent-sh/shell";
11
+ import type { Terminal } from "agent-sh/shell/terminal";
12
+ import activateShellContext from "agent-sh/shell/context";
10
13
  import type { AppConfig } from "agent-sh/types";
11
14
 
15
+ /** No-op: ashi renders via pi-tui, the PTY only needs to exist. */
16
+ function headlessTerminal(): Terminal {
17
+ return {
18
+ write() {},
19
+ onInput: () => () => {},
20
+ onResize: () => () => {},
21
+ cols: () => 100,
22
+ rows: () => 30,
23
+ };
24
+ }
25
+
12
26
  import { mountAshi } from "./frontend.js";
13
27
  import { MultiSessionStore } from "./multi-session-store.js";
14
- import { registerForkCommands } from "./commands.js";
28
+ import { registerForkCommands, applyBranchMessages } from "./commands.js";
15
29
  import { registerSessionCommands } from "./session-commands.js";
16
30
  import { registerCompaction } from "./compaction.js";
17
31
  import { registerCapture } from "./capture.js";
18
32
  import { registerRenderDefaults } from "./hooks.js";
19
- import { registerDefaultToolRenderers } from "./default-renderers.js";
33
+ import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
20
34
  import * as os from "node:os";
21
35
  import * as path from "node:path";
22
36
 
23
- function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
37
+ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean } {
24
38
  let model: string | undefined;
25
39
  let apiKey: string | undefined = process.env.OPENAI_API_KEY ?? process.env.OPENROUTER_API_KEY;
26
40
  let baseURL: string | undefined = process.env.OPENAI_BASE_URL;
27
41
  let provider: string | undefined;
28
42
  let backend: string | undefined;
43
+ let continueLast = false;
29
44
  const extensions: string[] = [];
30
45
 
31
46
  for (let i = 0; i < argv.length; i++) {
@@ -37,13 +52,15 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
37
52
  else if (a === "--backend" && argv[i + 1]) backend = argv[++i];
38
53
  else if ((a === "-e" || a === "--extensions") && argv[i + 1]) {
39
54
  extensions.push(...argv[++i]!.split(",").map(s => s.trim()).filter(Boolean));
55
+ } else if (a === "-c" || a === "--continue") {
56
+ continueLast = true;
40
57
  } else if (a === "-h" || a === "--help") {
41
58
  process.stdout.write(MANAGEMENT_HELP + "\n");
42
59
  process.exit(0);
43
60
  }
44
61
  }
45
62
 
46
- return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions };
63
+ return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions, continueLast };
47
64
  }
48
65
 
49
66
  const MANAGEMENT_HELP = `ashi — ash (agent-sh's built-in agent) in an interactive TUI
@@ -59,7 +76,9 @@ Management:
59
76
 
60
77
  Launch (default):
61
78
  ashi [--provider <name>] [--model <id>] [--api-key <key>] [--base-url <url>]
62
- [--backend <name>] [-e <ext>[,<ext>...]]
79
+ [--backend <name>] [-e <ext>[,<ext>...]] [-c | --continue]
80
+
81
+ -c, --continue Resume the last session in this cwd (fresh session if none)
63
82
 
64
83
  Reads ~/.agent-sh/settings.json for providers and defaults.`;
65
84
 
@@ -99,7 +118,6 @@ async function main(): Promise<void> {
99
118
  process.exit(1);
100
119
  }
101
120
 
102
- // ── Pi-tui frontend
103
121
  const config = parseArgs(rawArgs);
104
122
 
105
123
  if (!process.stdin.isTTY) {
@@ -110,18 +128,23 @@ async function main(): Promise<void> {
110
128
  const cwd = process.cwd();
111
129
  const cwdSlug = cwd.replace(/\//g, "-").replace(/^-/, "");
112
130
  const sessionsDir = path.join(os.homedir(), ".agent-sh", "ashi", "history", cwdSlug, "sessions");
113
- const store = new MultiSessionStore(sessionsDir, cwd);
131
+ const resumeId = config.continueLast
132
+ ? MultiSessionStore.readLastSessionId(sessionsDir, { fallbackToLatest: true })
133
+ : undefined;
134
+ const store = new MultiSessionStore(sessionsDir, cwd, { resumeSessionId: resumeId });
114
135
  const getStore = (): MultiSessionStore => store;
115
136
 
116
137
  const core = createCore({ ...config, history: new NoopHistory() });
117
138
 
118
139
  let stopFrontend: (() => void) | null = null;
119
140
 
141
+ let shellRef: { kill(): void } | null = null;
120
142
  const cleanup = (): void => {
121
- try { stopFrontend?.(); } catch { /* ignore */ }
122
- try { core.kill(); } catch { /* ignore */ }
143
+ try { stopFrontend?.(); } catch {}
144
+ try { shellRef?.kill(); } catch {}
145
+ try { core.kill(); } catch {}
123
146
  if (process.stdin.isTTY) {
124
- try { process.stdin.setRawMode(false); } catch { /* ignore */ }
147
+ try { process.stdin.setRawMode(false); } catch {}
125
148
  }
126
149
  process.exit(0);
127
150
  };
@@ -129,8 +152,21 @@ async function main(): Promise<void> {
129
152
  const ctx = core.extensionContext({ quit: cleanup });
130
153
 
131
154
  activateAgent(ctx);
155
+ activateShellContext(ctx);
132
156
  await loadBuiltinExtensions(ctx);
133
157
 
158
+ const shell = new Shell({
159
+ bus: core.bus,
160
+ handlers: { define: ctx.define, call: ctx.call },
161
+ cols: 100,
162
+ rows: 30,
163
+ shell: process.env.SHELL ?? "/bin/bash",
164
+ cwd: process.cwd(),
165
+ instanceId: ctx.instanceId,
166
+ terminal: headlessTerminal(),
167
+ });
168
+ shellRef = shell;
169
+
134
170
  const loaded = await loadExtensions(ctx, config.extensions);
135
171
  core.bus.emit("core:extensions-loaded", { names: loaded });
136
172
 
@@ -145,7 +181,7 @@ async function main(): Promise<void> {
145
181
  const capture = registerCapture(ctx, getStore);
146
182
  registerCompaction(ctx, getStore, capture);
147
183
  registerRenderDefaults(ctx);
148
- registerDefaultToolRenderers(ctx);
184
+ registerDefaultSchemaRenderers(ctx);
149
185
 
150
186
  ctx.advise("conversation:format-prior-history", () => null);
151
187
  ctx.advise("system-prompt:build", (next) => `${next()}\n\n<cwd>${process.cwd()}</cwd>`);
@@ -159,6 +195,12 @@ async function main(): Promise<void> {
159
195
  rebuildChat: handle.rebuildChat,
160
196
  });
161
197
 
198
+ if (resumeId) {
199
+ applyBranchMessages(ctx, getStore, capture);
200
+ await handle.rebuildChat();
201
+ ctx.bus.emit("ui:info", { message: `continued session ${resumeId.slice(0, 12)}…` });
202
+ }
203
+
162
204
  await core.activateBackend(config.backend ?? getSettings().defaultBackend);
163
205
 
164
206
  process.on("SIGTERM", cleanup);
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionContext } from "agent-sh/types";
2
2
  import type { MultiSessionStore } from "./multi-session-store.js";
3
- import type { AgentMessage } from "./session-store.js";
4
3
  import type { Capture } from "./capture.js";
5
4
 
6
5
  export function registerForkCommands(
@@ -12,14 +11,13 @@ export function registerForkCommands(
12
11
  ): void {
13
12
  const { bus } = ctx;
14
13
 
15
- ctx.registerCommand("fork", "Rewind and branch: /fork (interactive picker) or /fork <id-prefix>", async (args) => {
14
+ ctx.registerCommand("fork", "Pick a past user message to edit, or a branch tip to switch to", async (args) => {
16
15
  const arg = args.trim();
17
16
  if (arg === "") {
18
17
  await openTreePicker();
19
18
  return;
20
19
  }
21
- const branch = getStore().current().getBranch();
22
- const matches = branch.filter((e) => e.id.startsWith(arg));
20
+ const matches = getStore().current().getAllEntries().filter((e) => e.id.startsWith(arg));
23
21
  if (matches.length === 0) {
24
22
  bus.emit("ui:error", { message: `fork: no entry matches "${arg}"` });
25
23
  return;
@@ -34,22 +32,6 @@ export function registerForkCommands(
34
32
  bus.emit("ui:info", { message: `fork: rewound to ${target.id}` });
35
33
  await rebuildChat();
36
34
  });
37
-
38
- ctx.registerCommand("branch", "Show the active branch (root → leaf)", async () => {
39
- const branch = getStore().current().getBranch();
40
- if (branch.length === 0) {
41
- bus.emit("ui:info", { message: "branch: empty" });
42
- return;
43
- }
44
- const lines = branch.map((e) => {
45
- if (e.type === "session") return `[${e.id}] session start (${e.cwd})`;
46
- if (e.type === "compaction") return `[${e.id}] compaction (firstKept=${e.firstKeptId})`;
47
- const msg = (e as { message: AgentMessage }).message;
48
- const text = typeof msg.content === "string" ? msg.content : "";
49
- return `[${e.id}] ${msg.role}: ${text.slice(0, 60)}`;
50
- });
51
- bus.emit("ui:info", { message: `branch (${branch.length} entries):\n${lines.join("\n")}` });
52
- });
53
35
  }
54
36
 
55
37
  export function applyBranchMessages(
@@ -1,93 +1,51 @@
1
1
  import type { ExtensionContext } from "agent-sh/types";
2
2
  import type { MultiSessionStore } from "./multi-session-store.js";
3
3
  import type { Capture } from "./capture.js";
4
- import type { AgentMessage, CompactionEntry } from "./session-store.js";
4
+ import type { AgentMessage } from "./session-store.js";
5
5
 
6
6
  const KEEP_RECENT_TOKEN_BUDGET = 20_000;
7
+ const FORCE_KEEP_RECENT_TOKEN_BUDGET = 4_000;
7
8
  const APPROX_TOKENS_PER_CHAR = 0.25;
8
9
 
9
- const SUMMARY_PROMPT = `You are compacting a coding-agent conversation so the agent can continue with limited context.
10
-
11
- Produce a Markdown summary using EXACTLY this structure:
12
-
13
- ## Goal
14
- [What the user is trying to accomplish, one or two sentences]
15
-
16
- ## Constraints & Preferences
17
- - [Bulleted user requirements / preferences expressed so far]
18
-
19
- ## Progress
20
- ### Done
21
- - [x] [Completed work]
22
-
23
- ### In Progress
24
- - [ ] [Active work and current sub-goal]
25
-
26
- ### Blocked
27
- - [Issues, or "None"]
28
-
29
- ## Key Decisions
30
- - **[Decision]**: [Rationale]
31
-
32
- ## Next Steps
33
- 1. [What should happen next]
34
-
35
- ## Critical Context
36
- - [Specific paths, names, identifiers, or data the agent must remember]
37
-
38
- Be concrete. Quote file paths, function names, error strings verbatim when relevant. Do not invent details that aren't in the conversation.`;
10
+ export function pickBudget(opts: { force?: boolean; target?: number } | undefined): number {
11
+ if (opts?.force) return FORCE_KEEP_RECENT_TOKEN_BUDGET;
12
+ const target = opts?.target ?? 0;
13
+ if (target > 0) return Math.max(target, FORCE_KEEP_RECENT_TOKEN_BUDGET);
14
+ return KEEP_RECENT_TOKEN_BUDGET;
15
+ }
39
16
 
40
17
  export function registerCompaction(
41
18
  ctx: ExtensionContext,
42
19
  getStore: () => MultiSessionStore,
43
20
  capture: Capture,
44
21
  ): void {
45
- ctx.advise("conversation:compact", async (next: (...a: unknown[]) => unknown, opts: unknown) => {
46
- const llm = ctx.agent?.llm;
47
- if (!llm?.available) return next(opts);
22
+ ctx.define("ashi:compact:build-summary", (_evicted: AgentMessage[]): string | null => null);
48
23
 
24
+ ctx.advise("conversation:compact", async (_next: (...a: unknown[]) => unknown, opts: unknown) => {
49
25
  await capture.flush();
50
26
  const messages = ctx.call("conversation:get-messages") as AgentMessage[] | undefined;
51
- if (!messages || messages.length < 6) return next(opts);
27
+ if (!messages || messages.length < 4) return null;
52
28
 
53
- const cutIdx = findCutPoint(messages, KEEP_RECENT_TOKEN_BUDGET);
54
- if (cutIdx < 2) return next(opts);
29
+ const o = (opts ?? {}) as { force?: boolean; target?: number };
30
+ const budget = pickBudget(o);
31
+ const minCut = o.force ? 1 : 2;
32
+ const cutIdx = findCutPoint(messages, budget);
33
+ if (cutIdx < minCut) return null;
55
34
 
56
35
  const firstKeptId = capture.getEntryIdAt(cutIdx);
57
36
  if (!firstKeptId) {
58
- ctx.bus.emit("ui:error", { message: "compaction: kept-message has no on-disk entry; falling back" });
59
- return next(opts);
37
+ ctx.bus.emit("ui:error", { message: "compaction: kept-message has no on-disk entry; aborting" });
38
+ return null;
60
39
  }
61
40
 
62
41
  const older = messages.slice(0, cutIdx);
63
42
  const kept = messages.slice(cutIdx);
64
-
65
- const branch = getStore().current().getBranch();
66
- const prevCompaction = [...branch].reverse().find((e) => e.type === "compaction") as CompactionEntry | undefined;
67
- const prevSummary = prevCompaction?.summary;
68
-
69
43
  const tokensBefore = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
44
+ const customSummary = (await ctx.call("ashi:compact:build-summary", older)) as string | null | undefined;
70
45
 
71
- let summary: string;
72
- try {
73
- summary = await llm.ask({
74
- system: SUMMARY_PROMPT,
75
- query: buildQuery(older, prevSummary),
76
- maxTokens: 16384,
77
- reasoningEffort: "low",
78
- });
79
- } catch (e) {
80
- ctx.bus.emit("ui:error", { message: `compaction: LLM failed (${(e as Error).message}); falling back` });
81
- return next(opts);
82
- }
83
-
84
- await getStore().current().appendCompaction(summary.trim(), firstKeptId, tokensBefore);
85
-
86
- const summaryMessage: AgentMessage = {
87
- role: "user",
88
- content: `[Compacted conversation summary]\n${summary.trim()}`,
89
- };
90
- ctx.call("conversation:replace-messages", [summaryMessage, ...kept]);
46
+ const store = getStore().current();
47
+ await store.appendCompaction(firstKeptId, tokensBefore, customSummary ?? undefined);
48
+ ctx.call("conversation:replace-messages", store.buildMessages());
91
49
 
92
50
  const keptIds = kept.map((_, i) => capture.getEntryIdAt(cutIdx + i));
93
51
  if (keptIds.some((id) => id === null)) {
@@ -96,13 +54,11 @@ export function registerCompaction(
96
54
  capture.resetTo([null, ...keptIds]);
97
55
 
98
56
  const tokensAfter = (ctx.call("conversation:estimate-prompt-tokens") as number) ?? 0;
99
- ctx.bus.emit("ui:info", { message: `compacted ${older.length} messages: ${tokensBefore} → ${tokensAfter} tokens` });
100
-
101
57
  return { before: tokensBefore, after: tokensAfter, evictedCount: older.length };
102
58
  });
103
59
  }
104
60
 
105
- function findCutPoint(messages: AgentMessage[], tokenBudget: number): number {
61
+ export function findCutPoint(messages: AgentMessage[], tokenBudget: number): number {
106
62
  let acc = 0;
107
63
  for (let i = messages.length - 1; i >= 0; i--) {
108
64
  acc += estimateMessageTokens(messages[i]!);
@@ -115,43 +71,16 @@ function findCutPoint(messages: AgentMessage[], tokenBudget: number): number {
115
71
  return 0;
116
72
  }
117
73
 
118
- function isSafeCutPoint(messages: AgentMessage[], idx: number): boolean {
74
+ export function isSafeCutPoint(messages: AgentMessage[], idx: number): boolean {
119
75
  const m = messages[idx];
120
76
  if (!m) return true;
121
77
  if (m.role === "tool") return false;
122
78
  return !(m.role === "assistant" && m.tool_calls && m.tool_calls.length > 0);
123
79
  }
124
80
 
125
- function estimateMessageTokens(m: AgentMessage): number {
81
+ export function estimateMessageTokens(m: AgentMessage): number {
126
82
  let chars = 0;
127
83
  if (typeof m.content === "string") chars += m.content.length;
128
84
  if (m.tool_calls) for (const t of m.tool_calls) chars += (t.function?.arguments?.length ?? 0);
129
85
  return Math.ceil(chars * APPROX_TOKENS_PER_CHAR) + 20;
130
86
  }
131
-
132
- function buildQuery(messages: AgentMessage[], prevSummary?: string): string {
133
- const lines: string[] = [];
134
- if (prevSummary) lines.push("Previous compaction summary (continue iteratively):\n", prevSummary, "\n---\n");
135
- lines.push("Conversation to summarize:");
136
- for (const m of messages) {
137
- const text = typeof m.content === "string" ? m.content : "";
138
- if (m.role === "user") lines.push(`[User]: ${text}`);
139
- else if (m.role === "assistant") {
140
- if (text) lines.push(`[Assistant]: ${text}`);
141
- if (m.tool_calls) {
142
- for (const t of m.tool_calls) {
143
- const args = t.function?.arguments ?? "";
144
- lines.push(`[Assistant tool call]: ${t.function?.name ?? "?"}(${truncate(args, 400)})`);
145
- }
146
- }
147
- } else if (m.role === "tool") {
148
- lines.push(`[Tool result]: ${truncate(text, 2000)}`);
149
- }
150
- }
151
- return lines.join("\n");
152
- }
153
-
154
- function truncate(s: string, max: number): string {
155
- if (s.length <= max) return s;
156
- return s.slice(0, max) + `\n[…truncated ${s.length - max} chars…]`;
157
- }