@nghyane/arcane 0.1.16 → 0.1.18

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 (74) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +7 -15
  3. package/src/cli/setup-cli.ts +2 -62
  4. package/src/commands/setup.ts +1 -1
  5. package/src/config/keybindings.ts +1 -4
  6. package/src/config/settings-schema.ts +23 -98
  7. package/src/config/settings.ts +0 -1
  8. package/src/exa/mcp-client.ts +57 -2
  9. package/src/extensibility/custom-tools/types.ts +2 -2
  10. package/src/extensibility/custom-tools/wrapper.ts +1 -1
  11. package/src/extensibility/extensions/wrapper.ts +1 -1
  12. package/src/extensibility/hooks/tool-wrapper.ts +1 -1
  13. package/src/internal-urls/docs-index.generated.ts +1 -2
  14. package/src/internal-urls/index.ts +2 -4
  15. package/src/internal-urls/router.ts +2 -2
  16. package/src/internal-urls/types.ts +2 -2
  17. package/src/mcp/oauth-flow.ts +1 -1
  18. package/src/modes/components/custom-editor.ts +6 -2
  19. package/src/modes/controllers/command-controller.ts +4 -46
  20. package/src/modes/controllers/input-controller.ts +123 -6
  21. package/src/modes/interactive-mode.ts +1 -84
  22. package/src/modes/types.ts +0 -1
  23. package/src/patch/edit-tool.ts +2 -11
  24. package/src/patch/hashline.ts +42 -0
  25. package/src/prompts/agents/explore.md +4 -2
  26. package/src/prompts/agents/librarian.md +4 -6
  27. package/src/prompts/agents/reviewer.md +1 -1
  28. package/src/prompts/agents/task.md +5 -1
  29. package/src/prompts/system/system-prompt.md +29 -18
  30. package/src/prompts/thread-extract.md +16 -0
  31. package/src/prompts/tools/render-mermaid.md +9 -0
  32. package/src/sdk.ts +12 -37
  33. package/src/session/agent-session.ts +5 -10
  34. package/src/session/retry-utils.ts +1 -1
  35. package/src/session/session-index.ts +329 -0
  36. package/src/session/session-manager.ts +0 -30
  37. package/src/session/streaming-edit.ts +1 -36
  38. package/src/slash-commands/builtin-registry.ts +0 -16
  39. package/src/task/index.ts +1 -1
  40. package/src/tools/ask.ts +9 -6
  41. package/src/tools/bash-skill-urls.ts +3 -3
  42. package/src/tools/bash.ts +2 -1
  43. package/src/tools/create-tools.ts +28 -33
  44. package/src/tools/fetch.ts +1 -1
  45. package/src/tools/find-thread.ts +120 -0
  46. package/src/tools/grep.ts +2 -1
  47. package/src/tools/index.ts +5 -0
  48. package/src/tools/python.ts +53 -1
  49. package/src/tools/read-thread.ts +409 -0
  50. package/src/tools/read.ts +4 -3
  51. package/src/tools/render-mermaid.ts +68 -0
  52. package/src/tools/save-memory.ts +182 -0
  53. package/src/tools/write.ts +1 -1
  54. package/src/web/search/index.ts +4 -1
  55. package/src/web/search/provider.ts +3 -0
  56. package/src/web/search/providers/anthropic.ts +1 -0
  57. package/src/web/search/providers/gemini.ts +122 -37
  58. package/src/web/search/providers/kagi.ts +163 -0
  59. package/src/web/search/types.ts +1 -0
  60. package/src/internal-urls/memory-protocol.ts +0 -133
  61. package/src/memories/index.ts +0 -1099
  62. package/src/memories/storage.ts +0 -563
  63. package/src/patch/normative.ts +0 -72
  64. package/src/prompts/memories/consolidation.md +0 -30
  65. package/src/prompts/memories/read_path.md +0 -11
  66. package/src/prompts/memories/stage_one_input.md +0 -6
  67. package/src/prompts/memories/stage_one_system.md +0 -21
  68. package/src/stt/downloader.ts +0 -68
  69. package/src/stt/index.ts +0 -3
  70. package/src/stt/recorder.ts +0 -351
  71. package/src/stt/setup.ts +0 -50
  72. package/src/stt/stt-controller.ts +0 -160
  73. package/src/stt/transcribe.py +0 -70
  74. package/src/stt/transcriber.ts +0 -91
@@ -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
+ }
@@ -1581,36 +1581,6 @@ export class SessionManager {
1581
1581
  await this.#rewriteFile();
1582
1582
  }
1583
1583
 
1584
- /**
1585
- * Rewrite tool call arguments in the most recent assistant message containing the toolCallId.
1586
- * Returns true if a tool call was updated.
1587
- */
1588
- async rewriteAssistantToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<boolean> {
1589
- let updated = false;
1590
- for (let i = this.#fileEntries.length - 1; i >= 0; i--) {
1591
- const entry = this.#fileEntries[i];
1592
- if (entry.type !== "message" || entry.message.role !== "assistant") continue;
1593
- const message = entry.message as { content?: unknown };
1594
- if (!Array.isArray(message.content)) continue;
1595
- for (const block of message.content) {
1596
- if (typeof block !== "object" || block === null) continue;
1597
- if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
1598
- const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
1599
- if (toolCall.id === toolCallId) {
1600
- toolCall.arguments = args;
1601
- updated = true;
1602
- break;
1603
- }
1604
- }
1605
- if (updated) break;
1606
- }
1607
-
1608
- if (updated && this.persist && this.#sessionFile) {
1609
- await this.#rewriteFile();
1610
- }
1611
- return updated;
1612
- }
1613
-
1614
1584
  /**
1615
1585
  * Append a custom message entry (for extensions) that participates in LLM context.
1616
1586
  * @param customType Hook identifier for filtering on reload
@@ -1,13 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
 
3
3
  import type { Agent, AgentEvent } from "@nghyane/arcane-agent";
4
- import type { AssistantMessage, ToolCall } from "@nghyane/arcane-ai";
4
+ import type { ToolCall } from "@nghyane/arcane-ai";
5
5
  import { isEnoent, logger } from "@nghyane/arcane-utils";
6
6
  import type { Settings } from "../config/settings";
7
7
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
8
8
  import type { SecretObfuscator } from "../secrets/obfuscator";
9
9
  import { resolveToCwd } from "../tools/path-utils";
10
- import type { SessionManager } from "./session-manager";
11
10
 
12
11
  /**
13
12
  * Mutable state for streaming edit abort detection.
@@ -222,37 +221,3 @@ async function checkPreviewPatchAsync(
222
221
  agent.abort();
223
222
  }
224
223
  }
225
-
226
- /**
227
- * Rewrite tool call arguments in agent state and persisted session history.
228
- */
229
- export async function rewriteToolCallArgs(
230
- agent: Agent,
231
- sessionManager: SessionManager,
232
- toolCallId: string,
233
- args: Record<string, unknown>,
234
- ): Promise<void> {
235
- let updated = false;
236
- const messages = agent.state.messages;
237
- for (let i = messages.length - 1; i >= 0; i--) {
238
- const msg = messages[i];
239
- if (msg.role !== "assistant") continue;
240
- const assistantMsg = msg as AssistantMessage;
241
- if (!Array.isArray(assistantMsg.content)) continue;
242
- for (const block of assistantMsg.content) {
243
- if (typeof block !== "object" || block === null) continue;
244
- if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
245
- const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
246
- if (toolCall.id === toolCallId) {
247
- toolCall.arguments = args;
248
- updated = true;
249
- break;
250
- }
251
- }
252
- if (updated) break;
253
- }
254
-
255
- if (updated) {
256
- await sessionManager.rewriteAssistantToolCallArgs(toolCallId, args);
257
- }
258
- }
@@ -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;
package/src/tools/bash.ts CHANGED
@@ -55,7 +55,8 @@ function isInteractiveResult(result: BashResult | BashInteractiveResult): result
55
55
  export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, Theme> {
56
56
  readonly name = "bash";
57
57
  readonly label = "Bash";
58
- description = "Execute a shell command";
58
+ description =
59
+ "Execute a shell command. Use grep/find instead of shell grep/find, read instead of cat/head/tail, edit instead of sed/awk, write instead of echo/printf redirects.";
59
60
  readonly parameters = bashSchema;
60
61
  readonly concurrency = "exclusive";
61
62
 
@@ -1,7 +1,5 @@
1
1
  import type { AgentTool } from "@nghyane/arcane-agent";
2
- import { $env, logger } from "@nghyane/arcane-utils";
3
- import { getPreludeDocs, warmPythonEnvironment } from "../ipy/executor";
4
- import { checkPythonKernelAvailability } from "../ipy/kernel";
2
+ import { $env } from "@nghyane/arcane-utils";
5
3
  import { LspTool } from "../lsp";
6
4
  import { EditTool } from "../patch";
7
5
  import { TaskTool } from "../task";
@@ -13,6 +11,7 @@ import { BrowserTool } from "./browser";
13
11
  import { exploreConfig } from "./explore";
14
12
  import { FetchTool } from "./fetch";
15
13
  import { FindTool } from "./find";
14
+ import { FindThreadTool } from "./find-thread";
16
15
  import { GitHubTool } from "./github";
17
16
  import { GrepTool } from "./grep";
18
17
  import type { ToolSession } from "./index";
@@ -22,7 +21,10 @@ import { oracleConfig } from "./oracle";
22
21
  import { wrapToolWithMetaNotice } from "./output-meta";
23
22
  import { PythonTool } from "./python";
24
23
  import { ReadTool } from "./read";
24
+ import { ReadThreadTool } from "./read-thread";
25
+ import { RenderMermaidTool } from "./render-mermaid";
25
26
  import { reviewerConfig } from "./reviewer-tool";
27
+ import { SaveMemoryTool } from "./save-memory";
26
28
  import { SearchCodeTool } from "./search-code";
27
29
  import { loadSshTool } from "./ssh";
28
30
  import { SubagentTool } from "./subagent-tool";
@@ -38,6 +40,8 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
38
40
  python: s => new PythonTool(s),
39
41
  ssh: loadSshTool,
40
42
  edit: s => new EditTool(s),
43
+ find_thread: s => new FindThreadTool(s),
44
+ read_thread: s => new ReadThreadTool(s),
41
45
  find: s => new FindTool(s),
42
46
  explore: s => new SubagentTool(s, exploreConfig),
43
47
  github: s => new GitHubTool(s),
@@ -47,6 +51,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
47
51
  notebook: s => new NotebookTool(s),
48
52
  oracle: s => new SubagentTool(s, oracleConfig),
49
53
  read: s => new ReadTool(s),
54
+ render_mermaid: s => new RenderMermaidTool(s),
50
55
  browser: s => new BrowserTool(s),
51
56
  task: TaskTool.create,
52
57
  code_review: s => new SubagentTool(s, reviewerConfig),
@@ -55,6 +60,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
55
60
  fetch: s => new FetchTool(s),
56
61
  web_search: () => new SearchTool(),
57
62
  search_code: () => new SearchCodeTool(),
63
+ save_memory: s => new SaveMemoryTool(s),
58
64
  write: s => new WriteTool(s),
59
65
  };
60
66
 
@@ -98,37 +104,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
98
104
  const enableLsp = session.enableLsp ?? true;
99
105
  const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined;
100
106
  const pythonMode = getPythonModeFromEnv() ?? session.settings.get("python.toolMode");
101
- const skipPythonPreflight = session.skipPythonPreflight === true;
102
- let pythonAvailable = true;
103
- const shouldCheckPython =
104
- !skipPythonPreflight &&
105
- pythonMode !== "bash-only" &&
106
- (requestedTools === undefined || requestedTools.includes("python"));
107
- const isTestEnv = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
108
- const skipPythonWarm = isTestEnv || $env.ARCANE_PYTHON_SKIP_CHECK === "1";
109
- if (shouldCheckPython) {
110
- const availability = await checkPythonKernelAvailability(session.cwd);
111
- time("createTools:pythonCheck");
112
- pythonAvailable = availability.ok;
113
- if (!availability.ok) {
114
- logger.warn("Python kernel unavailable, falling back to bash", {
115
- reason: availability.reason,
116
- });
117
- } else if (!skipPythonWarm && getPreludeDocs().length === 0) {
118
- const sessionFile = session.getSessionFile?.() ?? undefined;
119
- const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
120
- try {
121
- await warmPythonEnvironment(session.cwd, warmSessionId, session.settings.get("python.sharedGateway"));
122
- time("createTools:warmPython");
123
- } catch (err) {
124
- logger.warn("Failed to warm Python environment", {
125
- error: err instanceof Error ? err.message : String(err),
126
- });
127
- }
128
- }
129
- }
130
107
 
131
- const effectiveMode = pythonAvailable ? pythonMode : "bash-only";
108
+ const effectiveMode = pythonMode;
132
109
  const allowBash = effectiveMode !== "ipy-only";
133
110
  const allowPython = effectiveMode !== "bash-only";
134
111
  if (
@@ -153,6 +130,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
153
130
  if (name === "web_search") return session.settings.get("web_search.enabled");
154
131
  if (name === "lsp") return session.settings.get("lsp.enabled");
155
132
  if (name === "browser") return session.settings.get("browser.enabled");
133
+ if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
156
134
  if (name === "librarian") return session.settings.get("librarian.enabled");
157
135
  if (name === "oracle") return session.settings.get("oracle.enabled");
158
136
  if (name === "github") return session.settings.get("github.enabled");
@@ -182,5 +160,22 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
182
160
  );
183
161
  const tools = results.filter((r): r is AgentTool => r !== null);
184
162
 
163
+ // Auto-include AST tools when their text-based counterparts are enabled
164
+ const toolNameSet = new Set(tools.map(t => t.name));
165
+ if (session.settings.get("astGrep.enabled") && toolNameSet.has("grep") && !toolNameSet.has("ast_grep")) {
166
+ const astGrepFactory = allTools.ast_grep;
167
+ if (astGrepFactory) {
168
+ const astGrepTool = await astGrepFactory(session);
169
+ if (astGrepTool) tools.push(wrapToolWithMetaNotice(astGrepTool));
170
+ }
171
+ }
172
+ if (session.settings.get("astEdit.enabled") && toolNameSet.has("edit") && !toolNameSet.has("ast_edit")) {
173
+ const astEditFactory = allTools.ast_edit;
174
+ if (astEditFactory) {
175
+ const astEditTool = await astEditFactory(session);
176
+ if (astEditTool) tools.push(wrapToolWithMetaNotice(astEditTool));
177
+ }
178
+ }
179
+
185
180
  return tools;
186
181
  }
@@ -850,7 +850,7 @@ export interface FetchToolDetails {
850
850
  export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails, Theme> {
851
851
  readonly name = "fetch";
852
852
  readonly label = "Fetch";
853
- description = "Fetch a URL and return its content";
853
+ description = "Fetch a URL and return its content. Do NOT use for localhost or local URLs; use bash curl instead.";
854
854
  readonly parameters = fetchSchema;
855
855
 
856
856
  constructor(private readonly session: ToolSession) {}