agent-sh 0.15.0 → 0.15.1

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 (116) hide show
  1. package/docs/README.md +14 -0
  2. package/docs/agent.md +398 -0
  3. package/docs/architecture.md +196 -0
  4. package/docs/context-management.md +200 -0
  5. package/docs/extensions.md +951 -0
  6. package/docs/library.md +84 -0
  7. package/docs/troubleshooting.md +65 -0
  8. package/docs/tui-composition.md +294 -0
  9. package/docs/usage.md +306 -0
  10. package/examples/extensions/ash-scheme/package.json +1 -1
  11. package/examples/extensions/ashi/EXTENDING.md +2 -2
  12. package/examples/extensions/ashi/README.md +2 -2
  13. package/examples/extensions/ashi/docs/ui-surface-protocol.md +1 -1
  14. package/examples/extensions/ashi/package.json +5 -3
  15. package/examples/extensions/ashi/src/cli.ts +6 -5
  16. package/examples/extensions/ashi/src/renderer.ts +22 -2
  17. package/examples/extensions/ashi/src/renderers/pi-tui/tool-group.ts +5 -8
  18. package/examples/extensions/ashi-ink/package.json +2 -2
  19. package/examples/extensions/claude-code-bridge/package.json +1 -1
  20. package/examples/extensions/opencode-bridge/package.json +1 -1
  21. package/package.json +3 -1
  22. package/src/agent/agent-loop.ts +1563 -0
  23. package/src/agent/entry-format.ts +19 -0
  24. package/src/agent/events.ts +151 -0
  25. package/src/agent/extensions/rolling-history/constants.ts +1 -0
  26. package/src/agent/extensions/rolling-history/index.ts +202 -0
  27. package/src/agent/extensions/rolling-history/recall.ts +131 -0
  28. package/src/agent/extensions/rolling-history/strategy.ts +404 -0
  29. package/src/agent/host-types.ts +192 -0
  30. package/src/agent/index.ts +591 -0
  31. package/src/agent/live-view.ts +279 -0
  32. package/src/agent/llm-client.ts +111 -0
  33. package/src/agent/llm-facade.ts +43 -0
  34. package/src/agent/normalize-args.ts +61 -0
  35. package/src/agent/nuclear-form.ts +382 -0
  36. package/src/agent/providers/deepseek.ts +39 -0
  37. package/src/agent/providers/ollama.ts +92 -0
  38. package/src/agent/providers/openai-compatible.ts +36 -0
  39. package/src/agent/providers/openai.ts +52 -0
  40. package/src/agent/providers/opencode.ts +142 -0
  41. package/src/agent/providers/openrouter.ts +105 -0
  42. package/src/agent/providers/zai-coding-plan.ts +33 -0
  43. package/src/agent/session-store.ts +336 -0
  44. package/src/agent/skills.ts +228 -0
  45. package/src/agent/store.ts +310 -0
  46. package/src/agent/subagent.ts +305 -0
  47. package/src/agent/system-prompt.ts +151 -0
  48. package/src/agent/token-budget.ts +12 -0
  49. package/src/agent/tool-protocol.ts +722 -0
  50. package/src/agent/tool-registry.ts +66 -0
  51. package/src/agent/tools/bash.ts +95 -0
  52. package/src/agent/tools/edit-file.ts +154 -0
  53. package/src/agent/tools/expand-home.ts +7 -0
  54. package/src/agent/tools/glob.ts +108 -0
  55. package/src/agent/tools/grep.ts +228 -0
  56. package/src/agent/tools/list-skills.ts +37 -0
  57. package/src/agent/tools/ls.ts +81 -0
  58. package/src/agent/tools/pwsh.ts +140 -0
  59. package/src/agent/tools/read-file.ts +164 -0
  60. package/src/agent/tools/write-file.ts +72 -0
  61. package/src/agent/types.ts +149 -0
  62. package/src/cli/args.ts +91 -0
  63. package/src/cli/auth/cli.ts +244 -0
  64. package/src/cli/auth/discover.ts +52 -0
  65. package/src/cli/auth/keys.ts +143 -0
  66. package/src/cli/index.ts +295 -0
  67. package/src/cli/init.ts +74 -0
  68. package/src/cli/install.ts +439 -0
  69. package/src/cli/shell-env.ts +68 -0
  70. package/src/cli/subcommands.ts +24 -0
  71. package/src/core/event-bus.ts +252 -0
  72. package/src/core/extension-loader.ts +347 -0
  73. package/src/core/index.ts +152 -0
  74. package/src/core/settings.ts +398 -0
  75. package/src/core/types.ts +61 -0
  76. package/src/extensions/file-autocomplete.ts +71 -0
  77. package/src/extensions/index.ts +38 -0
  78. package/src/extensions/slash-commands/events.ts +14 -0
  79. package/src/extensions/slash-commands/index.ts +269 -0
  80. package/src/shell/events.ts +73 -0
  81. package/src/shell/host-types.ts +150 -0
  82. package/src/shell/index.ts +159 -0
  83. package/src/shell/input-handler.ts +505 -0
  84. package/src/shell/output-parser.ts +156 -0
  85. package/src/shell/shell-context.ts +193 -0
  86. package/src/shell/shell.ts +414 -0
  87. package/src/shell/strategies/bash.ts +83 -0
  88. package/src/shell/strategies/fish.ts +77 -0
  89. package/src/shell/strategies/index.ts +24 -0
  90. package/src/shell/strategies/types.ts +64 -0
  91. package/src/shell/strategies/zsh.ts +92 -0
  92. package/src/shell/terminal.ts +124 -0
  93. package/src/shell/tui-input-view.ts +222 -0
  94. package/src/shell/tui-renderer.ts +1126 -0
  95. package/src/utils/ansi.ts +140 -0
  96. package/src/utils/box-frame.ts +138 -0
  97. package/src/utils/compositor.ts +157 -0
  98. package/src/utils/diff-renderer.ts +829 -0
  99. package/src/utils/diff.ts +244 -0
  100. package/src/utils/executor.ts +305 -0
  101. package/src/utils/file-watcher.ts +110 -0
  102. package/src/utils/floating-panel.ts +1160 -0
  103. package/src/utils/handler-registry.ts +110 -0
  104. package/src/utils/line-editor.ts +636 -0
  105. package/src/utils/markdown.ts +437 -0
  106. package/src/utils/message-utils.ts +113 -0
  107. package/src/utils/package-version.ts +12 -0
  108. package/src/utils/palette.ts +64 -0
  109. package/src/utils/ref-counter.ts +9 -0
  110. package/src/utils/ripgrep-path.ts +17 -0
  111. package/src/utils/shell-output-spill.ts +76 -0
  112. package/src/utils/stream-transform.ts +292 -0
  113. package/src/utils/terminal-buffer.ts +213 -0
  114. package/src/utils/tool-display.ts +315 -0
  115. package/src/utils/tool-interactive.ts +71 -0
  116. package/src/utils/tty.ts +14 -0
@@ -0,0 +1,19 @@
1
+ /** Display line for synthetic summary blocks and conversation_recall.
2
+ * The leading `#${id}` is the token the LLM uses to reference an
3
+ * entry when calling `recall:expand`. */
4
+ import type { Entry } from "./store.js";
5
+
6
+ interface SummaryPayload {
7
+ sum?: string;
8
+ why?: string;
9
+ }
10
+
11
+ export function formatEntryLine(e: Entry): string {
12
+ const d = new Date(e.ts);
13
+ const pad = (n: number) => String(n).padStart(2, "0");
14
+ const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
15
+ const p = e.payload as SummaryPayload;
16
+ const sum = p.sum ?? `(${e.kind})`;
17
+ const whyTag = p.why ? ` {${p.why.length > 80 ? p.why.slice(0, 77) + "..." : p.why}}` : "";
18
+ return `#${e.id} [${stamp}] ${sum}${whyTag}`;
19
+ }
@@ -0,0 +1,151 @@
1
+ /** Agent-protocol events. Speak this to be a backend; consume it to be a frontend. */
2
+ import type { ContentBlock } from "../core/event-bus.js";
3
+ import type { Model, ProviderRegistration } from "./host-types.js";
4
+ import type { ImageContent, ToolDefinition, ToolResultDisplay } from "./types.js";
5
+
6
+ export interface AgentIdentity {
7
+ name: string;
8
+ version: string;
9
+ model?: string;
10
+ provider?: string;
11
+ contextWindow?: number;
12
+ }
13
+
14
+ declare module "../core/event-bus.js" {
15
+ interface BusEvents {
16
+ /** Sync pipe: extensions append core tool names; unioned with settings.coreTools. */
17
+ "agent:core-tools:collect": { names: string[] };
18
+
19
+ "agent:providers": { providers: ProviderRegistration[] };
20
+ "agent:providers:changed": Record<string, never>;
21
+ "provider:configure": {
22
+ id: string;
23
+ reasoningParams?: (level: string, model?: string) => Record<string, unknown>;
24
+ cacheTokens?: (usage: Record<string, unknown>) => number | undefined;
25
+ };
26
+
27
+ "agent:models-changed": Record<string, never>;
28
+ "config:switch-provider": { provider: string };
29
+
30
+ "agent:info": AgentIdentity;
31
+ "agent:tools": { tools: ToolDefinition[] };
32
+ "agent:instructions": { instructions: Array<{ name: string; text: string }> };
33
+ "agent:skills": { skills: Array<{ name: string; description: string; filePath: string }> };
34
+
35
+ "agent:submit": { query: string; images?: ImageContent[] };
36
+ "agent:cancel-request": { silent?: boolean };
37
+ "agent:append-user-message": { text: string };
38
+ "agent:query": { query: string };
39
+ "agent:reset-session": Record<string, never>;
40
+ "agent:compact-request": Record<string, never>;
41
+
42
+ "agent:thinking-chunk": { text: string };
43
+ "agent:response-chunk": { blocks: ContentBlock[] };
44
+ "agent:response-done": { response: string };
45
+ "agent:usage": { prompt_tokens: number; completion_tokens: number; total_tokens: number; cached_prompt_tokens?: number };
46
+
47
+ "agent:processing-start": Record<string, never>;
48
+ "agent:processing-done": Record<string, never>;
49
+ "agent:cancelled": Record<string, never>;
50
+ "agent:error": { message: string };
51
+
52
+ "agent:tool-call": { tool: string; args: Record<string, unknown> };
53
+ "agent:tool-output": {
54
+ tool: string;
55
+ output: string;
56
+ exitCode: number | null;
57
+ };
58
+ "agent:tool-batch": {
59
+ groups: Array<{
60
+ kind: string;
61
+ tools: Array<{ name: string; displayDetail?: string }>;
62
+ }>;
63
+ };
64
+ "agent:tool-batch-complete": {
65
+ results: Array<{ name: string; isError: boolean; errorSummary?: string }>;
66
+ };
67
+ "agent:tool-started": {
68
+ title: string;
69
+ /** Canonical tool name; `title` is the display label and may differ. */
70
+ name?: string;
71
+ toolCallId?: string;
72
+ kind?: string;
73
+ icon?: string;
74
+ locations?: { path: string; line?: number | null }[];
75
+ rawInput?: unknown;
76
+ displayDetail?: string;
77
+ /** highlight.js identifier for rawInput.source. */
78
+ sourceLanguage?: string;
79
+ batchIndex?: number;
80
+ batchTotal?: number;
81
+ };
82
+ "agent:tool-completed": {
83
+ toolCallId?: string;
84
+ exitCode: number | null;
85
+ rawOutput?: unknown;
86
+ kind?: string;
87
+ resultDisplay?: ToolResultDisplay;
88
+ };
89
+ "agent:tool-output-chunk": { chunk: string };
90
+
91
+ "tool:interactive-start": Record<string, never>;
92
+ "tool:interactive-end": Record<string, never>;
93
+
94
+ "agent:subagent-started": { taskId: string; task: string };
95
+ "agent:subagent-completed": { taskId: string; task: string; result: string; isError: boolean };
96
+
97
+ "agent:terminal-intercept": {
98
+ command: string;
99
+ cwd: string;
100
+ intercepted: boolean;
101
+ output: string;
102
+ };
103
+
104
+ "conversation:message-appended": {
105
+ role: "user" | "assistant" | "tool" | "system";
106
+ content: string;
107
+ toolName?: string;
108
+ toolArgs?: Record<string, unknown>;
109
+ isError?: boolean;
110
+ };
111
+ "conversation:after-compact": {
112
+ beforeTokens: number;
113
+ afterTokens: number;
114
+ evictedCount: number;
115
+ };
116
+
117
+ "context:get-stats": {
118
+ activeTokens: number;
119
+ totalTokens: number;
120
+ budgetTokens: number;
121
+ };
122
+ "context:snapshot": {
123
+ messages: unknown[];
124
+ contextWindow: number;
125
+ activeTokens: number;
126
+ };
127
+ "context:compact": {
128
+ strategy?:
129
+ | { kind: "two-tier-pin"; target: number; keepRecent?: number; force?: boolean }
130
+ | { kind: "rewind"; toIndex: number }
131
+ | { kind: "replace"; messages: unknown[] };
132
+ stats?: { before: number; after: number; evictedCount: number };
133
+ };
134
+
135
+ "config:switch-model": { id: string; provider: string };
136
+ "config:get-models": { models: Model[]; active: Model | null };
137
+ "config:set-thinking": { level: string };
138
+ "config:get-thinking": { level: string; levels: string[]; supported: boolean };
139
+
140
+ "llm:request": {
141
+ messages: unknown[];
142
+ tools?: unknown;
143
+ model?: string;
144
+ max_tokens?: number;
145
+ reasoning_effort?: string;
146
+ };
147
+ "llm:chunk": { chunk: unknown };
148
+ }
149
+ }
150
+
151
+ export {};
@@ -0,0 +1 @@
1
+ export const RECALL_CACHE_KIND = "recall-cache";
@@ -0,0 +1,202 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionContext } from "../../../shell/host-types.js";
4
+ import type { AgentShMessage } from "../../llm-client.js";
5
+ import { contentText, type ToolDefinition } from "../../types.js";
6
+ import { SharedFileStore, newEntryId, type Store, type Entry } from "../../store.js";
7
+ import { CONFIG_DIR, getSettings } from "../../../core/settings.js";
8
+ import { deserializeEntry, isReadOnly } from "../../nuclear-form.js";
9
+ import {
10
+ activate as activateSummaryStrategy,
11
+ nuclearToEntry,
12
+ readSummaryLines,
13
+ type SummaryCtx,
14
+ } from "./strategy.js";
15
+ import { recallSearch, recallExpand, recallBrowse } from "./recall.js";
16
+
17
+ const TOOL_NAME = "conversation_recall";
18
+ const INSTRUCTION_NAME = "recall-guidance";
19
+ const INSTRUCTION_TEXT =
20
+ "When starting a task that may have been discussed before (conventions, preferences, corrections, prior examples), " +
21
+ "use conversation_recall to search history for relevant prior entries. " +
22
+ "Treat recurring user guidance as standing preferences. " +
23
+ "If a search returns nothing useful, try: shorter queries, alternate terms, or browse to scan the full timeline. " +
24
+ "Recall only covers this and recent sessions — for older context, also search the filesystem (grep, glob).";
25
+
26
+ /** One-time migration: old ~/.agent-sh/history → rolling-history store. */
27
+ export function migrateFromLegacy(
28
+ storeDir: string,
29
+ legacyPath: string,
30
+ ctx: Pick<ExtensionContext, "bus">,
31
+ ): void {
32
+ const sentinel = path.join(storeDir, ".migrated");
33
+ if (fs.existsSync(sentinel)) return;
34
+
35
+ const newFile = path.join(storeDir, "history.jsonl");
36
+ if (fs.existsSync(newFile) && fs.statSync(newFile).size > 0) {
37
+ try { fs.writeFileSync(sentinel, ""); } catch { /* ignore */ }
38
+ return;
39
+ }
40
+
41
+ if (!fs.existsSync(legacyPath)) {
42
+ try { fs.writeFileSync(sentinel, ""); } catch { /* ignore */ }
43
+ return;
44
+ }
45
+
46
+ let migrated = 0;
47
+ try {
48
+ const lines = fs.readFileSync(legacyPath, "utf-8").split("\n").filter(Boolean);
49
+ const entries: Entry[] = [];
50
+ for (const line of lines) {
51
+ const ne = deserializeEntry(line);
52
+ if (!ne) continue;
53
+ if (isReadOnly(ne)) continue;
54
+ entries.push(nuclearToEntry(ne, newEntryId()));
55
+ }
56
+ if (entries.length > 0) {
57
+ fs.writeFileSync(newFile, entries.map((e) => JSON.stringify(e) + "\n").join(""));
58
+ migrated = entries.length;
59
+ }
60
+ } catch {
61
+ return; // retry next start
62
+ }
63
+
64
+ try { fs.writeFileSync(sentinel, ""); } catch { /* ignore */ }
65
+ if (migrated > 0) {
66
+ ctx.bus.emit("ui:info", { message: `history: migrated ${migrated} entries from legacy ~/.agent-sh/history` });
67
+ }
68
+ }
69
+
70
+ export default function activate(ctx: ExtensionContext): void {
71
+ const { maxBytes, prefetchEntries } = ctx.getExtensionSettings("rolling-history", {
72
+ maxBytes: undefined as number | undefined,
73
+ prefetchEntries: 50,
74
+ });
75
+ const storeDir = ctx.getStoragePath("rolling-history");
76
+ const settings = getSettings();
77
+ const legacyPath = settings.historyFilePath ?? path.join(CONFIG_DIR, "history");
78
+ migrateFromLegacy(storeDir, legacyPath, ctx);
79
+ const summaryStore = new SharedFileStore({
80
+ filePath: path.join(storeDir, "history.jsonl"),
81
+ maxBytes,
82
+ });
83
+
84
+ // `/history off` gates only writes — store.append and the linkMessage
85
+ // back-stamp. Everything else (meta.tool stamping, compact's reorg,
86
+ // recall reads) runs identically on both sides. Tool + instruction stay
87
+ // registered either way so toggling never perturbs the tools array or
88
+ // system prompt (LLM prompt cache is preserved).
89
+ let enabled = true;
90
+ const gatedStore: Store = {
91
+ append: (entries, opts) => enabled ? summaryStore.append(entries, opts) : Promise.resolve(),
92
+ findById: (id) => summaryStore.findById(id),
93
+ readRecent: (n) => summaryStore.readRecent(n),
94
+ search: (q) => summaryStore.search(q),
95
+ };
96
+
97
+ const summaryCtx: SummaryCtx = {
98
+ store: gatedStore,
99
+ bus: { on: (e, f) => ctx.bus.on(e, f) },
100
+ advise: (op, f) => { ctx.advise(op, f as Parameters<typeof ctx.advise>[1]); },
101
+ iid: ctx.instanceId,
102
+ getMessages: () => (ctx.call("conversation:get-messages") as AgentShMessage[] | undefined) ?? [],
103
+ replaceMessages: (msgs) => { ctx.call("conversation:replace-messages", msgs); },
104
+ estimateTokens: () => (ctx.call("conversation:estimate-tokens") as number | undefined) ?? 0,
105
+ estimatePromptTokens: () => (ctx.call("conversation:estimate-prompt-tokens") as number | undefined) ?? 0,
106
+ linkMessage: (index, entryId) => { if (enabled) ctx.call("conversation:link", index, entryId); },
107
+ };
108
+ activateSummaryStrategy(summaryCtx);
109
+
110
+ const toolDef: ToolDefinition = {
111
+ name: TOOL_NAME,
112
+ displayName: "recall",
113
+ description:
114
+ "Browse, search, or expand evicted conversation turns. " +
115
+ "Use when you need context from earlier in the conversation that was compacted away. " +
116
+ "Search is regex-based and covers both summaries and full body text. " +
117
+ "If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline.",
118
+ input_schema: {
119
+ type: "object",
120
+ properties: {
121
+ action: {
122
+ type: "string",
123
+ enum: ["browse", "search", "expand"],
124
+ description: "browse: list evicted turns, search: regex search, expand: show full turn",
125
+ },
126
+ query: { type: "string", description: "Search query (for action=search)" },
127
+ turn_id: { type: "string", description: "Turn ID to expand (for action=expand)" },
128
+ },
129
+ required: ["action"],
130
+ },
131
+ execute: async (args) => {
132
+ const action = args.action as string;
133
+ let content: string;
134
+ if (action === "search") {
135
+ content = await recallSearch(summaryStore, (args.query as string) ?? "");
136
+ } else if (action === "expand") {
137
+ content = await recallExpand(summaryStore, args.turn_id as string);
138
+ } else {
139
+ content = await recallBrowse(summaryStore);
140
+ }
141
+ return { content, exitCode: 0, isError: false };
142
+ },
143
+ formatResult: (args, result) => {
144
+ const action = args.action as string;
145
+ const text = contentText(result.content);
146
+ if (result.isError) return { summary: "error" };
147
+ if (action === "search") {
148
+ if (text.startsWith("No results")) return { summary: "0 matches" };
149
+ const m = text.match(/^Found (\d+)/);
150
+ return { summary: m ? `${m[1]} matches` : "search done" };
151
+ }
152
+ if (action === "browse") {
153
+ if (text.startsWith("No conversation")) return { summary: "empty" };
154
+ return { summary: "browsed" };
155
+ }
156
+ if (text.includes("no expanded content")) return { summary: "not found" };
157
+ return { summary: "expanded" };
158
+ },
159
+ getDisplayInfo: () => ({ kind: "search", icon: "⟲" }),
160
+ };
161
+
162
+ if (ctx.agent) {
163
+ ctx.agent.registerTool(toolDef);
164
+ ctx.agent.registerInstruction(INSTRUCTION_NAME, INSTRUCTION_TEXT);
165
+ }
166
+
167
+ ctx.registerCommand("history", "Toggle conversation history writes (on / off / status).", (args) => {
168
+ const arg = args.trim().toLowerCase();
169
+ if (arg === "" || arg === "status") {
170
+ ctx.bus.emit("ui:info", { message: `history: writes ${enabled ? "on" : "off"} — recall remains available for prior sessions` });
171
+ return;
172
+ }
173
+ if (arg === "on") {
174
+ if (enabled) { ctx.bus.emit("ui:info", { message: "history: already on" }); return; }
175
+ enabled = true;
176
+ ctx.bus.emit("ui:info", { message: "history: on — new turns will be summarized" });
177
+ return;
178
+ }
179
+ if (arg === "off") {
180
+ if (!enabled) { ctx.bus.emit("ui:info", { message: "history: already off" }); return; }
181
+ enabled = false;
182
+ ctx.bus.emit("ui:info", { message: "history: off — new turns won't be summarized (recall still available)" });
183
+ return;
184
+ }
185
+ ctx.bus.emit("ui:info", { message: `history: unknown arg "${arg}" (use on / off / status)` });
186
+ });
187
+
188
+ if (prefetchEntries > 0) {
189
+ Promise.resolve().then(async () => {
190
+ const lines = await readSummaryLines(summaryStore, prefetchEntries);
191
+ if (lines.length === 0) return;
192
+ const current = (ctx.call("conversation:get-messages") as AgentShMessage[] | undefined) ?? [];
193
+ ctx.call("conversation:replace-messages", [
194
+ ...current,
195
+ {
196
+ role: "user",
197
+ content: `[Prior session history — loaded from the summary store]\n${lines.join("\n")}`,
198
+ },
199
+ ]);
200
+ }).catch(() => {});
201
+ }
202
+ }
@@ -0,0 +1,131 @@
1
+ import type { Store, Entry } from "../../store.js";
2
+ import type { AgentShMessage } from "../../llm-client.js";
3
+ import { formatEntryLine } from "../../entry-format.js";
4
+ import { RECALL_CACHE_KIND } from "./constants.js";
5
+ import { readSummaryLines } from "./strategy.js";
6
+
7
+ interface SummaryPayload {
8
+ sum: string;
9
+ body?: string;
10
+ iid?: string;
11
+ tool?: string;
12
+ why?: string;
13
+ }
14
+
15
+ interface RecallCachePayload {
16
+ fullMessage: AgentShMessage;
17
+ }
18
+
19
+ function turnToText(msgs: AgentShMessage[]): string {
20
+ const lines: string[] = [];
21
+ for (const m of msgs) {
22
+ if (m.role === "user") {
23
+ lines.push(`[user] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`);
24
+ } else if (m.role === "assistant") {
25
+ if (typeof m.content === "string" && m.content) lines.push(`[assistant] ${m.content}`);
26
+ if ("tool_calls" in m && m.tool_calls) {
27
+ for (const tc of m.tool_calls) {
28
+ if ("function" in tc) lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments})`);
29
+ }
30
+ }
31
+ } else if (m.role === "tool") {
32
+ lines.push(`[tool] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`);
33
+ } else {
34
+ lines.push(`[${m.role}] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`);
35
+ }
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+
40
+ function firstMatchExcerpt(text: string, regex: RegExp): string | null {
41
+ const idx = text.search(regex);
42
+ if (idx === -1) return null;
43
+ const lineStart = text.lastIndexOf("\n", idx) + 1;
44
+ const lineEnd = text.indexOf("\n", idx);
45
+ const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd).trim();
46
+ if (line.length > 120) {
47
+ const matchInLine = idx - lineStart;
48
+ const start = Math.max(0, matchInLine - 40);
49
+ const end = Math.min(line.length, matchInLine + 80);
50
+ return (start > 0 ? "…" : "") + line.slice(start, end) + (end < line.length ? "…" : "");
51
+ }
52
+ return line;
53
+ }
54
+
55
+ function buildSearchRegex(query: string): RegExp {
56
+ try {
57
+ return new RegExp(query, "i");
58
+ } catch {
59
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
60
+ const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
61
+ const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
62
+ return new RegExp(lookaheads, "i");
63
+ }
64
+ }
65
+
66
+ /** Cache entries are ephemeral, so this only resolves for the
67
+ * current process. */
68
+ async function findCacheChild(store: Store, parentId: string): Promise<RecallCachePayload | null> {
69
+ const recent = await store.readRecent();
70
+ for (let i = recent.length - 1; i >= 0; i--) {
71
+ const e = recent[i]!;
72
+ if (e.kind === RECALL_CACHE_KIND && e.parentId === parentId) {
73
+ return e.payload as unknown as RecallCachePayload;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ export async function recallSearch(store: Store, query: string): Promise<string> {
80
+ if (!query.trim()) return "No query provided.";
81
+ const regex = buildSearchRegex(query);
82
+ const hits: string[] = [];
83
+ const seenParents = new Set<string>();
84
+
85
+ const matches = await store.search(query);
86
+ for (const m of matches) {
87
+ // Cache hits surface via their parent summary; summary hits are their own parent.
88
+ let parentEntry: Entry | null = null;
89
+ if (m.entry.kind === RECALL_CACHE_KIND) {
90
+ if (!m.entry.parentId) continue;
91
+ parentEntry = await store.findById(m.entry.parentId);
92
+ } else {
93
+ parentEntry = m.entry;
94
+ }
95
+ if (!parentEntry || seenParents.has(parentEntry.id)) continue;
96
+ seenParents.add(parentEntry.id);
97
+
98
+ const cache = await findCacheChild(store, parentEntry.id);
99
+ const excerptSource = cache
100
+ ? turnToText([cache.fullMessage])
101
+ : (parentEntry.payload as unknown as SummaryPayload).body ?? "";
102
+ const excerpt = excerptSource ? firstMatchExcerpt(excerptSource, regex) : null;
103
+ const header = formatEntryLine(parentEntry);
104
+ hits.push(excerpt ? `${header}\n ${excerpt}` : header);
105
+ }
106
+
107
+ if (hits.length === 0) return `No results found for "${query}".`;
108
+ const total = hits.length;
109
+ const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
110
+ return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
111
+ }
112
+
113
+ export async function recallExpand(store: Store, id: string): Promise<string> {
114
+ const entry = await store.findById(id);
115
+ if (!entry) return `Entry ${id}: not found.`;
116
+ if (entry.kind === RECALL_CACHE_KIND) return `Entry ${id}: not expandable.`;
117
+ const header = formatEntryLine(entry);
118
+
119
+ const cache = await findCacheChild(store, id);
120
+ if (cache) return `${header}\n\n${turnToText([cache.fullMessage])}`;
121
+
122
+ const body = (entry.payload as unknown as SummaryPayload).body;
123
+ if (body) return `${header}\n\n${body}`;
124
+ return `${header}\n\n(no expanded content available — recall cache may have been cleared)`;
125
+ }
126
+
127
+ export async function recallBrowse(store: Store, limit = 25): Promise<string> {
128
+ const lines = await readSummaryLines(store, limit);
129
+ if (lines.length === 0) return "No conversation history.";
130
+ return ["Recent summary entries:", ...lines.map((l) => ` ${l}`)].join("\n");
131
+ }