agent-sh 0.14.0 → 0.14.2

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 (64) hide show
  1. package/README.md +7 -18
  2. package/dist/agent/agent-loop.d.ts +1 -1
  3. package/dist/agent/agent-loop.js +42 -31
  4. package/dist/agent/conversation-state.d.ts +3 -2
  5. package/dist/agent/conversation-state.js +20 -3
  6. package/dist/agent/events.d.ts +2 -0
  7. package/dist/agent/host-types.d.ts +3 -0
  8. package/dist/agent/index.js +2 -1
  9. package/dist/agent/llm-client.js +1 -0
  10. package/dist/agent/subagent.d.ts +1 -1
  11. package/dist/agent/subagent.js +5 -1
  12. package/dist/agent/tool-protocol.d.ts +2 -2
  13. package/dist/agent/tool-protocol.js +5 -4
  14. package/dist/agent/tools/glob.d.ts +1 -1
  15. package/dist/agent/tools/glob.js +4 -2
  16. package/dist/agent/tools/grep.d.ts +1 -1
  17. package/dist/agent/tools/grep.js +4 -2
  18. package/dist/agent/tools/ls.d.ts +1 -1
  19. package/dist/agent/tools/ls.js +4 -2
  20. package/dist/agent/tools/read-file.d.ts +1 -1
  21. package/dist/agent/tools/read-file.js +30 -2
  22. package/dist/agent/types.d.ts +13 -3
  23. package/dist/agent/types.js +6 -1
  24. package/dist/cli/args.js +3 -1
  25. package/dist/cli/index.js +0 -0
  26. package/dist/cli/install.d.ts +1 -0
  27. package/dist/cli/install.js +86 -2
  28. package/dist/cli/subcommands.js +4 -1
  29. package/dist/core/index.d.ts +1 -1
  30. package/dist/core/settings.d.ts +3 -0
  31. package/dist/core/settings.js +2 -2
  32. package/dist/shell/index.d.ts +6 -0
  33. package/dist/shell/index.js +10 -10
  34. package/dist/shell/shell.d.ts +4 -0
  35. package/dist/shell/shell.js +15 -29
  36. package/dist/shell/terminal.d.ts +33 -0
  37. package/dist/shell/terminal.js +62 -0
  38. package/dist/utils/tool-interactive.js +4 -2
  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 +25 -8
  46. package/examples/extensions/ashi/src/compaction.ts +25 -96
  47. package/examples/extensions/ashi/src/components.ts +64 -166
  48. package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
  49. package/examples/extensions/ashi/src/display-config.ts +21 -22
  50. package/examples/extensions/ashi/src/frontend.ts +64 -65
  51. package/examples/extensions/ashi/src/hooks.ts +47 -63
  52. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  53. package/examples/extensions/ashi/src/schema.ts +407 -0
  54. package/examples/extensions/ashi/src/session-store.ts +55 -4
  55. package/examples/extensions/ashi/src/status-footer.ts +27 -6
  56. package/examples/extensions/ashi-compact-llm.ts +93 -0
  57. package/examples/extensions/claude-code-bridge/index.ts +9 -2
  58. package/examples/extensions/claude-code-bridge/package.json +1 -1
  59. package/examples/extensions/opencode-bridge/index.ts +208 -53
  60. package/examples/extensions/opencode-bridge/package.json +1 -1
  61. package/examples/extensions/opencode-provider.ts +252 -0
  62. package/examples/extensions/pi-bridge/index.ts +1 -0
  63. package/package.json +12 -1
  64. 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.4",
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(); });
@@ -11,21 +11,22 @@ import type { AppConfig } from "agent-sh/types";
11
11
 
12
12
  import { mountAshi } from "./frontend.js";
13
13
  import { MultiSessionStore } from "./multi-session-store.js";
14
- import { registerForkCommands } from "./commands.js";
14
+ import { registerForkCommands, applyBranchMessages } from "./commands.js";
15
15
  import { registerSessionCommands } from "./session-commands.js";
16
16
  import { registerCompaction } from "./compaction.js";
17
17
  import { registerCapture } from "./capture.js";
18
18
  import { registerRenderDefaults } from "./hooks.js";
19
- import { registerDefaultToolRenderers } from "./default-renderers.js";
19
+ import { registerDefaultSchemaRenderers } from "./default-schema-renderers.js";
20
20
  import * as os from "node:os";
21
21
  import * as path from "node:path";
22
22
 
23
- function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
23
+ function parseArgs(argv: string[]): AppConfig & { extensions?: string[]; continueLast: boolean } {
24
24
  let model: string | undefined;
25
25
  let apiKey: string | undefined = process.env.OPENAI_API_KEY ?? process.env.OPENROUTER_API_KEY;
26
26
  let baseURL: string | undefined = process.env.OPENAI_BASE_URL;
27
27
  let provider: string | undefined;
28
28
  let backend: string | undefined;
29
+ let continueLast = false;
29
30
  const extensions: string[] = [];
30
31
 
31
32
  for (let i = 0; i < argv.length; i++) {
@@ -37,13 +38,15 @@ function parseArgs(argv: string[]): AppConfig & { extensions?: string[] } {
37
38
  else if (a === "--backend" && argv[i + 1]) backend = argv[++i];
38
39
  else if ((a === "-e" || a === "--extensions") && argv[i + 1]) {
39
40
  extensions.push(...argv[++i]!.split(",").map(s => s.trim()).filter(Boolean));
41
+ } else if (a === "-c" || a === "--continue") {
42
+ continueLast = true;
40
43
  } else if (a === "-h" || a === "--help") {
41
44
  process.stdout.write(MANAGEMENT_HELP + "\n");
42
45
  process.exit(0);
43
46
  }
44
47
  }
45
48
 
46
- return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions };
49
+ return { shell: "/bin/sh", model, apiKey, baseURL, provider, backend, extensions, continueLast };
47
50
  }
48
51
 
49
52
  const MANAGEMENT_HELP = `ashi — ash (agent-sh's built-in agent) in an interactive TUI
@@ -59,7 +62,9 @@ Management:
59
62
 
60
63
  Launch (default):
61
64
  ashi [--provider <name>] [--model <id>] [--api-key <key>] [--base-url <url>]
62
- [--backend <name>] [-e <ext>[,<ext>...]]
65
+ [--backend <name>] [-e <ext>[,<ext>...]] [-c | --continue]
66
+
67
+ -c, --continue Resume the last session in this cwd (fresh session if none)
63
68
 
64
69
  Reads ~/.agent-sh/settings.json for providers and defaults.`;
65
70
 
@@ -70,7 +75,10 @@ async function main(): Promise<void> {
70
75
 
71
76
  if (sub === "install" || sub === "uninstall" || sub === "list") {
72
77
  const { runInstall, runUninstall, runList } = await import("agent-sh/cli/install");
73
- if (sub === "install") await runInstall(rest[0] ?? "", { force: rest.includes("--force") });
78
+ if (sub === "install") await runInstall(rest[0] ?? "", {
79
+ force: rest.includes("--force"),
80
+ syncDeps: rest.includes("--sync-deps"),
81
+ });
74
82
  else if (sub === "uninstall") await runUninstall(rest[0] ?? "");
75
83
  else runList();
76
84
  process.exit(0);
@@ -107,7 +115,10 @@ async function main(): Promise<void> {
107
115
  const cwd = process.cwd();
108
116
  const cwdSlug = cwd.replace(/\//g, "-").replace(/^-/, "");
109
117
  const sessionsDir = path.join(os.homedir(), ".agent-sh", "ashi", "history", cwdSlug, "sessions");
110
- const store = new MultiSessionStore(sessionsDir, cwd);
118
+ const resumeId = config.continueLast
119
+ ? MultiSessionStore.readLastSessionId(sessionsDir, { fallbackToLatest: true })
120
+ : undefined;
121
+ const store = new MultiSessionStore(sessionsDir, cwd, { resumeSessionId: resumeId });
111
122
  const getStore = (): MultiSessionStore => store;
112
123
 
113
124
  const core = createCore({ ...config, history: new NoopHistory() });
@@ -142,7 +153,7 @@ async function main(): Promise<void> {
142
153
  const capture = registerCapture(ctx, getStore);
143
154
  registerCompaction(ctx, getStore, capture);
144
155
  registerRenderDefaults(ctx);
145
- registerDefaultToolRenderers(ctx);
156
+ registerDefaultSchemaRenderers(ctx);
146
157
 
147
158
  ctx.advise("conversation:format-prior-history", () => null);
148
159
  ctx.advise("system-prompt:build", (next) => `${next()}\n\n<cwd>${process.cwd()}</cwd>`);
@@ -156,6 +167,12 @@ async function main(): Promise<void> {
156
167
  rebuildChat: handle.rebuildChat,
157
168
  });
158
169
 
170
+ if (resumeId) {
171
+ applyBranchMessages(ctx, getStore, capture);
172
+ await handle.rebuildChat();
173
+ ctx.bus.emit("ui:info", { message: `continued session ${resumeId.slice(0, 12)}…` });
174
+ }
175
+
159
176
  await core.activateBackend(config.backend ?? getSettings().defaultBackend);
160
177
 
161
178
  process.on("SIGTERM", cleanup);
@@ -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
- }