@nghyane/arcane 0.1.16 → 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 (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +7 -15
  3. package/src/config/settings-schema.ts +19 -46
  4. package/src/config/settings.ts +0 -1
  5. package/src/exa/mcp-client.ts +57 -2
  6. package/src/internal-urls/docs-index.generated.ts +1 -2
  7. package/src/internal-urls/index.ts +2 -4
  8. package/src/internal-urls/router.ts +2 -2
  9. package/src/internal-urls/types.ts +2 -2
  10. package/src/mcp/oauth-flow.ts +1 -1
  11. package/src/modes/controllers/command-controller.ts +4 -44
  12. package/src/patch/hashline.ts +42 -0
  13. package/src/prompts/system/system-prompt.md +14 -10
  14. package/src/prompts/thread-extract.md +16 -0
  15. package/src/prompts/tools/render-mermaid.md +9 -0
  16. package/src/sdk.ts +1 -19
  17. package/src/session/agent-session.ts +4 -3
  18. package/src/session/retry-utils.ts +1 -1
  19. package/src/session/session-index.ts +329 -0
  20. package/src/slash-commands/builtin-registry.ts +0 -16
  21. package/src/task/index.ts +1 -1
  22. package/src/tools/ask.ts +9 -6
  23. package/src/tools/bash-skill-urls.ts +3 -3
  24. package/src/tools/create-tools.ts +26 -0
  25. package/src/tools/find-thread.ts +120 -0
  26. package/src/tools/index.ts +5 -0
  27. package/src/tools/read-thread.ts +409 -0
  28. package/src/tools/read.ts +2 -2
  29. package/src/tools/render-mermaid.ts +68 -0
  30. package/src/tools/save-memory.ts +182 -0
  31. package/src/web/search/index.ts +2 -0
  32. package/src/web/search/provider.ts +3 -0
  33. package/src/web/search/providers/anthropic.ts +1 -0
  34. package/src/web/search/providers/gemini.ts +122 -37
  35. package/src/web/search/providers/kagi.ts +163 -0
  36. package/src/web/search/types.ts +1 -0
  37. package/src/internal-urls/memory-protocol.ts +0 -133
  38. package/src/memories/index.ts +0 -1099
  39. package/src/memories/storage.ts +0 -563
  40. package/src/prompts/memories/consolidation.md +0 -30
  41. package/src/prompts/memories/read_path.md +0 -11
  42. package/src/prompts/memories/stage_one_input.md +0 -6
  43. package/src/prompts/memories/stage_one_system.md +0 -21
@@ -13,6 +13,7 @@ import { BrowserTool } from "./browser";
13
13
  import { exploreConfig } from "./explore";
14
14
  import { FetchTool } from "./fetch";
15
15
  import { FindTool } from "./find";
16
+ import { FindThreadTool } from "./find-thread";
16
17
  import { GitHubTool } from "./github";
17
18
  import { GrepTool } from "./grep";
18
19
  import type { ToolSession } from "./index";
@@ -22,7 +23,10 @@ import { oracleConfig } from "./oracle";
22
23
  import { wrapToolWithMetaNotice } from "./output-meta";
23
24
  import { PythonTool } from "./python";
24
25
  import { ReadTool } from "./read";
26
+ import { ReadThreadTool } from "./read-thread";
27
+ import { RenderMermaidTool } from "./render-mermaid";
25
28
  import { reviewerConfig } from "./reviewer-tool";
29
+ import { SaveMemoryTool } from "./save-memory";
26
30
  import { SearchCodeTool } from "./search-code";
27
31
  import { loadSshTool } from "./ssh";
28
32
  import { SubagentTool } from "./subagent-tool";
@@ -38,6 +42,8 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
38
42
  python: s => new PythonTool(s),
39
43
  ssh: loadSshTool,
40
44
  edit: s => new EditTool(s),
45
+ find_thread: s => new FindThreadTool(s),
46
+ read_thread: s => new ReadThreadTool(s),
41
47
  find: s => new FindTool(s),
42
48
  explore: s => new SubagentTool(s, exploreConfig),
43
49
  github: s => new GitHubTool(s),
@@ -47,6 +53,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
47
53
  notebook: s => new NotebookTool(s),
48
54
  oracle: s => new SubagentTool(s, oracleConfig),
49
55
  read: s => new ReadTool(s),
56
+ render_mermaid: s => new RenderMermaidTool(s),
50
57
  browser: s => new BrowserTool(s),
51
58
  task: TaskTool.create,
52
59
  code_review: s => new SubagentTool(s, reviewerConfig),
@@ -55,6 +62,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
55
62
  fetch: s => new FetchTool(s),
56
63
  web_search: () => new SearchTool(),
57
64
  search_code: () => new SearchCodeTool(),
65
+ save_memory: s => new SaveMemoryTool(s),
58
66
  write: s => new WriteTool(s),
59
67
  };
60
68
 
@@ -153,6 +161,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
153
161
  if (name === "web_search") return session.settings.get("web_search.enabled");
154
162
  if (name === "lsp") return session.settings.get("lsp.enabled");
155
163
  if (name === "browser") return session.settings.get("browser.enabled");
164
+ if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
156
165
  if (name === "librarian") return session.settings.get("librarian.enabled");
157
166
  if (name === "oracle") return session.settings.get("oracle.enabled");
158
167
  if (name === "github") return session.settings.get("github.enabled");
@@ -182,5 +191,22 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
182
191
  );
183
192
  const tools = results.filter((r): r is AgentTool => r !== null);
184
193
 
194
+ // Auto-include AST tools when their text-based counterparts are enabled
195
+ const toolNameSet = new Set(tools.map(t => t.name));
196
+ if (session.settings.get("astGrep.enabled") && toolNameSet.has("grep") && !toolNameSet.has("ast_grep")) {
197
+ const astGrepFactory = allTools.ast_grep;
198
+ if (astGrepFactory) {
199
+ const astGrepTool = await astGrepFactory(session);
200
+ if (astGrepTool) tools.push(wrapToolWithMetaNotice(astGrepTool));
201
+ }
202
+ }
203
+ if (session.settings.get("astEdit.enabled") && toolNameSet.has("edit") && !toolNameSet.has("ast_edit")) {
204
+ const astEditFactory = allTools.ast_edit;
205
+ if (astEditFactory) {
206
+ const astEditTool = await astEditFactory(session);
207
+ if (astEditTool) tools.push(wrapToolWithMetaNotice(astEditTool));
208
+ }
209
+ }
210
+
185
211
  return tools;
186
212
  }
@@ -0,0 +1,120 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@nghyane/arcane-agent";
2
+ import type { Component } from "@nghyane/arcane-tui";
3
+ import { Text } from "@nghyane/arcane-tui";
4
+ import { logger } from "@nghyane/arcane-utils";
5
+ import { type Static, Type } from "@sinclair/typebox";
6
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
7
+ import { SessionIndex, type SessionSearchResult } from "../session/session-index";
8
+ import type { Theme } from "../theme/theme";
9
+ import { renderStatusLine, renderTreeList } from "../tui";
10
+ import { PREVIEW_LIMITS } from "../ui/render-utils";
11
+ import type { ToolSession } from ".";
12
+
13
+ const findThreadSchema = Type.Object({
14
+ query: Type.String({
15
+ description:
16
+ "Keywords to search past sessions. Supports bare words, quoted phrases, after:7d, before:2026-01-01 date filters.",
17
+ }),
18
+ limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),
19
+ });
20
+
21
+ type FindThreadParams = Static<typeof findThreadSchema>;
22
+
23
+ export interface FindThreadToolDetails {
24
+ results: SessionSearchResult[];
25
+ query: string;
26
+ indexed: boolean;
27
+ }
28
+
29
+ interface FindThreadRenderArgs {
30
+ query?: string;
31
+ limit?: number;
32
+ }
33
+
34
+ export class FindThreadTool implements AgentTool<typeof findThreadSchema, FindThreadToolDetails, Theme> {
35
+ readonly name = "find_thread";
36
+ readonly label = "Find Thread";
37
+ description = [
38
+ "Find past conversation threads by keyword search. Returns thread IDs, titles, dates, and matching snippets.",
39
+ "Use read_thread to get full content from a specific thread.",
40
+ "",
41
+ 'Query syntax: bare keywords, "quoted phrases", after:7d, before:2026-01-01 date filters.',
42
+ "",
43
+ "When to use:",
44
+ "- User references past work or previous sessions",
45
+ "- Need context from earlier conversations",
46
+ "- Task may overlap with prior work",
47
+ "",
48
+ "When NOT to use: git history/blame, current session context, generic questions.",
49
+ ].join("\n");
50
+ readonly parameters = findThreadSchema;
51
+ readonly concurrency = "shared" as const;
52
+
53
+ constructor(readonly _session: ToolSession) {}
54
+
55
+ async execute(
56
+ _toolCallId: string,
57
+ params: FindThreadParams,
58
+ _signal?: AbortSignal,
59
+ _onUpdate?: AgentToolUpdateCallback<FindThreadToolDetails>,
60
+ _context?: AgentToolContext,
61
+ ): Promise<AgentToolResult<FindThreadToolDetails>> {
62
+ const index = SessionIndex.open();
63
+
64
+ try {
65
+ await index.indexAllSessions();
66
+ } catch (error) {
67
+ logger.warn("FindThread: indexing failed", { error: String(error) });
68
+ }
69
+
70
+ const limit = Math.min(Math.max(1, params.limit ?? 10), 50);
71
+ const results = index.search(params.query, limit);
72
+
73
+ const text =
74
+ results.length > 0 ? JSON.stringify(results, null, 2) : `No threads found matching "${params.query}".`;
75
+
76
+ return {
77
+ content: [{ type: "text", text }],
78
+ details: { results, query: params.query, indexed: true },
79
+ };
80
+ }
81
+
82
+ renderCall(args: FindThreadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
83
+ const meta = args.query ? [`"${args.query}"`] : [];
84
+ const text = renderStatusLine({ icon: "pending", title: "Find Thread", meta }, uiTheme);
85
+ return new Text(text, 0, 0);
86
+ }
87
+
88
+ renderResult(
89
+ result: { content: Array<{ type: string; text?: string }>; details?: FindThreadToolDetails },
90
+ options: RenderResultOptions,
91
+ uiTheme: Theme,
92
+ _args?: FindThreadRenderArgs,
93
+ ): Component {
94
+ const results = result.details?.results ?? [];
95
+ const header = renderStatusLine(
96
+ { icon: "success", title: "Find Thread", meta: [`${results.length} results`] },
97
+ uiTheme,
98
+ );
99
+
100
+ if (results.length === 0) {
101
+ const fallback = result.content?.find(c => c.type === "text")?.text ?? "No results";
102
+ return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
103
+ }
104
+
105
+ const { expanded } = options;
106
+ const treeLines = renderTreeList(
107
+ {
108
+ items: results,
109
+ expanded,
110
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
111
+ itemType: "thread",
112
+ renderItem: r =>
113
+ `${uiTheme.fg("accent", r.title)} ${uiTheme.fg("dim", r.date)} ${uiTheme.fg("dim", `(${r.messageCount} msgs)`)}`,
114
+ },
115
+ uiTheme,
116
+ );
117
+ const text = [header, ...treeLines].join("\n");
118
+ return new Text(text, 0, 0);
119
+ }
120
+ }
@@ -26,6 +26,7 @@ export {
26
26
  warmupLspServers,
27
27
  } from "../lsp";
28
28
  export { EditTool, type EditToolDetails } from "../patch";
29
+ export { SessionIndex, type SessionIndexEntry, type SessionSearchResult } from "../session/session-index";
29
30
  export {
30
31
  DEFAULT_MAX_BYTES,
31
32
  DEFAULT_MAX_LINES,
@@ -70,6 +71,7 @@ export {
70
71
  type FindToolInput,
71
72
  type FindToolOptions,
72
73
  } from "./find";
74
+ export { FindThreadTool, type FindThreadToolDetails } from "./find-thread";
73
75
  export { setPreferredImageProvider } from "./gemini-image";
74
76
  export { GitHubTool, type GitHubToolDetails } from "./github";
75
77
  export { GrepTool, type GrepToolDetails, type GrepToolInput } from "./grep";
@@ -82,7 +84,10 @@ export {
82
84
  type PythonToolOptions,
83
85
  } from "./python";
84
86
  export { ReadTool, type ReadToolDetails, type ReadToolInput } from "./read";
87
+ export { ReadThreadTool, type ReadThreadToolDetails } from "./read-thread";
88
+ export { RenderMermaidTool, type RenderMermaidToolDetails } from "./render-mermaid";
85
89
  export { reviewerConfig } from "./reviewer-tool";
90
+ export { SaveMemoryTool, type SaveMemoryToolDetails } from "./save-memory";
86
91
  export { loadSshTool, type SSHToolDetails, SshTool } from "./ssh";
87
92
  export { type SubagentConfig, SubagentTool } from "./subagent-tool";
88
93
  export {
@@ -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>> {