@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,329 @@
1
+ import { Database, type Statement } from "bun:sqlite";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { logger, parseJsonlLenient } from "@nghyane/arcane-utils";
5
+ import { getAgentDir } from "@nghyane/arcane-utils/dirs";
6
+
7
+ export interface SessionIndexEntry {
8
+ sessionId: string;
9
+ title: string;
10
+ firstMessage: string;
11
+ files: string;
12
+ cwd: string;
13
+ createdAt: number;
14
+ messageCount: number;
15
+ }
16
+
17
+ export interface SessionSearchResult {
18
+ threadId: string;
19
+ title: string;
20
+ date: string;
21
+ messageCount: number;
22
+ snippet: string;
23
+ }
24
+
25
+ interface SearchRow {
26
+ session_id: string;
27
+ title: string;
28
+ created_at: number;
29
+ message_count: number;
30
+ snippet: string;
31
+ }
32
+
33
+ const FILE_PATH_PARAMS = new Set(["path", "file", "filePath", "glob", "pattern", "command_working_directory"]);
34
+
35
+ export class SessionIndex {
36
+ #db: Database;
37
+ static #instance?: SessionIndex;
38
+
39
+ #upsertStmt: Statement;
40
+ #hasStmt: Statement;
41
+
42
+ private constructor(dbPath: string) {
43
+ const dir = path.dirname(dbPath);
44
+ fs.mkdirSync(dir, { recursive: true });
45
+
46
+ this.#db = new Database(dbPath);
47
+
48
+ this.#db.exec(`
49
+ PRAGMA journal_mode=WAL;
50
+ PRAGMA synchronous=NORMAL;
51
+ PRAGMA busy_timeout=5000;
52
+
53
+ CREATE TABLE IF NOT EXISTS session_index (
54
+ session_id TEXT PRIMARY KEY,
55
+ title TEXT,
56
+ first_message TEXT,
57
+ files TEXT,
58
+ cwd TEXT,
59
+ created_at INTEGER,
60
+ message_count INTEGER
61
+ );
62
+
63
+ CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
64
+ title, first_message, files,
65
+ content='session_index', content_rowid='rowid'
66
+ );
67
+
68
+ CREATE TRIGGER IF NOT EXISTS session_index_ai AFTER INSERT ON session_index BEGIN
69
+ INSERT INTO session_fts(rowid, title, first_message, files)
70
+ VALUES (new.rowid, new.title, new.first_message, new.files);
71
+ END;
72
+
73
+ CREATE TRIGGER IF NOT EXISTS session_index_ad AFTER DELETE ON session_index BEGIN
74
+ INSERT INTO session_fts(session_fts, rowid, title, first_message, files)
75
+ VALUES ('delete', old.rowid, old.title, old.first_message, old.files);
76
+ END;
77
+
78
+ CREATE TRIGGER IF NOT EXISTS session_index_au AFTER UPDATE ON session_index BEGIN
79
+ INSERT INTO session_fts(session_fts, rowid, title, first_message, files)
80
+ VALUES ('delete', old.rowid, old.title, old.first_message, old.files);
81
+ INSERT INTO session_fts(rowid, title, first_message, files)
82
+ VALUES (new.rowid, new.title, new.first_message, new.files);
83
+ END;
84
+ `);
85
+
86
+ this.#upsertStmt = this.#db.prepare(
87
+ "INSERT OR REPLACE INTO session_index (session_id, title, first_message, files, cwd, created_at, message_count) VALUES (?, ?, ?, ?, ?, ?, ?)",
88
+ );
89
+ this.#hasStmt = this.#db.prepare("SELECT 1 FROM session_index WHERE session_id = ?");
90
+ }
91
+
92
+ static open(dbPath: string = path.join(getAgentDir(), "session-index.db")): SessionIndex {
93
+ if (!SessionIndex.#instance) {
94
+ SessionIndex.#instance = new SessionIndex(dbPath);
95
+ }
96
+ return SessionIndex.#instance;
97
+ }
98
+
99
+ upsert(entry: SessionIndexEntry): void {
100
+ try {
101
+ this.#upsertStmt.run(
102
+ entry.sessionId,
103
+ entry.title,
104
+ entry.firstMessage,
105
+ entry.files,
106
+ entry.cwd,
107
+ entry.createdAt,
108
+ entry.messageCount,
109
+ );
110
+ } catch (error) {
111
+ logger.error("SessionIndex upsert failed", { error: String(error) });
112
+ }
113
+ }
114
+
115
+ has(sessionId: string): boolean {
116
+ return this.#hasStmt.get(sessionId) != null;
117
+ }
118
+
119
+ search(query: string, limit: number): SessionSearchResult[] {
120
+ const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 50);
121
+ const { ftsQuery, afterTs, beforeTs } = this.#parseQuery(query);
122
+ if (!ftsQuery) return [];
123
+
124
+ const conditions = ["session_fts MATCH ?"];
125
+ const params: (string | number)[] = [ftsQuery];
126
+
127
+ if (afterTs != null) {
128
+ conditions.push("si.created_at >= ?");
129
+ params.push(afterTs);
130
+ }
131
+ if (beforeTs != null) {
132
+ conditions.push("si.created_at <= ?");
133
+ params.push(beforeTs);
134
+ }
135
+ params.push(safeLimit);
136
+
137
+ const sql = `SELECT si.session_id, si.title, si.created_at, si.message_count,
138
+ snippet(session_fts, -1, '<match>', '</match>', '...', 32) as snippet
139
+ FROM session_fts f
140
+ JOIN session_index si ON si.rowid = f.rowid
141
+ WHERE ${conditions.join(" AND ")}
142
+ ORDER BY si.created_at DESC
143
+ LIMIT ?`;
144
+
145
+ try {
146
+ const rows = this.#db.prepare(sql).all(...params) as SearchRow[];
147
+ return rows.map(row => ({
148
+ threadId: row.session_id,
149
+ title: row.title || "Untitled",
150
+ date: new Date(row.created_at * 1000).toISOString().slice(0, 10),
151
+ messageCount: row.message_count,
152
+ snippet: row.snippet || "",
153
+ }));
154
+ } catch (error) {
155
+ logger.error("SessionIndex search failed", { error: String(error) });
156
+ return [];
157
+ }
158
+ }
159
+
160
+ async indexSessionFile(filePath: string): Promise<void> {
161
+ try {
162
+ const content = await Bun.file(filePath).text();
163
+ const entries = parseJsonlLenient<Record<string, unknown>>(content);
164
+ if (entries.length === 0) return;
165
+
166
+ const header = entries.find(e => e.type === "session") as
167
+ | { type: string; id?: string; title?: string; cwd?: string; timestamp?: string }
168
+ | undefined;
169
+ if (!header?.id) return;
170
+
171
+ let firstMessage = "";
172
+ let messageCount = 0;
173
+ const fileSet = new Set<string>();
174
+
175
+ for (const entry of entries) {
176
+ if (entry.type !== "message") continue;
177
+ const msg = entry.message as { role?: string; content?: unknown } | undefined;
178
+ if (!msg?.role) continue;
179
+
180
+ messageCount++;
181
+
182
+ if (msg.role === "user" && !firstMessage) {
183
+ firstMessage = extractTextContent(msg.content);
184
+ }
185
+
186
+ if (msg.role === "assistant") {
187
+ extractFilePaths(msg.content, fileSet);
188
+ }
189
+ }
190
+
191
+ const title = header.title || firstMessage.slice(0, 100) || "Untitled";
192
+ const createdAt = header.timestamp ? Math.floor(new Date(header.timestamp).getTime() / 1000) : 0;
193
+
194
+ this.upsert({
195
+ sessionId: header.id,
196
+ title,
197
+ firstMessage: firstMessage.slice(0, 500),
198
+ files: [...fileSet].join(" "),
199
+ cwd: header.cwd || "",
200
+ createdAt,
201
+ messageCount,
202
+ });
203
+ } catch (error) {
204
+ logger.warn("SessionIndex indexSessionFile failed", { path: filePath, error: String(error) });
205
+ }
206
+ }
207
+
208
+ async indexAllSessions(sessionsDir?: string): Promise<void> {
209
+ const dir = sessionsDir ?? path.join(getAgentDir(), "sessions");
210
+ let subdirs: string[];
211
+ try {
212
+ subdirs = fs.readdirSync(dir);
213
+ } catch {
214
+ return;
215
+ }
216
+
217
+ for (const subdir of subdirs) {
218
+ const subdirPath = path.join(dir, subdir);
219
+ let stat: fs.Stats;
220
+ try {
221
+ stat = fs.statSync(subdirPath);
222
+ } catch {
223
+ continue;
224
+ }
225
+ if (!stat.isDirectory()) continue;
226
+
227
+ let files: string[];
228
+ try {
229
+ files = fs.readdirSync(subdirPath).filter(f => f.endsWith(".jsonl"));
230
+ } catch {
231
+ continue;
232
+ }
233
+
234
+ for (const file of files) {
235
+ const filePath = path.join(subdirPath, file);
236
+ try {
237
+ const firstLine = await Bun.file(filePath).text();
238
+ const headerLine = firstLine.split("\n")[0];
239
+ if (!headerLine) continue;
240
+ const header = JSON.parse(headerLine) as { id?: string };
241
+ if (!header.id) continue;
242
+ if (this.has(header.id)) continue;
243
+ await this.indexSessionFile(filePath);
244
+ } catch {}
245
+ }
246
+ }
247
+ }
248
+
249
+ #parseQuery(query: string): { ftsQuery: string | null; afterTs: number | null; beforeTs: number | null } {
250
+ let afterTs: number | null = null;
251
+ let beforeTs: number | null = null;
252
+
253
+ const remaining = query.replace(/\b(after|before):(\S+)/g, (_, dir: string, val: string) => {
254
+ const ts = this.#parseDate(val);
255
+ if (ts != null) {
256
+ if (dir === "after") afterTs = ts;
257
+ else beforeTs = ts;
258
+ }
259
+ return "";
260
+ });
261
+
262
+ const ftsQuery = this.#buildFtsQuery(remaining);
263
+ return { ftsQuery, afterTs, beforeTs };
264
+ }
265
+
266
+ #parseDate(value: string): number | null {
267
+ const relMatch = value.match(/^(\d+)([dwm])$/);
268
+ if (relMatch) {
269
+ const n = Number.parseInt(relMatch[1], 10);
270
+ const unit = relMatch[2];
271
+ const now = Date.now();
272
+ let ms = 0;
273
+ if (unit === "d") ms = n * 86400_000;
274
+ else if (unit === "w") ms = n * 7 * 86400_000;
275
+ else if (unit === "m") ms = n * 30 * 86400_000;
276
+ return Math.floor((now - ms) / 1000);
277
+ }
278
+
279
+ const d = new Date(value);
280
+ if (!Number.isNaN(d.getTime())) {
281
+ return Math.floor(d.getTime() / 1000);
282
+ }
283
+ return null;
284
+ }
285
+
286
+ #buildFtsQuery(query: string): string | null {
287
+ const tokens = query
288
+ .trim()
289
+ .split(/\s+/)
290
+ .map(t => t.trim())
291
+ .filter(Boolean);
292
+
293
+ if (tokens.length === 0) return null;
294
+
295
+ return tokens
296
+ .map(token => {
297
+ const escaped = token.replace(/"/g, '""');
298
+ return `"${escaped}"*`;
299
+ })
300
+ .join(" ");
301
+ }
302
+ }
303
+
304
+ function extractTextContent(content: unknown): string {
305
+ if (typeof content === "string") return content;
306
+ if (Array.isArray(content)) {
307
+ for (const block of content) {
308
+ if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
309
+ return String(block.text);
310
+ }
311
+ }
312
+ }
313
+ return "";
314
+ }
315
+
316
+ function extractFilePaths(content: unknown, fileSet: Set<string>): void {
317
+ if (!Array.isArray(content)) return;
318
+ for (const block of content) {
319
+ if (!block || typeof block !== "object") continue;
320
+ if (!("type" in block) || block.type !== "toolCall") continue;
321
+ const args = "arguments" in block ? (block.arguments as Record<string, unknown>) : null;
322
+ if (!args) continue;
323
+ for (const [key, val] of Object.entries(args)) {
324
+ if (FILE_PATH_PARAMS.has(key) && typeof val === "string" && val.length > 0) {
325
+ fileSet.add(val);
326
+ }
327
+ }
328
+ }
329
+ }
@@ -349,22 +349,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
349
349
  runtime.ctx.editor.setText("");
350
350
  },
351
351
  },
352
- {
353
- name: "memory",
354
- description: "Inspect and operate memory maintenance",
355
- subcommands: [
356
- { name: "view", description: "Show current memory injection payload" },
357
- { name: "clear", description: "Clear persisted memory data and artifacts" },
358
- { name: "reset", description: "Alias for clear" },
359
- { name: "enqueue", description: "Enqueue memory consolidation maintenance" },
360
- { name: "rebuild", description: "Alias for enqueue" },
361
- ],
362
- allowArgs: true,
363
- handle: async (command, runtime) => {
364
- runtime.ctx.editor.setText("");
365
- await runtime.ctx.handleMemoryCommand(command.text);
366
- },
367
- },
368
352
  {
369
353
  name: "move",
370
354
  description: "Move session to a different working directory",
package/src/task/index.ts CHANGED
@@ -189,7 +189,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
189
189
  modelRegistry: this.session.subagentContext?.modelRegistry,
190
190
  settings: this.session.settings,
191
191
  mcpManager: this.session.subagentContext?.mcpManager,
192
- contextFiles: this.session.contextFiles,
192
+ contextFiles: this.session.contextFiles?.filter(f => !f.path.endsWith("AGENTS.md")),
193
193
  skills: this.session.skills,
194
194
  promptTemplates: this.session.promptTemplates,
195
195
  });
package/src/tools/ask.ts CHANGED
@@ -111,14 +111,15 @@ interface UIContext {
111
111
  select(
112
112
  prompt: string,
113
113
  options: string[],
114
- options_?: { initialIndex?: number; timeout?: number; outline?: boolean },
114
+ options_?: { initialIndex?: number; timeout?: number; outline?: boolean; signal?: AbortSignal },
115
115
  ): Promise<string | undefined>;
116
- input(prompt: string): Promise<string | undefined>;
116
+ input(prompt: string, placeholder?: string, options_?: { signal?: AbortSignal }): Promise<string | undefined>;
117
117
  }
118
118
 
119
119
  interface AskQuestionOptions {
120
120
  /** Timeout in milliseconds, null/undefined to disable */
121
121
  timeout?: number | null;
122
+ signal?: AbortSignal;
122
123
  }
123
124
 
124
125
  async function askSingleQuestion(
@@ -158,6 +159,7 @@ async function askSingleQuestion(
158
159
  initialIndex: cursorIndex,
159
160
  timeout: timeout ?? undefined,
160
161
  outline: true,
162
+ signal: options?.signal,
161
163
  });
162
164
  const elapsed = Date.now() - selectionStart;
163
165
  const timedOut = timeout != null && elapsed >= timeout;
@@ -166,7 +168,7 @@ async function askSingleQuestion(
166
168
 
167
169
  if (choice === OTHER_OPTION) {
168
170
  if (!timedOut) {
169
- const input = await ui.input("Enter your response:");
171
+ const input = await ui.input("Enter your response:", undefined, { signal: options?.signal });
170
172
  if (input) customInput = input;
171
173
  }
172
174
  break;
@@ -205,9 +207,10 @@ async function askSingleQuestion(
205
207
  timeout: timeout ?? undefined,
206
208
  initialIndex: recommended,
207
209
  outline: true,
210
+ signal: options?.signal,
208
211
  });
209
212
  if (choice === OTHER_OPTION) {
210
- const input = await ui.input("Enter your response:");
213
+ const input = await ui.input("Enter your response:", undefined, { signal: options?.signal });
211
214
  if (input) customInput = input;
212
215
  } else if (choice) {
213
216
  selectedOptions = [stripRecommendedSuffix(choice)];
@@ -300,7 +303,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails, Them
300
303
  optionLabels,
301
304
  q.multi ?? false,
302
305
  q.recommended,
303
- { timeout },
306
+ { timeout, signal: _signal },
304
307
  );
305
308
 
306
309
  const details: AskToolDetails = {
@@ -335,7 +338,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails, Them
335
338
  optionLabels,
336
339
  q.multi ?? false,
337
340
  q.recommended,
338
- { timeout },
341
+ { timeout, signal: _signal },
339
342
  );
340
343
 
341
344
  results.push({
@@ -9,9 +9,9 @@ const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|ski
9
9
 
10
10
  /** Regex to find supported internal URL tokens in command text. */
11
11
  const INTERNAL_URL_PATTERN =
12
- /'(?:skill|agent|artifact|plan|memory|rule):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|memory|rule):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|memory|rule):\/\/[^\s'")`\\]+/g;
12
+ /'(?:skill|agent|artifact|plan|rule):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|rule):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|rule):\/\/[^\s'")`\\]+/g;
13
13
 
14
- const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "memory", "rule"] as const;
14
+ const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "rule"] as const;
15
15
 
16
16
  type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
17
17
 
@@ -152,7 +152,7 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
152
152
 
153
153
  /**
154
154
  * Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
155
- * Supported schemes: skill://, agent://, artifact://, plan://, memory://, rule://
155
+ * Supported schemes: skill://, agent://, artifact://, plan://, rule://
156
156
  */
157
157
  export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
158
158
  if (!command.includes("://")) return command;
@@ -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 {