agent-sh 0.14.1 → 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.
- package/dist/agent/agent-loop.d.ts +1 -1
- package/dist/agent/agent-loop.js +42 -31
- package/dist/agent/conversation-state.d.ts +3 -2
- package/dist/agent/conversation-state.js +20 -3
- package/dist/agent/events.d.ts +2 -0
- package/dist/agent/host-types.d.ts +3 -0
- package/dist/agent/index.js +2 -1
- package/dist/agent/subagent.d.ts +1 -1
- package/dist/agent/subagent.js +5 -1
- package/dist/agent/tool-protocol.d.ts +2 -2
- package/dist/agent/tool-protocol.js +5 -4
- package/dist/agent/tools/glob.d.ts +1 -1
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.d.ts +1 -1
- package/dist/agent/tools/grep.js +4 -2
- package/dist/agent/tools/ls.d.ts +1 -1
- package/dist/agent/tools/ls.js +4 -2
- package/dist/agent/tools/read-file.d.ts +1 -1
- package/dist/agent/tools/read-file.js +30 -2
- package/dist/agent/types.d.ts +11 -1
- package/dist/agent/types.js +6 -1
- package/dist/cli/index.js +0 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/settings.d.ts +3 -0
- package/dist/core/settings.js +2 -2
- package/dist/shell/index.d.ts +6 -0
- package/dist/shell/index.js +10 -10
- package/dist/shell/shell.d.ts +4 -0
- package/dist/shell/shell.js +15 -29
- package/dist/shell/terminal.d.ts +33 -0
- package/dist/shell/terminal.js +62 -0
- package/examples/extensions/ash-scheme/index.ts +2170 -0
- package/examples/extensions/ash-scheme/package.json +11 -0
- package/examples/extensions/ash-scheme-render.ts +58 -0
- package/examples/extensions/ashi/README.md +36 -26
- package/examples/extensions/ashi/package.json +9 -1
- package/examples/extensions/ashi/src/capture.ts +1 -0
- package/examples/extensions/ashi/src/cli.ts +21 -7
- package/examples/extensions/ashi/src/compaction.ts +25 -96
- package/examples/extensions/ashi/src/components.ts +64 -166
- package/examples/extensions/ashi/src/default-schema-renderers.ts +229 -0
- package/examples/extensions/ashi/src/display-config.ts +21 -22
- package/examples/extensions/ashi/src/frontend.ts +64 -65
- package/examples/extensions/ashi/src/hooks.ts +47 -63
- package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
- package/examples/extensions/ashi/src/schema.ts +407 -0
- package/examples/extensions/ashi/src/session-store.ts +55 -4
- package/examples/extensions/ashi/src/status-footer.ts +27 -6
- package/examples/extensions/ashi-compact-llm.ts +93 -0
- package/examples/extensions/claude-code-bridge/index.ts +2 -0
- package/examples/extensions/opencode-bridge/index.ts +3 -0
- package/examples/extensions/opencode-provider.ts +252 -0
- package/examples/extensions/pi-bridge/index.ts +1 -0
- package/package.json +12 -1
- 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
|
|
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":
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
163
|
+
### Tool hooks — declarative render schema
|
|
164
164
|
|
|
165
|
-
Tool rendering
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
183
|
+
export default function activate(ctx) {
|
|
184
|
+
ctx.define("ashi:render-tool:bash", () => myModel);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
175
187
|
|
|
176
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
|
@@ -110,7 +115,10 @@ async function main(): Promise<void> {
|
|
|
110
115
|
const cwd = process.cwd();
|
|
111
116
|
const cwdSlug = cwd.replace(/\//g, "-").replace(/^-/, "");
|
|
112
117
|
const sessionsDir = path.join(os.homedir(), ".agent-sh", "ashi", "history", cwdSlug, "sessions");
|
|
113
|
-
const
|
|
118
|
+
const resumeId = config.continueLast
|
|
119
|
+
? MultiSessionStore.readLastSessionId(sessionsDir, { fallbackToLatest: true })
|
|
120
|
+
: undefined;
|
|
121
|
+
const store = new MultiSessionStore(sessionsDir, cwd, { resumeSessionId: resumeId });
|
|
114
122
|
const getStore = (): MultiSessionStore => store;
|
|
115
123
|
|
|
116
124
|
const core = createCore({ ...config, history: new NoopHistory() });
|
|
@@ -145,7 +153,7 @@ async function main(): Promise<void> {
|
|
|
145
153
|
const capture = registerCapture(ctx, getStore);
|
|
146
154
|
registerCompaction(ctx, getStore, capture);
|
|
147
155
|
registerRenderDefaults(ctx);
|
|
148
|
-
|
|
156
|
+
registerDefaultSchemaRenderers(ctx);
|
|
149
157
|
|
|
150
158
|
ctx.advise("conversation:format-prior-history", () => null);
|
|
151
159
|
ctx.advise("system-prompt:build", (next) => `${next()}\n\n<cwd>${process.cwd()}</cwd>`);
|
|
@@ -159,6 +167,12 @@ async function main(): Promise<void> {
|
|
|
159
167
|
rebuildChat: handle.rebuildChat,
|
|
160
168
|
});
|
|
161
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
|
+
|
|
162
176
|
await core.activateBackend(config.backend ?? getSettings().defaultBackend);
|
|
163
177
|
|
|
164
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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 <
|
|
27
|
+
if (!messages || messages.length < 4) return null;
|
|
52
28
|
|
|
53
|
-
const
|
|
54
|
-
|
|
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;
|
|
59
|
-
return
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
}
|