@nghyane/arcane 0.1.15 → 0.1.17

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 (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +7 -15
  3. package/src/config/keybindings.ts +9 -7
  4. package/src/config/settings-schema.ts +19 -46
  5. package/src/config/settings.ts +0 -1
  6. package/src/exa/mcp-client.ts +57 -2
  7. package/src/internal-urls/docs-index.generated.ts +1 -2
  8. package/src/internal-urls/index.ts +2 -4
  9. package/src/internal-urls/router.ts +2 -2
  10. package/src/internal-urls/types.ts +2 -2
  11. package/src/mcp/oauth-flow.ts +1 -1
  12. package/src/modes/controllers/command-controller.ts +26 -64
  13. package/src/modes/utils/ui-helpers.ts +2 -1
  14. package/src/patch/hashline.ts +42 -0
  15. package/src/prompts/system/system-prompt.md +14 -10
  16. package/src/prompts/thread-extract.md +16 -0
  17. package/src/prompts/tools/render-mermaid.md +9 -0
  18. package/src/sdk.ts +1 -19
  19. package/src/session/agent-session.ts +4 -3
  20. package/src/session/retry-utils.ts +1 -1
  21. package/src/session/session-index.ts +329 -0
  22. package/src/slash-commands/builtin-registry.ts +0 -16
  23. package/src/task/index.ts +1 -1
  24. package/src/tools/ask.ts +9 -6
  25. package/src/tools/bash-skill-urls.ts +3 -3
  26. package/src/tools/create-tools.ts +26 -0
  27. package/src/tools/find-thread.ts +120 -0
  28. package/src/tools/index.ts +5 -0
  29. package/src/tools/read-thread.ts +409 -0
  30. package/src/tools/read.ts +2 -2
  31. package/src/tools/render-mermaid.ts +68 -0
  32. package/src/tools/save-memory.ts +182 -0
  33. package/src/web/search/index.ts +2 -0
  34. package/src/web/search/provider.ts +3 -0
  35. package/src/web/search/providers/anthropic.ts +1 -0
  36. package/src/web/search/providers/gemini.ts +122 -37
  37. package/src/web/search/providers/kagi.ts +163 -0
  38. package/src/web/search/types.ts +1 -0
  39. package/src/internal-urls/memory-protocol.ts +0 -133
  40. package/src/memories/index.ts +0 -1099
  41. package/src/memories/storage.ts +0 -563
  42. package/src/prompts/memories/consolidation.md +0 -30
  43. package/src/prompts/memories/read_path.md +0 -11
  44. package/src/prompts/memories/stage_one_input.md +0 -6
  45. package/src/prompts/memories/stage_one_system.md +0 -21
@@ -0,0 +1,409 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
4
+ import type { Api, Model } from "@nghyane/arcane-ai";
5
+ import { completeSimple } from "@nghyane/arcane-ai";
6
+ import type { Component } from "@nghyane/arcane-tui";
7
+ import { Text } from "@nghyane/arcane-tui";
8
+ import { logger, parseJsonlLenient } from "@nghyane/arcane-utils";
9
+ import { getSessionsDir } from "@nghyane/arcane-utils/dirs";
10
+ import { type Static, Type } from "@sinclair/typebox";
11
+ import { parseModelString } from "../config/model-resolver";
12
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
+ import extractPrompt from "../prompts/thread-extract.md" with { type: "text" };
14
+ import type { Theme } from "../theme/theme";
15
+ import { renderStatusLine } from "../tui";
16
+ import { PREVIEW_LIMITS, truncateToWidth } from "../ui/render-utils";
17
+ import type { ToolSession } from ".";
18
+
19
+ const readThreadSchema = Type.Object({
20
+ threadId: Type.String({ description: "Session/thread ID to read" }),
21
+ goal: Type.String({ description: "What information to extract from the thread. Be specific." }),
22
+ });
23
+
24
+ type ReadThreadParams = Static<typeof readThreadSchema>;
25
+
26
+ export interface ReadThreadToolDetails {
27
+ threadId: string;
28
+ goal: string;
29
+ title?: string;
30
+ originalLength: number;
31
+ extractedLength: number;
32
+ compressionRatio: number;
33
+ }
34
+
35
+ interface ReadThreadRenderArgs {
36
+ threadId?: string;
37
+ goal?: string;
38
+ }
39
+
40
+ interface MessageContent {
41
+ type?: string;
42
+ text?: string;
43
+ name?: string;
44
+ input?: Record<string, unknown>;
45
+ arguments?: Record<string, unknown>;
46
+ toolCallId?: string;
47
+ toolName?: string;
48
+ isError?: boolean;
49
+ }
50
+
51
+ interface RawSessionEntry {
52
+ type?: string;
53
+ id?: string;
54
+ title?: string;
55
+ message?: {
56
+ role?: string;
57
+ content?: string | MessageContent[];
58
+ toolName?: string;
59
+ isError?: boolean;
60
+ };
61
+ }
62
+
63
+ async function findSessionFile(threadId: string): Promise<{ file: string; title?: string } | null> {
64
+ const sessionsDir = getSessionsDir();
65
+ let subdirs: string[];
66
+ try {
67
+ subdirs = fs.readdirSync(sessionsDir);
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ for (const subdir of subdirs) {
73
+ const dirPath = path.join(sessionsDir, subdir);
74
+ let stat: fs.Stats;
75
+ try {
76
+ stat = fs.statSync(dirPath);
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (!stat.isDirectory()) continue;
81
+
82
+ let files: string[];
83
+ try {
84
+ files = fs.readdirSync(dirPath);
85
+ } catch {
86
+ continue;
87
+ }
88
+
89
+ for (const file of files) {
90
+ if (!file.endsWith(".jsonl")) continue;
91
+ const filePath = path.join(dirPath, file);
92
+ try {
93
+ const fd = fs.openSync(filePath, "r");
94
+ const buf = Buffer.alloc(4096);
95
+ const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
96
+ fs.closeSync(fd);
97
+ const firstLine = buf.subarray(0, bytesRead).toString("utf-8").split("\n")[0];
98
+ if (!firstLine) continue;
99
+ const header = JSON.parse(firstLine) as RawSessionEntry;
100
+ if (header.type === "session" && header.id === threadId) {
101
+ return { file: filePath, title: header.title };
102
+ }
103
+ } catch {}
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function renderSessionMarkdown(entries: RawSessionEntry[]): { markdown: string; turnCount: number } {
110
+ const parts: string[] = [];
111
+ let turnCount = 0;
112
+
113
+ for (const entry of entries) {
114
+ if (entry.type === "session") continue;
115
+ if (entry.type !== "message") continue;
116
+
117
+ const msg = entry.message;
118
+ if (!msg?.role) continue;
119
+ const role = msg.role;
120
+ if (!["user", "assistant", "toolResult"].includes(role)) continue;
121
+
122
+ if (role === "user") {
123
+ turnCount++;
124
+ const text = typeof msg.content === "string" ? msg.content : "";
125
+ parts.push(`## User\n\n${text}\n`);
126
+ } else if (role === "assistant") {
127
+ turnCount++;
128
+ if (typeof msg.content === "string") {
129
+ parts.push(`## Assistant\n\n${msg.content}\n`);
130
+ } else if (Array.isArray(msg.content)) {
131
+ const blocks: string[] = [];
132
+ for (const block of msg.content) {
133
+ if (block.type === "text" && block.text) {
134
+ blocks.push(block.text);
135
+ } else if (block.type === "toolCall" || block.type === "tool_use") {
136
+ const name = block.name ?? "unknown";
137
+ const input = block.arguments ?? block.input;
138
+ let argSummary = "";
139
+ if (input && typeof input === "object") {
140
+ const argParts: string[] = [];
141
+ for (const [k, v] of Object.entries(input)) {
142
+ const val = typeof v === "string" ? v : JSON.stringify(v);
143
+ argParts.push(`${k}: ${val.length > 200 ? `${val.slice(0, 200)}...` : val}`);
144
+ }
145
+ argSummary = argParts.join("\n");
146
+ }
147
+ blocks.push(`**Tool: ${name}**\n${argSummary}`);
148
+ }
149
+ }
150
+ if (blocks.length > 0) {
151
+ parts.push(`## Assistant\n\n${blocks.join("\n\n")}\n`);
152
+ }
153
+ }
154
+ } else if (role === "toolResult") {
155
+ const toolName = msg.toolName ?? "unknown";
156
+ const isError = msg.isError === true;
157
+ if (typeof msg.content === "string") {
158
+ const text = msg.content;
159
+ if (isError) {
160
+ parts.push(`**Error (${toolName}):**\n${text}\n`);
161
+ } else if (text.length > 500) {
162
+ parts.push(
163
+ `**Result (${toolName}):**\n${text.slice(0, 300)}... [truncated, ${text.length} chars total]\n`,
164
+ );
165
+ } else {
166
+ parts.push(`**Result (${toolName}):**\n${text}\n`);
167
+ }
168
+ } else if (Array.isArray(msg.content)) {
169
+ for (const block of msg.content) {
170
+ const text = block.text ?? "";
171
+ if (isError) {
172
+ parts.push(`**Error (${toolName}):**\n${text}\n`);
173
+ } else if (text.length > 500) {
174
+ parts.push(
175
+ `**Result (${toolName}):**\n${text.slice(0, 300)}... [truncated, ${text.length} chars total]\n`,
176
+ );
177
+ } else {
178
+ parts.push(`**Result (${toolName}):**\n${text}\n`);
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ return { markdown: parts.join("\n"), turnCount };
186
+ }
187
+
188
+ function truncateTurns(markdown: string, turnCount: number): string {
189
+ if (turnCount <= 40) return markdown;
190
+
191
+ const lines = markdown.split("\n");
192
+ const turnStarts: number[] = [];
193
+ for (let i = 0; i < lines.length; i++) {
194
+ if (lines[i].startsWith("## User") || lines[i].startsWith("## Assistant")) {
195
+ turnStarts.push(i);
196
+ }
197
+ }
198
+
199
+ if (turnStarts.length <= 40) return markdown;
200
+
201
+ const keepFirst = 20;
202
+ const keepLast = 20;
203
+ const firstEnd = turnStarts[keepFirst];
204
+ const lastStart = turnStarts[turnStarts.length - keepLast];
205
+ const omitted = turnStarts.length - keepFirst - keepLast;
206
+
207
+ const head = lines.slice(0, firstEnd).join("\n");
208
+ const tail = lines.slice(lastStart).join("\n");
209
+ return `${head}\n\n---\n[... ${omitted} turns omitted ...]\n---\n\n${tail}`;
210
+ }
211
+
212
+ export class ReadThreadTool implements AgentTool<typeof readThreadSchema, ReadThreadToolDetails, Theme> {
213
+ readonly name = "read_thread";
214
+ readonly label = "Read Thread";
215
+ description = [
216
+ "Read and extract relevant content from a past conversation thread by its ID.",
217
+ "Uses AI to extract only information relevant to your goal, keeping context concise.",
218
+ "Use find_thread first to discover thread IDs.",
219
+ "",
220
+ 'Goal tips: be specific ("what auth approach was chosen" not "tell me about auth").',
221
+ "",
222
+ "Examples:",
223
+ '- read_thread(id, "Extract the implementation plan and design decisions")',
224
+ '- read_thread(id, "Extract the bug fix, root cause, and relevant code changes")',
225
+ ].join("\n");
226
+ readonly parameters = readThreadSchema;
227
+ readonly concurrency = "shared" as const;
228
+
229
+ constructor(private readonly session: ToolSession) {}
230
+
231
+ async execute(
232
+ _toolCallId: string,
233
+ params: ReadThreadParams,
234
+ _signal?: AbortSignal,
235
+ _onUpdate?: AgentToolUpdateCallback<ReadThreadToolDetails>,
236
+ _context?: AgentToolContext,
237
+ ): Promise<AgentToolResult<ReadThreadToolDetails>> {
238
+ const { threadId, goal } = params;
239
+
240
+ // Find session file
241
+ const found = await findSessionFile(threadId);
242
+ if (!found) {
243
+ return {
244
+ content: [{ type: "text", text: `Thread "${threadId}" not found.` }],
245
+ details: { threadId, goal, originalLength: 0, extractedLength: 0, compressionRatio: 1 },
246
+ };
247
+ }
248
+
249
+ const { file: sessionFile, title } = found;
250
+
251
+ // Load and parse JSONL
252
+ const content = await Bun.file(sessionFile).text();
253
+ const entries = parseJsonlLenient<RawSessionEntry>(content);
254
+
255
+ // Render to markdown
256
+ const { markdown: rawMarkdown, turnCount } = renderSessionMarkdown(entries);
257
+ const markdown = truncateTurns(rawMarkdown, turnCount);
258
+
259
+ if (markdown.length === 0) {
260
+ return {
261
+ content: [{ type: "text", text: `Thread "${threadId}" is empty.` }],
262
+ details: { threadId, goal, title, originalLength: 0, extractedLength: 0, compressionRatio: 1 },
263
+ };
264
+ }
265
+
266
+ // Resolve extraction model
267
+ const registry = this.session.subagentContext?.modelRegistry;
268
+ if (!registry) {
269
+ return {
270
+ content: [{ type: "text", text: `No model registry available. Cannot extract content.` }],
271
+ details: {
272
+ threadId,
273
+ goal,
274
+ title,
275
+ originalLength: markdown.length,
276
+ extractedLength: 0,
277
+ compressionRatio: 0,
278
+ },
279
+ };
280
+ }
281
+
282
+ const fastModelId = this.session.settings.getModelRole("fast") ?? this.session.settings.getModelRole("default");
283
+ const availableModels = registry.getAvailable();
284
+ let model: Model<Api> | undefined;
285
+
286
+ if (fastModelId) {
287
+ const parsed = parseModelString(fastModelId);
288
+ if (parsed) {
289
+ model = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
290
+ }
291
+ }
292
+ if (!model) {
293
+ model = availableModels[0];
294
+ }
295
+ if (!model) {
296
+ return {
297
+ content: [{ type: "text", text: "No model available for extraction." }],
298
+ details: {
299
+ threadId,
300
+ goal,
301
+ title,
302
+ originalLength: markdown.length,
303
+ extractedLength: 0,
304
+ compressionRatio: 0,
305
+ },
306
+ };
307
+ }
308
+
309
+ const sessionId = this.session.getSessionId?.() ?? undefined;
310
+ const apiKey = await registry.getApiKey(model, sessionId);
311
+ if (!apiKey) {
312
+ return {
313
+ content: [{ type: "text", text: "No API key available for extraction model." }],
314
+ details: {
315
+ threadId,
316
+ goal,
317
+ title,
318
+ originalLength: markdown.length,
319
+ extractedLength: 0,
320
+ compressionRatio: 0,
321
+ },
322
+ };
323
+ }
324
+
325
+ // Call LLM for extraction
326
+ let relevantContent: string;
327
+ try {
328
+ const response = await completeSimple(
329
+ model,
330
+ {
331
+ systemPrompt: extractPrompt,
332
+ messages: [
333
+ {
334
+ role: "user",
335
+ content: `Here is the thread content:\n\n<thread>\n${markdown}\n</thread>\n\nGoal: ${goal}`,
336
+ timestamp: Date.now(),
337
+ },
338
+ ],
339
+ },
340
+ { apiKey, maxTokens: 8192 },
341
+ );
342
+
343
+ let text = "";
344
+ for (const block of response.content) {
345
+ if (block.type === "text") {
346
+ text += block.text;
347
+ }
348
+ }
349
+ relevantContent = text.trim();
350
+
351
+ if (!relevantContent) {
352
+ relevantContent = "No relevant content extracted.";
353
+ }
354
+ } catch (err) {
355
+ logger.error("read_thread: extraction failed", { error: err instanceof Error ? err.message : String(err) });
356
+ return {
357
+ content: [{ type: "text", text: `Extraction failed: ${err instanceof Error ? err.message : String(err)}` }],
358
+ details: {
359
+ threadId,
360
+ goal,
361
+ title,
362
+ originalLength: markdown.length,
363
+ extractedLength: 0,
364
+ compressionRatio: 0,
365
+ },
366
+ };
367
+ }
368
+
369
+ const originalLength = markdown.length;
370
+ const extractedLength = relevantContent.length;
371
+ const compressionRatio = originalLength > 0 ? extractedLength / originalLength : 1;
372
+ logger.debug("read_thread compression", { originalLength, extractedLength, compressionRatio });
373
+
374
+ return {
375
+ content: [{ type: "text", text: relevantContent }],
376
+ details: { threadId, goal, title, originalLength, extractedLength, compressionRatio },
377
+ };
378
+ }
379
+
380
+ renderCall(args: ReadThreadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
381
+ const meta = args.threadId ? [args.threadId] : [];
382
+ const text = renderStatusLine({ icon: "pending", title: "Read Thread", meta }, uiTheme);
383
+ return new Text(text, 0, 0);
384
+ }
385
+
386
+ renderResult(
387
+ result: { content: Array<{ type: string; text?: string }>; details?: ReadThreadToolDetails },
388
+ options: RenderResultOptions,
389
+ uiTheme: Theme,
390
+ _args?: ReadThreadRenderArgs,
391
+ ): Component {
392
+ const details = result.details;
393
+ const titlePart = details?.title ? ` — ${details.title}` : "";
394
+ const compressionPart = details ? ` (${Math.round(details.compressionRatio * 100)}% of original)` : "";
395
+ const header = renderStatusLine(
396
+ { icon: "success", title: "Read Thread", meta: [`${titlePart}${compressionPart}`] },
397
+ uiTheme,
398
+ );
399
+
400
+ const contentText = result.content?.find(c => c.type === "text")?.text ?? "No content";
401
+ const { expanded } = options;
402
+ const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
403
+ const lines = contentText.split("\n").slice(0, maxLines);
404
+ const truncated = lines.map(line => truncateToWidth(line, 120)).join("\n");
405
+ const preview = uiTheme.fg("dim", truncated);
406
+
407
+ return new Text(`${header}\n${preview}`, 0, 0);
408
+ }
409
+ }
package/src/tools/read.ts CHANGED
@@ -554,7 +554,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails, T
554
554
 
555
555
  const displayMode = resolveFileDisplayMode(this.session);
556
556
 
557
- // Handle internal URLs (agent://, artifact://, plan://, memory://, skill://, rule://)
557
+ // Handle internal URLs (agent://, artifact://, plan://, skill://, rule://)
558
558
  const internalRouter = this.session.internalRouter;
559
559
  if (internalRouter?.canHandle(readPath)) {
560
560
  return this.#handleInternalUrl(readPath, offset, limit);
@@ -832,7 +832,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails, T
832
832
  }
833
833
 
834
834
  /**
835
- * Handle internal URLs (agent://, artifact://, plan://, memory://, skill://, rule://).
835
+ * Handle internal URLs (agent://, artifact://, plan://, skill://, rule://).
836
836
  * Supports pagination via offset/limit but rejects them when query extraction is used.
837
837
  */
838
838
  async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
@@ -0,0 +1,68 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
2
+ import { type AsciiRenderOptions, renderMermaidAscii } from "@nghyane/arcane-utils";
3
+ import { type Static, Type } from "@sinclair/typebox";
4
+ import { renderPromptTemplate } from "../config/prompt-templates";
5
+ import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" };
6
+ import type { ToolSession } from "./index";
7
+ import { allocateOutputArtifact } from "./output-utils";
8
+
9
+ const renderMermaidSchema = Type.Object({
10
+ mermaid: Type.String({ description: "Mermaid graph source text" }),
11
+ config: Type.Optional(
12
+ Type.Object({
13
+ useAscii: Type.Optional(Type.Boolean()),
14
+ paddingX: Type.Optional(Type.Number()),
15
+ paddingY: Type.Optional(Type.Number()),
16
+ boxBorderPadding: Type.Optional(Type.Number()),
17
+ }),
18
+ ),
19
+ });
20
+
21
+ type RenderMermaidParams = Static<typeof renderMermaidSchema>;
22
+
23
+ function sanitizeRenderConfig(config: AsciiRenderOptions | undefined): AsciiRenderOptions | undefined {
24
+ if (!config) return undefined;
25
+ return {
26
+ useAscii: config.useAscii,
27
+ boxBorderPadding:
28
+ config.boxBorderPadding === undefined ? undefined : Math.max(0, Math.floor(config.boxBorderPadding)),
29
+ paddingX: config.paddingX === undefined ? undefined : Math.max(0, Math.floor(config.paddingX)),
30
+ paddingY: config.paddingY === undefined ? undefined : Math.max(0, Math.floor(config.paddingY)),
31
+ };
32
+ }
33
+
34
+ export interface RenderMermaidToolDetails {
35
+ artifactId?: string;
36
+ }
37
+
38
+ export class RenderMermaidTool implements AgentTool<typeof renderMermaidSchema, RenderMermaidToolDetails> {
39
+ readonly name = "render_mermaid";
40
+ readonly label = "RenderMermaid";
41
+ readonly description: string;
42
+ readonly parameters = renderMermaidSchema;
43
+ readonly strict = true;
44
+
45
+ constructor(private readonly session: ToolSession) {
46
+ this.description = renderPromptTemplate(renderMermaidDescription);
47
+ }
48
+
49
+ async execute(
50
+ _toolCallId: string,
51
+ params: RenderMermaidParams,
52
+ _signal?: AbortSignal,
53
+ _onUpdate?: AgentToolUpdateCallback<RenderMermaidToolDetails>,
54
+ _context?: AgentToolContext,
55
+ ): Promise<AgentToolResult<RenderMermaidToolDetails>> {
56
+ const ascii = renderMermaidAscii(params.mermaid, sanitizeRenderConfig(params.config));
57
+ const { artifactPath, artifactId } = await allocateOutputArtifact(this.session, "render_mermaid");
58
+ if (artifactPath) {
59
+ await Bun.write(artifactPath, ascii);
60
+ }
61
+
62
+ const artifactLine = artifactId ? `\n\nSaved artifact: artifact://${artifactId}` : "";
63
+ return {
64
+ content: [{ type: "text", text: `${ascii}${artifactLine}` }],
65
+ details: { artifactId },
66
+ };
67
+ }
68
+ }
@@ -0,0 +1,182 @@
1
+ import * as path from "node:path";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
3
+ import type { Component } from "@nghyane/arcane-tui";
4
+ import { Text } from "@nghyane/arcane-tui";
5
+ import { isEnoent, logger } from "@nghyane/arcane-utils";
6
+ import { type Static, Type } from "@sinclair/typebox";
7
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
+ import type { Theme } from "../theme/theme";
9
+ import { renderStatusLine } from "../tui";
10
+ import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../ui/render-utils";
11
+ import type { ToolSession } from ".";
12
+
13
+ const saveMemorySchema = Type.Object({
14
+ fact: Type.String({ description: "A clear, self-contained statement to remember across sessions" }),
15
+ });
16
+
17
+ type SaveMemoryParams = Static<typeof saveMemorySchema>;
18
+
19
+ export interface SaveMemoryToolDetails {
20
+ fact: string;
21
+ filePath: string;
22
+ duplicate?: boolean;
23
+ }
24
+
25
+ interface SaveMemoryRenderArgs {
26
+ fact?: string;
27
+ }
28
+
29
+ const MEMORIES_HEADING = "## Memories";
30
+ const MEMORIES_HEADING_RE = /^## Memories\s*$/;
31
+ const NEXT_HEADING_RE = /^## /;
32
+
33
+ async function findNearestAgentsMd(startDir: string): Promise<string | null> {
34
+ let dir = path.resolve(startDir);
35
+ const root = path.parse(dir).root;
36
+ while (true) {
37
+ const candidate = path.join(dir, "AGENTS.md");
38
+ try {
39
+ await Bun.file(candidate).text();
40
+ return candidate;
41
+ } catch (err) {
42
+ if (!isEnoent(err)) throw err;
43
+ }
44
+ const parent = path.dirname(dir);
45
+ if (parent === dir || dir === root) break;
46
+ dir = parent;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function insertMemory(content: string, fact: string): { content: string; duplicate: boolean } {
52
+ const lines = content.split("\n");
53
+ const bullet = `- ${fact}`;
54
+
55
+ // Find Memories section
56
+ let sectionStart = -1;
57
+ for (let i = 0; i < lines.length; i++) {
58
+ if (MEMORIES_HEADING_RE.test(lines[i])) {
59
+ sectionStart = i;
60
+ break;
61
+ }
62
+ }
63
+
64
+ if (sectionStart === -1) {
65
+ // Append section at end
66
+ const trimmed = content.trimEnd();
67
+ return { content: `${trimmed}\n\n${MEMORIES_HEADING}\n${bullet}\n`, duplicate: false };
68
+ }
69
+
70
+ // Find section end (next ## heading or EOF)
71
+ let sectionEnd = lines.length;
72
+ for (let i = sectionStart + 1; i < lines.length; i++) {
73
+ if (NEXT_HEADING_RE.test(lines[i])) {
74
+ sectionEnd = i;
75
+ break;
76
+ }
77
+ }
78
+
79
+ // Check duplicates among existing bullets
80
+ const factLower = fact.toLowerCase();
81
+ for (let i = sectionStart + 1; i < sectionEnd; i++) {
82
+ const line = lines[i].trim();
83
+ if (line.startsWith("- ")) {
84
+ const existing = line.slice(2).toLowerCase();
85
+ if (existing.includes(factLower) || factLower.includes(existing)) {
86
+ return { content, duplicate: true };
87
+ }
88
+ }
89
+ }
90
+
91
+ // Insert bullet before sectionEnd
92
+ lines.splice(sectionEnd, 0, bullet);
93
+ return { content: lines.join("\n"), duplicate: false };
94
+ }
95
+
96
+ export class SaveMemoryTool implements AgentTool<typeof saveMemorySchema, SaveMemoryToolDetails, Theme> {
97
+ readonly name = "save_memory";
98
+ readonly label = "Save Memory";
99
+ description =
100
+ 'Save a fact or preference to long-term memory that persists across sessions. Use when the user explicitly asks to remember something or states a clear preference. Facts should be short, self-contained: "Prefers tabs over spaces", "Project uses pnpm". Do not save transient conversation context. If unsure, ask the user.';
101
+ readonly parameters = saveMemorySchema;
102
+ readonly concurrency = "exclusive";
103
+
104
+ constructor(private readonly session: ToolSession) {}
105
+
106
+ async execute(
107
+ _toolCallId: string,
108
+ params: SaveMemoryParams,
109
+ _signal?: AbortSignal,
110
+ _onUpdate?: AgentToolUpdateCallback<SaveMemoryToolDetails>,
111
+ _context?: AgentToolContext,
112
+ ): Promise<AgentToolResult<SaveMemoryToolDetails>> {
113
+ const fact = params.fact.trim();
114
+ if (!fact) {
115
+ return {
116
+ content: [{ type: "text", text: "Fact cannot be empty." }],
117
+ details: { fact: "", filePath: "" },
118
+ };
119
+ }
120
+
121
+ let filePath = await findNearestAgentsMd(this.session.cwd);
122
+ let content: string;
123
+
124
+ if (filePath) {
125
+ content = await Bun.file(filePath).text();
126
+ } else {
127
+ filePath = path.join(this.session.cwd, "AGENTS.md");
128
+ content = "";
129
+ }
130
+
131
+ const result = insertMemory(content, fact);
132
+
133
+ if (result.duplicate) {
134
+ return {
135
+ content: [{ type: "text", text: "This fact is already saved." }],
136
+ details: { fact, filePath, duplicate: true },
137
+ };
138
+ }
139
+
140
+ try {
141
+ await Bun.write(filePath, result.content);
142
+ } catch (err) {
143
+ logger.error("Failed to write AGENTS.md", { path: filePath, error: String(err) });
144
+ return {
145
+ content: [{ type: "text", text: "Failed to save memory." }],
146
+ details: { fact, filePath },
147
+ };
148
+ }
149
+
150
+ return {
151
+ content: [{ type: "text", text: `Saved to ${filePath}` }],
152
+ details: { fact, filePath },
153
+ };
154
+ }
155
+
156
+ renderCall(args: SaveMemoryRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
157
+ const preview = args.fact ? truncateToWidth(args.fact, TRUNCATE_LENGTHS.CONTENT) : "";
158
+ const meta = preview ? [preview] : [];
159
+ const text = renderStatusLine({ icon: "pending", title: "Save Memory", meta }, uiTheme);
160
+ return new Text(text, 0, 0);
161
+ }
162
+
163
+ renderResult(
164
+ result: { content: Array<{ type: string; text?: string }>; details?: SaveMemoryToolDetails },
165
+ _options: RenderResultOptions,
166
+ uiTheme: Theme,
167
+ _args?: SaveMemoryRenderArgs,
168
+ ): Component {
169
+ const details = result.details;
170
+ const isDuplicate = details?.duplicate === true;
171
+ const icon = isDuplicate ? "info" : "success";
172
+ const filePath = details?.filePath ? shortenPath(details.filePath) : "";
173
+ const meta = filePath ? [filePath] : [];
174
+ const header = renderStatusLine({ icon, title: "Save Memory", meta }, uiTheme);
175
+
176
+ const message = isDuplicate
177
+ ? uiTheme.fg("dim", "This fact is already saved.")
178
+ : uiTheme.fg("dim", details?.fact ?? "");
179
+
180
+ return new Text(`${header}\n${message}`, 0, 0);
181
+ }
182
+ }
@@ -35,6 +35,7 @@ export const webSearchSchema = Type.Object({
35
35
  "exa",
36
36
  "brave",
37
37
  "jina",
38
+ "kagi",
38
39
  "kimi",
39
40
  "zai",
40
41
  "anthropic",
@@ -55,6 +56,7 @@ export type SearchParams = {
55
56
  | "exa"
56
57
  | "brave"
57
58
  | "jina"
59
+ | "kagi"
58
60
  | "kimi"
59
61
  | "zai"
60
62
  | "anthropic"