botholomew 0.12.3 → 0.13.0
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/README.md +91 -68
- package/package.json +3 -3
- package/src/chat/agent.ts +42 -82
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +177 -926
- package/src/commands/db.ts +9 -13
- package/src/commands/init.ts +4 -1
- package/src/commands/nuke.ts +57 -90
- package/src/commands/schedule.ts +103 -124
- package/src/commands/skill.ts +2 -2
- package/src/commands/task.ts +86 -95
- package/src/commands/thread.ts +107 -112
- package/src/commands/worker.ts +88 -88
- package/src/constants.ts +93 -16
- package/src/context/capabilities.ts +10 -10
- package/src/context/fetcher.ts +9 -10
- package/src/context/reindex.ts +189 -0
- package/src/context/store.ts +630 -0
- package/src/db/doctor.ts +1 -8
- package/src/db/embeddings.ts +227 -175
- package/src/db/sql/19-disk_backed_index.sql +36 -0
- package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
- package/src/fs/atomic.ts +217 -0
- package/src/fs/compat.ts +86 -0
- package/src/fs/sandbox.ts +279 -0
- package/src/init/index.ts +69 -52
- package/src/init/templates.ts +1 -1
- package/src/mcpx/client.ts +1 -1
- package/src/schedules/schema.ts +19 -0
- package/src/schedules/store.ts +296 -0
- package/src/skills/commands.ts +1 -3
- package/src/tasks/schema.ts +47 -0
- package/src/tasks/store.ts +486 -0
- package/src/threads/store.ts +559 -0
- package/src/tools/capabilities/refresh.ts +42 -21
- package/src/tools/context/pipe.ts +15 -71
- package/src/tools/context/update-beliefs.ts +3 -3
- package/src/tools/context/update-goals.ts +3 -3
- package/src/tools/dir/create.ts +26 -23
- package/src/tools/dir/size.ts +46 -17
- package/src/tools/dir/tree.ts +73 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +44 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +25 -85
- package/src/tools/file/move.ts +39 -24
- package/src/tools/file/read.ts +32 -80
- package/src/tools/file/write.ts +14 -91
- package/src/tools/registry.ts +3 -7
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/list.ts +7 -3
- package/src/tools/search/fuse.ts +12 -33
- package/src/tools/search/index.ts +36 -43
- package/src/tools/search/regexp.ts +29 -17
- package/src/tools/search/semantic.ts +137 -51
- package/src/tools/skill/delete.ts +1 -1
- package/src/tools/skill/list.ts +1 -1
- package/src/tools/skill/write.ts +1 -1
- package/src/tools/task/create.ts +41 -16
- package/src/tools/task/delete.ts +3 -3
- package/src/tools/task/list.ts +6 -3
- package/src/tools/task/update.ts +31 -9
- package/src/tools/task/view.ts +6 -6
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/thread/search.ts +208 -0
- package/src/tools/thread/view.ts +50 -5
- package/src/tools/worker/spawn.ts +28 -14
- package/src/tui/App.tsx +12 -19
- package/src/tui/components/ContextPanel.tsx +83 -316
- package/src/tui/components/SchedulePanel.tsx +34 -48
- package/src/tui/components/StatusBar.tsx +15 -15
- package/src/tui/components/TaskPanel.tsx +34 -38
- package/src/tui/components/ThreadPanel.tsx +29 -38
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- package/src/types/file-imports.d.ts +9 -0
- package/src/utils/title.ts +5 -7
- package/src/utils/v7-date.ts +47 -0
- package/src/worker/heartbeat.ts +46 -24
- package/src/worker/index.ts +13 -15
- package/src/worker/llm.ts +30 -37
- package/src/worker/prompt.ts +19 -41
- package/src/worker/schedules.ts +48 -69
- package/src/worker/spawn.ts +11 -11
- package/src/worker/tick.ts +39 -43
- package/src/workers/store.ts +247 -0
- package/src/commands/tools.ts +0 -367
- package/src/context/describer.ts +0 -140
- package/src/context/drives.ts +0 -110
- package/src/context/ingest.ts +0 -162
- package/src/context/refresh.ts +0 -183
- package/src/db/context.ts +0 -637
- package/src/db/daemon-state.ts +0 -6
- package/src/db/reembed.ts +0 -113
- package/src/db/schedules.ts +0 -213
- package/src/db/tasks.ts +0 -347
- package/src/db/threads.ts +0 -276
- package/src/db/workers.ts +0 -212
- package/src/tools/context/list-drives.ts +0 -36
- package/src/tools/context/refresh.ts +0 -165
- package/src/tools/context/search.ts +0 -54
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { appendFile, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getThreadsDir } from "../constants.ts";
|
|
4
|
+
import { uuidv7 } from "../db/uuid.ts";
|
|
5
|
+
import { atomicWrite } from "../fs/atomic.ts";
|
|
6
|
+
import { DATE_DIR_RE, dateForId } from "../utils/v7-date.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thread + interaction history, stored as CSV files under
|
|
10
|
+
* `<projectDir>/threads/<YYYY-MM-DD>/<id>.csv`. Threads live OUTSIDE
|
|
11
|
+
* `context/` because they're system metadata, not user-curated knowledge —
|
|
12
|
+
* the regular context reindex skips them. Agents search threads through
|
|
13
|
+
* the dedicated `search_threads` tool instead.
|
|
14
|
+
*
|
|
15
|
+
* CSV schema (8 columns, RFC-4180 quoting):
|
|
16
|
+
* created_at, role, kind, content, tool_name, tool_input,
|
|
17
|
+
* duration_ms, token_count
|
|
18
|
+
*
|
|
19
|
+
* Thread metadata (title, source_type, parent_task_id, ended_at) is encoded
|
|
20
|
+
* as a synthetic first row with `kind="thread_meta"` whose `content` is a
|
|
21
|
+
* JSON blob. End-of-thread is a `kind="thread_ended"` row. That keeps the
|
|
22
|
+
* format pure CSV — no sidecar files, no frontmatter — at the cost of a
|
|
23
|
+
* full file rewrite when we need to update the title.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type ThreadType = "worker_tick" | "chat_session";
|
|
27
|
+
export type InteractionRole = "user" | "assistant" | "system" | "tool";
|
|
28
|
+
export type InteractionKind =
|
|
29
|
+
| "message"
|
|
30
|
+
| "thinking"
|
|
31
|
+
| "tool_use"
|
|
32
|
+
| "tool_result"
|
|
33
|
+
| "context_update"
|
|
34
|
+
| "status_change";
|
|
35
|
+
|
|
36
|
+
export interface Thread {
|
|
37
|
+
id: string;
|
|
38
|
+
type: ThreadType;
|
|
39
|
+
task_id: string | null;
|
|
40
|
+
title: string;
|
|
41
|
+
started_at: Date;
|
|
42
|
+
ended_at: Date | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Interaction {
|
|
46
|
+
id: string; // synthesized as `<thread_id>:<sequence>` for back-compat with callers
|
|
47
|
+
thread_id: string;
|
|
48
|
+
sequence: number;
|
|
49
|
+
role: InteractionRole;
|
|
50
|
+
kind: InteractionKind;
|
|
51
|
+
content: string;
|
|
52
|
+
tool_name: string | null;
|
|
53
|
+
tool_input: string | null;
|
|
54
|
+
duration_ms: number | null;
|
|
55
|
+
token_count: number | null;
|
|
56
|
+
created_at: Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const HEADER =
|
|
60
|
+
"created_at,role,kind,content,tool_name,tool_input,duration_ms,token_count\n";
|
|
61
|
+
|
|
62
|
+
interface ThreadMetaPayload {
|
|
63
|
+
type: ThreadType;
|
|
64
|
+
task_id: string | null;
|
|
65
|
+
title: string;
|
|
66
|
+
started_at: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The canonical write path for `id`: `threads/<YYYY-MM-DD>/<id>.csv`. The
|
|
71
|
+
* date subdir keeps the directory bounded as conversations accumulate;
|
|
72
|
+
* deriving the date from the id (not from `Date.now()`) means the path is
|
|
73
|
+
* a pure function of the id, so reads after a process restart land in the
|
|
74
|
+
* same place.
|
|
75
|
+
*/
|
|
76
|
+
function threadFilePath(projectDir: string, id: string): string {
|
|
77
|
+
return join(getThreadsDir(projectDir), dateForId(id), `${id}.csv`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Locate the CSV for `id`. Tries the predicted v7-derived path first, then
|
|
82
|
+
* falls back to walking date subdirs — this catches legacy ids without a
|
|
83
|
+
* v7 timestamp and the rare case where a thread file got moved between
|
|
84
|
+
* dirs by hand. Returns null if no match exists.
|
|
85
|
+
*/
|
|
86
|
+
async function findThreadFile(
|
|
87
|
+
projectDir: string,
|
|
88
|
+
id: string,
|
|
89
|
+
): Promise<string | null> {
|
|
90
|
+
const predicted = threadFilePath(projectDir, id);
|
|
91
|
+
try {
|
|
92
|
+
await stat(predicted);
|
|
93
|
+
return predicted;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
96
|
+
}
|
|
97
|
+
// Fallback: walk date subdirs. Cheap because there's at most one file
|
|
98
|
+
// per (date, id) pair and the dir count grows with calendar days, not
|
|
99
|
+
// thread volume.
|
|
100
|
+
const root = getThreadsDir(projectDir);
|
|
101
|
+
let entries: string[];
|
|
102
|
+
try {
|
|
103
|
+
entries = await readdir(root);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!DATE_DIR_RE.test(entry)) continue;
|
|
110
|
+
const candidate = join(root, entry, `${id}.csv`);
|
|
111
|
+
try {
|
|
112
|
+
await stat(candidate);
|
|
113
|
+
return candidate;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Yield every `<id>` whose CSV exists somewhere under `threads/`. */
|
|
122
|
+
async function listThreadIds(projectDir: string): Promise<string[]> {
|
|
123
|
+
const root = getThreadsDir(projectDir);
|
|
124
|
+
let dateDirs: string[];
|
|
125
|
+
try {
|
|
126
|
+
dateDirs = await readdir(root);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
const ids: string[] = [];
|
|
132
|
+
for (const dir of dateDirs) {
|
|
133
|
+
if (!DATE_DIR_RE.test(dir)) continue;
|
|
134
|
+
let names: string[];
|
|
135
|
+
try {
|
|
136
|
+
names = await readdir(join(root, dir));
|
|
137
|
+
} catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
if (name.endsWith(".csv")) ids.push(name.slice(0, -".csv".length));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return ids;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function csvField(value: string | number | null | undefined): string {
|
|
148
|
+
if (value === null || value === undefined) return "";
|
|
149
|
+
const s = String(value);
|
|
150
|
+
if (/[",\r\n]/.test(s)) {
|
|
151
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
152
|
+
}
|
|
153
|
+
return s;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function csvRow(cells: Array<string | number | null | undefined>): string {
|
|
157
|
+
return `${cells.map(csvField).join(",")}\n`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse a CSV file produced by this module. Accepts RFC-4180 quoting:
|
|
162
|
+
* - fields may be quoted with `"`,
|
|
163
|
+
* - inside a quoted field, `""` is an escaped quote,
|
|
164
|
+
* - quoted fields may span newlines.
|
|
165
|
+
*/
|
|
166
|
+
function parseCsv(text: string): string[][] {
|
|
167
|
+
const rows: string[][] = [];
|
|
168
|
+
let row: string[] = [];
|
|
169
|
+
let field = "";
|
|
170
|
+
let inQuotes = false;
|
|
171
|
+
for (let i = 0; i < text.length; i++) {
|
|
172
|
+
const ch = text[i];
|
|
173
|
+
if (inQuotes) {
|
|
174
|
+
if (ch === '"') {
|
|
175
|
+
if (text[i + 1] === '"') {
|
|
176
|
+
field += '"';
|
|
177
|
+
i++;
|
|
178
|
+
} else {
|
|
179
|
+
inQuotes = false;
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
field += ch;
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (ch === '"') {
|
|
187
|
+
inQuotes = true;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === ",") {
|
|
191
|
+
row.push(field);
|
|
192
|
+
field = "";
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (ch === "\r") continue;
|
|
196
|
+
if (ch === "\n") {
|
|
197
|
+
row.push(field);
|
|
198
|
+
field = "";
|
|
199
|
+
rows.push(row);
|
|
200
|
+
row = [];
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
field += ch;
|
|
204
|
+
}
|
|
205
|
+
if (field.length > 0 || row.length > 0) {
|
|
206
|
+
row.push(field);
|
|
207
|
+
rows.push(row);
|
|
208
|
+
}
|
|
209
|
+
return rows;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function createThread(
|
|
213
|
+
projectDir: string,
|
|
214
|
+
type: ThreadType,
|
|
215
|
+
taskId?: string,
|
|
216
|
+
title?: string,
|
|
217
|
+
): Promise<string> {
|
|
218
|
+
const id = uuidv7();
|
|
219
|
+
const now = new Date().toISOString();
|
|
220
|
+
const meta: ThreadMetaPayload = {
|
|
221
|
+
type,
|
|
222
|
+
task_id: taskId ?? null,
|
|
223
|
+
title: title ?? "",
|
|
224
|
+
started_at: now,
|
|
225
|
+
};
|
|
226
|
+
const body =
|
|
227
|
+
HEADER +
|
|
228
|
+
csvRow([
|
|
229
|
+
now,
|
|
230
|
+
"system",
|
|
231
|
+
"thread_meta",
|
|
232
|
+
JSON.stringify(meta),
|
|
233
|
+
"",
|
|
234
|
+
"",
|
|
235
|
+
"",
|
|
236
|
+
"",
|
|
237
|
+
]);
|
|
238
|
+
await atomicWrite(threadFilePath(projectDir, id), body);
|
|
239
|
+
return id;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function logInteraction(
|
|
243
|
+
projectDir: string,
|
|
244
|
+
threadId: string,
|
|
245
|
+
params: {
|
|
246
|
+
role: InteractionRole;
|
|
247
|
+
kind: InteractionKind;
|
|
248
|
+
content: string;
|
|
249
|
+
toolName?: string;
|
|
250
|
+
toolInput?: string;
|
|
251
|
+
durationMs?: number;
|
|
252
|
+
tokenCount?: number;
|
|
253
|
+
},
|
|
254
|
+
): Promise<string> {
|
|
255
|
+
const path =
|
|
256
|
+
(await findThreadFile(projectDir, threadId)) ??
|
|
257
|
+
threadFilePath(projectDir, threadId);
|
|
258
|
+
const row = csvRow([
|
|
259
|
+
new Date().toISOString(),
|
|
260
|
+
params.role,
|
|
261
|
+
params.kind,
|
|
262
|
+
params.content,
|
|
263
|
+
params.toolName ?? "",
|
|
264
|
+
params.toolInput ?? "",
|
|
265
|
+
params.durationMs ?? "",
|
|
266
|
+
params.tokenCount ?? "",
|
|
267
|
+
]);
|
|
268
|
+
// Append is atomic-enough for a single writer (each thread is owned by
|
|
269
|
+
// one chat session or one worker tick at a time). If a second writer
|
|
270
|
+
// sneaks in we get interleaved bytes — a known accepted limitation; we
|
|
271
|
+
// can swap in a lockfile per-thread if it becomes an issue.
|
|
272
|
+
await appendFile(path, row, "utf-8");
|
|
273
|
+
// Synthesize an id stable across reads: `<thread>:<seq>`. Sequence is
|
|
274
|
+
// the data row index (rows after the header).
|
|
275
|
+
const sequence = (await readRows(path)).length - 1;
|
|
276
|
+
return `${threadId}:${sequence}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function endThread(
|
|
280
|
+
projectDir: string,
|
|
281
|
+
threadId: string,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const path =
|
|
284
|
+
(await findThreadFile(projectDir, threadId)) ??
|
|
285
|
+
threadFilePath(projectDir, threadId);
|
|
286
|
+
await appendFile(
|
|
287
|
+
path,
|
|
288
|
+
csvRow([
|
|
289
|
+
new Date().toISOString(),
|
|
290
|
+
"system",
|
|
291
|
+
"thread_ended",
|
|
292
|
+
"",
|
|
293
|
+
"",
|
|
294
|
+
"",
|
|
295
|
+
"",
|
|
296
|
+
"",
|
|
297
|
+
]),
|
|
298
|
+
"utf-8",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function reopenThread(
|
|
303
|
+
projectDir: string,
|
|
304
|
+
threadId: string,
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
// "Reopen" = drop the most recent thread_ended marker if there is one.
|
|
307
|
+
const path = await findThreadFile(projectDir, threadId);
|
|
308
|
+
if (!path) return;
|
|
309
|
+
const rows = await readRows(path);
|
|
310
|
+
if (rows.length === 0) return;
|
|
311
|
+
const last = rows[rows.length - 1];
|
|
312
|
+
if (!last) return;
|
|
313
|
+
if (last[2] !== "thread_ended") return;
|
|
314
|
+
rows.pop();
|
|
315
|
+
await rewrite(path, rows);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function updateThreadTitle(
|
|
319
|
+
projectDir: string,
|
|
320
|
+
threadId: string,
|
|
321
|
+
title: string,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const path = await findThreadFile(projectDir, threadId);
|
|
324
|
+
if (!path) return;
|
|
325
|
+
const rows = await readRows(path);
|
|
326
|
+
const metaIdx = rows.findIndex((r) => r[2] === "thread_meta");
|
|
327
|
+
if (metaIdx === -1) return;
|
|
328
|
+
const metaRow = rows[metaIdx];
|
|
329
|
+
if (!metaRow) return;
|
|
330
|
+
let meta: ThreadMetaPayload;
|
|
331
|
+
try {
|
|
332
|
+
meta = JSON.parse(metaRow[3] ?? "{}") as ThreadMetaPayload;
|
|
333
|
+
} catch {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
meta.title = title;
|
|
337
|
+
metaRow[3] = JSON.stringify(meta);
|
|
338
|
+
await rewrite(path, rows);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function getThread(
|
|
342
|
+
projectDir: string,
|
|
343
|
+
threadId: string,
|
|
344
|
+
): Promise<{ thread: Thread; interactions: Interaction[] } | null> {
|
|
345
|
+
const path = await findThreadFile(projectDir, threadId);
|
|
346
|
+
if (!path) return null;
|
|
347
|
+
const rows = await readRows(path);
|
|
348
|
+
if (rows.length === 0) return null;
|
|
349
|
+
return rowsToThread(threadId, rows);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function deleteThread(
|
|
353
|
+
projectDir: string,
|
|
354
|
+
threadId: string,
|
|
355
|
+
): Promise<boolean> {
|
|
356
|
+
const path = await findThreadFile(projectDir, threadId);
|
|
357
|
+
if (!path) return false;
|
|
358
|
+
try {
|
|
359
|
+
await rm(path);
|
|
360
|
+
return true;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function deleteAllThreads(
|
|
368
|
+
projectDir: string,
|
|
369
|
+
): Promise<{ threads: number; interactions: number }> {
|
|
370
|
+
const root = getThreadsDir(projectDir);
|
|
371
|
+
let dateDirs: string[];
|
|
372
|
+
try {
|
|
373
|
+
dateDirs = await readdir(root);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
376
|
+
return { threads: 0, interactions: 0 };
|
|
377
|
+
}
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
let threads = 0;
|
|
381
|
+
let interactions = 0;
|
|
382
|
+
for (const dir of dateDirs) {
|
|
383
|
+
if (!DATE_DIR_RE.test(dir)) continue;
|
|
384
|
+
const subdir = join(root, dir);
|
|
385
|
+
let names: string[];
|
|
386
|
+
try {
|
|
387
|
+
names = await readdir(subdir);
|
|
388
|
+
} catch {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
for (const name of names) {
|
|
392
|
+
if (!name.endsWith(".csv")) continue;
|
|
393
|
+
const path = join(subdir, name);
|
|
394
|
+
const rows = await readRows(path);
|
|
395
|
+
interactions += Math.max(0, rows.length - 1); // exclude meta row
|
|
396
|
+
await rm(path).catch(() => {});
|
|
397
|
+
threads++;
|
|
398
|
+
}
|
|
399
|
+
// Best-effort cleanup of the now-empty date dir.
|
|
400
|
+
await rm(subdir, { recursive: false }).catch(() => {});
|
|
401
|
+
}
|
|
402
|
+
return { threads, interactions };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function getInteractionsAfter(
|
|
406
|
+
projectDir: string,
|
|
407
|
+
threadId: string,
|
|
408
|
+
afterSequence: number,
|
|
409
|
+
): Promise<Interaction[]> {
|
|
410
|
+
const t = await getThread(projectDir, threadId);
|
|
411
|
+
if (!t) return [];
|
|
412
|
+
return t.interactions.filter((i) => i.sequence > afterSequence);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function getActiveThread(
|
|
416
|
+
projectDir: string,
|
|
417
|
+
): Promise<Thread | null> {
|
|
418
|
+
const summaries = await listThreads(projectDir);
|
|
419
|
+
for (const t of summaries) {
|
|
420
|
+
if (!t.ended_at) return t;
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function isThreadEnded(
|
|
426
|
+
projectDir: string,
|
|
427
|
+
threadId: string,
|
|
428
|
+
): Promise<boolean> {
|
|
429
|
+
const t = await getThread(projectDir, threadId);
|
|
430
|
+
if (!t) return true;
|
|
431
|
+
return t.thread.ended_at !== null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export async function listThreads(
|
|
435
|
+
projectDir: string,
|
|
436
|
+
filters?: {
|
|
437
|
+
type?: ThreadType;
|
|
438
|
+
taskId?: string;
|
|
439
|
+
limit?: number;
|
|
440
|
+
offset?: number;
|
|
441
|
+
},
|
|
442
|
+
): Promise<Thread[]> {
|
|
443
|
+
const ids = await listThreadIds(projectDir);
|
|
444
|
+
const out: Thread[] = [];
|
|
445
|
+
for (const id of ids) {
|
|
446
|
+
const data = await getThread(projectDir, id);
|
|
447
|
+
if (!data) continue;
|
|
448
|
+
const t = data.thread;
|
|
449
|
+
if (filters?.type && t.type !== filters.type) continue;
|
|
450
|
+
if (filters?.taskId && t.task_id !== filters.taskId) continue;
|
|
451
|
+
out.push(t);
|
|
452
|
+
}
|
|
453
|
+
out.sort((a, b) => b.started_at.getTime() - a.started_at.getTime());
|
|
454
|
+
const offset = filters?.offset ?? 0;
|
|
455
|
+
const limit = filters?.limit ?? out.length;
|
|
456
|
+
return out.slice(offset, offset + limit);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// internals
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
async function readRows(path: string): Promise<string[][]> {
|
|
464
|
+
let text: string;
|
|
465
|
+
try {
|
|
466
|
+
text = await readFile(path, "utf-8");
|
|
467
|
+
} catch (err) {
|
|
468
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
const rows = parseCsv(text);
|
|
472
|
+
if (rows.length > 0 && rows[0]?.[0] === "created_at") {
|
|
473
|
+
rows.shift(); // drop header
|
|
474
|
+
}
|
|
475
|
+
return rows;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function rewrite(path: string, rows: string[][]): Promise<void> {
|
|
479
|
+
const body =
|
|
480
|
+
HEADER +
|
|
481
|
+
rows
|
|
482
|
+
.map((r) =>
|
|
483
|
+
csvRow([
|
|
484
|
+
r[0] ?? "",
|
|
485
|
+
r[1] ?? "",
|
|
486
|
+
r[2] ?? "",
|
|
487
|
+
r[3] ?? "",
|
|
488
|
+
r[4] ?? "",
|
|
489
|
+
r[5] ?? "",
|
|
490
|
+
r[6] ?? "",
|
|
491
|
+
r[7] ?? "",
|
|
492
|
+
]),
|
|
493
|
+
)
|
|
494
|
+
.join("");
|
|
495
|
+
await atomicWrite(path, body);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function rowsToThread(
|
|
499
|
+
threadId: string,
|
|
500
|
+
rows: string[][],
|
|
501
|
+
): { thread: Thread; interactions: Interaction[] } | null {
|
|
502
|
+
const metaRow = rows.find((r) => r[2] === "thread_meta");
|
|
503
|
+
if (!metaRow) return null;
|
|
504
|
+
let meta: ThreadMetaPayload;
|
|
505
|
+
try {
|
|
506
|
+
meta = JSON.parse(metaRow[3] ?? "{}") as ThreadMetaPayload;
|
|
507
|
+
} catch {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const startedAt = new Date(meta.started_at);
|
|
511
|
+
const endedRow = [...rows].reverse().find((r) => r[2] === "thread_ended");
|
|
512
|
+
const endedAt = endedRow ? new Date(endedRow[0] ?? "") : null;
|
|
513
|
+
|
|
514
|
+
const interactions: Interaction[] = [];
|
|
515
|
+
let seq = 0;
|
|
516
|
+
for (const r of rows) {
|
|
517
|
+
if (r[2] === "thread_meta" || r[2] === "thread_ended") continue;
|
|
518
|
+
seq += 1;
|
|
519
|
+
const role = (r[1] ?? "system") as InteractionRole;
|
|
520
|
+
const kind = (r[2] ?? "message") as InteractionKind;
|
|
521
|
+
interactions.push({
|
|
522
|
+
id: `${threadId}:${seq}`,
|
|
523
|
+
thread_id: threadId,
|
|
524
|
+
sequence: seq,
|
|
525
|
+
role,
|
|
526
|
+
kind,
|
|
527
|
+
content: r[3] ?? "",
|
|
528
|
+
tool_name: r[4] ? r[4] : null,
|
|
529
|
+
tool_input: r[5] ? r[5] : null,
|
|
530
|
+
duration_ms: r[6] ? Number(r[6]) : null,
|
|
531
|
+
token_count: r[7] ? Number(r[7]) : null,
|
|
532
|
+
created_at: new Date(r[0] ?? ""),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
thread: {
|
|
538
|
+
id: threadId,
|
|
539
|
+
type: meta.type,
|
|
540
|
+
task_id: meta.task_id,
|
|
541
|
+
title: meta.title,
|
|
542
|
+
started_at: startedAt,
|
|
543
|
+
ended_at: endedAt,
|
|
544
|
+
},
|
|
545
|
+
interactions,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Best-effort ensure the threads directory exists (e.g. before first write). */
|
|
550
|
+
export async function ensureThreadsDir(projectDir: string): Promise<void> {
|
|
551
|
+
const dir = getThreadsDir(projectDir);
|
|
552
|
+
try {
|
|
553
|
+
await stat(dir);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
556
|
+
const { mkdir } = await import("node:fs/promises");
|
|
557
|
+
await mkdir(dir, { recursive: true });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
@@ -12,42 +12,63 @@ const inputSchema = z.object({
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
const outputSchema = z.object({
|
|
15
|
-
path: z.string(),
|
|
15
|
+
path: z.string().nullable(),
|
|
16
16
|
internal_tool_count: z.number(),
|
|
17
17
|
mcp_tool_count: z.number(),
|
|
18
18
|
created_file: z.boolean(),
|
|
19
19
|
message: z.string(),
|
|
20
20
|
is_error: z.boolean(),
|
|
21
|
+
error_type: z.string().optional(),
|
|
22
|
+
next_action_hint: z.string().optional(),
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
export const capabilitiesRefreshTool = {
|
|
24
26
|
name: "capabilities_refresh",
|
|
25
27
|
description:
|
|
26
|
-
"[[ bash equivalent command: which ]] Rescan every available tool (built-in + configured MCPX servers) and rewrite
|
|
28
|
+
"[[ bash equivalent command: which ]] Rescan every available tool (built-in + configured MCPX servers) and rewrite `prompts/capabilities.md`. Call this when you think the inventory is stale — new MCP servers were added, tools were renamed, or the capabilities file was deleted. The regenerated file is automatically loaded into every subsequent system prompt.",
|
|
27
29
|
group: "capabilities",
|
|
28
30
|
inputSchema,
|
|
29
31
|
outputSchema,
|
|
30
32
|
execute: async (input, ctx) => {
|
|
31
33
|
const includeMcp = input.include_mcp !== false;
|
|
32
34
|
const client = includeMcp ? ctx.mcpxClient : null;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
35
|
+
try {
|
|
36
|
+
const result = await writeCapabilitiesFile(
|
|
37
|
+
ctx.projectDir,
|
|
38
|
+
client,
|
|
39
|
+
ctx.config,
|
|
40
|
+
);
|
|
41
|
+
const parts = [
|
|
42
|
+
`${result.counts.internal} internal tool(s)`,
|
|
43
|
+
`${result.counts.mcp} MCPX tool(s)`,
|
|
44
|
+
];
|
|
45
|
+
if (!includeMcp) parts.push("MCPX skipped");
|
|
46
|
+
if (result.createdFile) parts.push("file created");
|
|
47
|
+
return {
|
|
48
|
+
path: result.path,
|
|
49
|
+
internal_tool_count: result.counts.internal,
|
|
50
|
+
mcp_tool_count: result.counts.mcp,
|
|
51
|
+
created_file: result.createdFile,
|
|
52
|
+
message: `Wrote capabilities.md (${parts.join(", ")})`,
|
|
53
|
+
is_error: false,
|
|
54
|
+
};
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// writeCapabilitiesFile may call out to Anthropic for a thematic
|
|
57
|
+
// summary; transient API errors shouldn't crash the agent loop.
|
|
58
|
+
// The static fallback path inside generateCapabilitiesMarkdown
|
|
59
|
+
// already covers the no-key case, so getting here means an
|
|
60
|
+
// unexpected I/O or LLM failure.
|
|
61
|
+
return {
|
|
62
|
+
path: null,
|
|
63
|
+
internal_tool_count: 0,
|
|
64
|
+
mcp_tool_count: 0,
|
|
65
|
+
created_file: false,
|
|
66
|
+
message: err instanceof Error ? err.message : String(err),
|
|
67
|
+
is_error: true,
|
|
68
|
+
error_type: "refresh_failed",
|
|
69
|
+
next_action_hint:
|
|
70
|
+
"Try again later or pass include_mcp=false to skip the MCPX enumeration.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
52
73
|
},
|
|
53
74
|
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|