@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.
- package/CHANGELOG.md +21 -0
- package/package.json +7 -15
- package/src/config/keybindings.ts +9 -7
- package/src/config/settings-schema.ts +19 -46
- package/src/config/settings.ts +0 -1
- package/src/exa/mcp-client.ts +57 -2
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/internal-urls/index.ts +2 -4
- package/src/internal-urls/router.ts +2 -2
- package/src/internal-urls/types.ts +2 -2
- package/src/mcp/oauth-flow.ts +1 -1
- package/src/modes/controllers/command-controller.ts +26 -64
- package/src/modes/utils/ui-helpers.ts +2 -1
- package/src/patch/hashline.ts +42 -0
- package/src/prompts/system/system-prompt.md +14 -10
- package/src/prompts/thread-extract.md +16 -0
- package/src/prompts/tools/render-mermaid.md +9 -0
- package/src/sdk.ts +1 -19
- package/src/session/agent-session.ts +4 -3
- package/src/session/retry-utils.ts +1 -1
- package/src/session/session-index.ts +329 -0
- package/src/slash-commands/builtin-registry.ts +0 -16
- package/src/task/index.ts +1 -1
- package/src/tools/ask.ts +9 -6
- package/src/tools/bash-skill-urls.ts +3 -3
- package/src/tools/create-tools.ts +26 -0
- package/src/tools/find-thread.ts +120 -0
- package/src/tools/index.ts +5 -0
- package/src/tools/read-thread.ts +409 -0
- package/src/tools/read.ts +2 -2
- package/src/tools/render-mermaid.ts +68 -0
- package/src/tools/save-memory.ts +182 -0
- package/src/web/search/index.ts +2 -0
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/anthropic.ts +1 -0
- package/src/web/search/providers/gemini.ts +122 -37
- package/src/web/search/providers/kagi.ts +163 -0
- package/src/web/search/types.ts +1 -0
- package/src/internal-urls/memory-protocol.ts +0 -133
- package/src/memories/index.ts +0 -1099
- package/src/memories/storage.ts +0 -563
- package/src/prompts/memories/consolidation.md +0 -30
- package/src/prompts/memories/read_path.md +0 -11
- package/src/prompts/memories/stage_one_input.md +0 -6
- 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|
|
|
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", "
|
|
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://,
|
|
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
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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 {
|