botholomew 0.7.7 → 0.7.9
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 +2 -0
- package/package.json +1 -1
- package/src/chat/agent.ts +68 -35
- package/src/chat/session.ts +27 -31
- package/src/commands/context.ts +334 -32
- package/src/commands/daemon.ts +15 -2
- package/src/commands/schedule.ts +10 -1
- package/src/commands/skill.ts +18 -3
- package/src/commands/task.ts +28 -4
- package/src/commands/thread.ts +3 -1
- package/src/commands/tools.ts +2 -0
- package/src/commands/with-db.ts +8 -9
- package/src/context/describer.ts +111 -2
- package/src/context/fetcher.ts +1 -0
- package/src/context/ingest.ts +3 -1
- package/src/daemon/index.ts +6 -5
- package/src/daemon/llm.ts +68 -42
- package/src/daemon/prompt.ts +6 -4
- package/src/daemon/schedules.ts +15 -10
- package/src/daemon/tick.ts +55 -39
- package/src/db/connection.ts +143 -14
- package/src/db/context.ts +33 -0
- package/src/db/schedules.ts +7 -3
- package/src/db/tasks.ts +6 -6
- package/src/db/threads.ts +6 -4
- package/src/tools/file/write.ts +52 -13
- package/src/tools/tool.ts +8 -0
- package/src/tui/App.tsx +16 -11
- package/src/tui/components/ContextPanel.tsx +19 -15
- package/src/tui/components/SchedulePanel.tsx +15 -9
- package/src/tui/components/StatusBar.tsx +8 -6
- package/src/tui/components/TaskPanel.tsx +6 -6
- package/src/tui/components/ThreadPanel.tsx +29 -19
- package/src/utils/title.ts +5 -3
package/src/commands/context.ts
CHANGED
|
@@ -6,7 +6,10 @@ import { isText } from "istextorbinary";
|
|
|
6
6
|
import { createSpinner } from "nanospinner";
|
|
7
7
|
import { loadConfig } from "../config/loader.ts";
|
|
8
8
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
generateDescription,
|
|
11
|
+
generateDescriptionAndPath,
|
|
12
|
+
} from "../context/describer.ts";
|
|
10
13
|
import { embedSingle } from "../context/embedder.ts";
|
|
11
14
|
import { FetchFailureError, fetchUrl } from "../context/fetcher.ts";
|
|
12
15
|
import {
|
|
@@ -19,9 +22,12 @@ import { isUrl, urlToContextPath } from "../context/url-utils.ts";
|
|
|
19
22
|
import type { DbConnection } from "../db/connection.ts";
|
|
20
23
|
import {
|
|
21
24
|
type ContextItem,
|
|
25
|
+
createContextItemStrict,
|
|
22
26
|
deleteContextItemByPath,
|
|
27
|
+
getContextItemByPath,
|
|
23
28
|
listContextItems,
|
|
24
29
|
listContextItemsByPrefix,
|
|
30
|
+
PathConflictError,
|
|
25
31
|
resolveContextItem,
|
|
26
32
|
upsertContextItem,
|
|
27
33
|
} from "../db/context.ts";
|
|
@@ -95,16 +101,41 @@ export function registerContextCommand(program: Command) {
|
|
|
95
101
|
ctx
|
|
96
102
|
.command("add <paths...>")
|
|
97
103
|
.description("Add files, directories, or URLs to context")
|
|
98
|
-
.option(
|
|
104
|
+
.option(
|
|
105
|
+
"--prefix <prefix>",
|
|
106
|
+
"virtual path prefix (if omitted, an LLM suggests a folder for each file)",
|
|
107
|
+
)
|
|
99
108
|
.option("--name <path>", "custom context path (single URL only)")
|
|
109
|
+
.option(
|
|
110
|
+
"--on-conflict <policy>",
|
|
111
|
+
"collision policy: error | overwrite | skip",
|
|
112
|
+
"error",
|
|
113
|
+
)
|
|
114
|
+
.option(
|
|
115
|
+
"--auto-place",
|
|
116
|
+
"accept all LLM-suggested paths without confirmation",
|
|
117
|
+
)
|
|
100
118
|
.option(
|
|
101
119
|
"--prompt-addition <text>",
|
|
102
120
|
"extra guidance for the URL fetcher agent (e.g., auth notes, tool hints)",
|
|
103
121
|
)
|
|
104
122
|
.action((paths: string[], opts) =>
|
|
105
123
|
withDb(program, async (conn, dir) => {
|
|
124
|
+
type ConflictPolicy = "error" | "overwrite" | "skip";
|
|
125
|
+
const policy = opts.onConflict as ConflictPolicy;
|
|
126
|
+
if (!["error", "overwrite", "skip"].includes(policy)) {
|
|
127
|
+
logger.error(
|
|
128
|
+
`Invalid --on-conflict value: ${policy} (must be error, overwrite, or skip)`,
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
|
|
106
133
|
// Phase 1: Scan all paths — separate URLs from local files
|
|
107
|
-
|
|
134
|
+
type FileToAdd = {
|
|
135
|
+
filePath: string;
|
|
136
|
+
contextPath: string | null; // null = defer to LLM placement
|
|
137
|
+
};
|
|
138
|
+
const filesToAdd: FileToAdd[] = [];
|
|
108
139
|
const urlsToAdd: { url: string; contextPath: string }[] = [];
|
|
109
140
|
const spinner = createSpinner("Scanning paths...").start();
|
|
110
141
|
|
|
@@ -116,10 +147,14 @@ export function registerContextCommand(program: Command) {
|
|
|
116
147
|
process.exit(1);
|
|
117
148
|
}
|
|
118
149
|
|
|
150
|
+
// Explicit placement: user passed --prefix (or --name for URLs).
|
|
151
|
+
// Implicit placement: LLM decides per-file.
|
|
152
|
+
const explicitPlacement = typeof opts.prefix === "string";
|
|
153
|
+
const urlPrefix = opts.prefix ?? "/";
|
|
154
|
+
|
|
119
155
|
for (const path of paths) {
|
|
120
156
|
if (isUrl(path)) {
|
|
121
|
-
const contextPath =
|
|
122
|
-
opts.name || urlToContextPath(path, opts.prefix);
|
|
157
|
+
const contextPath = opts.name || urlToContextPath(path, urlPrefix);
|
|
123
158
|
urlsToAdd.push({ url: path, contextPath });
|
|
124
159
|
} else {
|
|
125
160
|
const resolvedPath = resolve(path);
|
|
@@ -137,13 +172,17 @@ export function registerContextCommand(program: Command) {
|
|
|
137
172
|
const relativePath = filePath.slice(resolvedPath.length);
|
|
138
173
|
filesToAdd.push({
|
|
139
174
|
filePath,
|
|
140
|
-
contextPath:
|
|
175
|
+
contextPath: explicitPlacement
|
|
176
|
+
? join(opts.prefix, relativePath)
|
|
177
|
+
: null,
|
|
141
178
|
});
|
|
142
179
|
}
|
|
143
180
|
} else {
|
|
144
181
|
filesToAdd.push({
|
|
145
182
|
filePath: resolvedPath,
|
|
146
|
-
contextPath:
|
|
183
|
+
contextPath: explicitPlacement
|
|
184
|
+
? join(opts.prefix, basename(resolvedPath))
|
|
185
|
+
: null,
|
|
147
186
|
});
|
|
148
187
|
}
|
|
149
188
|
}
|
|
@@ -154,11 +193,78 @@ export function registerContextCommand(program: Command) {
|
|
|
154
193
|
text: `Found ${totalCount} item(s) to add (${filesToAdd.length} file(s), ${urlsToAdd.length} URL(s)).`,
|
|
155
194
|
});
|
|
156
195
|
|
|
157
|
-
// Phase
|
|
196
|
+
// Phase 1.5: LLM placement for files without an explicit path
|
|
158
197
|
const config = await loadConfig(dir);
|
|
159
198
|
const CONCURRENCY = 10;
|
|
199
|
+
const needsPlacement = filesToAdd.filter((f) => f.contextPath === null);
|
|
200
|
+
// description cache keyed by filePath — populated when LLM placement runs,
|
|
201
|
+
// reused in addFile to avoid a second describe call.
|
|
202
|
+
const descriptionCache = new Map<string, string>();
|
|
203
|
+
|
|
204
|
+
if (needsPlacement.length > 0) {
|
|
205
|
+
if (!config.anthropic_api_key) {
|
|
206
|
+
logger.error(
|
|
207
|
+
"No anthropic_api_key configured — cannot auto-place files. Pass --prefix to specify a folder.",
|
|
208
|
+
);
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const existingTree = await renderExistingTree(conn);
|
|
213
|
+
const placeSpinner = createSpinner(
|
|
214
|
+
`Choosing paths for 0/${needsPlacement.length} file(s)...`,
|
|
215
|
+
).start();
|
|
216
|
+
let placed = 0;
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < needsPlacement.length; i += CONCURRENCY) {
|
|
219
|
+
const batch = needsPlacement.slice(i, i + CONCURRENCY);
|
|
220
|
+
await Promise.all(
|
|
221
|
+
batch.map(async (entry) => {
|
|
222
|
+
const suggestion = await suggestPathForFile(
|
|
223
|
+
entry.filePath,
|
|
224
|
+
config,
|
|
225
|
+
existingTree,
|
|
226
|
+
);
|
|
227
|
+
entry.contextPath =
|
|
228
|
+
suggestion?.suggested_path ?? `/${basename(entry.filePath)}`;
|
|
229
|
+
if (suggestion?.description) {
|
|
230
|
+
descriptionCache.set(entry.filePath, suggestion.description);
|
|
231
|
+
}
|
|
232
|
+
placed++;
|
|
233
|
+
placeSpinner.update({
|
|
234
|
+
text: `Choosing paths for ${placed}/${needsPlacement.length} file(s)...`,
|
|
235
|
+
});
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
placeSpinner.success({
|
|
240
|
+
text: `Chose paths for ${placed} file(s).`,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Confirm in TTY unless --auto-place
|
|
244
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
245
|
+
if (isTTY && !opts.autoPlace) {
|
|
246
|
+
console.log("");
|
|
247
|
+
console.log(ansis.bold("Suggested paths:"));
|
|
248
|
+
for (const entry of needsPlacement) {
|
|
249
|
+
console.log(
|
|
250
|
+
` ${ansis.dim(entry.filePath)} → ${ansis.cyan(entry.contextPath ?? "")}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const accepted = await confirmYesNo("Accept these paths? (Y/n): ");
|
|
254
|
+
if (!accepted) {
|
|
255
|
+
logger.warn(
|
|
256
|
+
"Aborted. Re-run with --prefix to place files manually, or --auto-place to skip this prompt.",
|
|
257
|
+
);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Phase 2: Upsert DB records (batched, parallel LLM descriptions)
|
|
160
264
|
let addCompleted = 0;
|
|
161
265
|
const itemIds: { id: string; contextPath: string }[] = [];
|
|
266
|
+
const conflicts: { contextPath: string; existingId: string }[] = [];
|
|
267
|
+
const skipped: string[] = [];
|
|
162
268
|
|
|
163
269
|
// Process local files (with spinner — these are quick, no chatty logs)
|
|
164
270
|
if (filesToAdd.length > 0) {
|
|
@@ -170,21 +276,34 @@ export function registerContextCommand(program: Command) {
|
|
|
170
276
|
const batch = filesToAdd.slice(i, i + CONCURRENCY);
|
|
171
277
|
const results = await Promise.all(
|
|
172
278
|
batch.map(async ({ filePath, contextPath }) => {
|
|
279
|
+
if (contextPath === null) return null; // unreachable — placement filled it
|
|
173
280
|
const result = await addFile(
|
|
174
281
|
conn,
|
|
175
282
|
filePath,
|
|
176
283
|
contextPath,
|
|
177
284
|
config,
|
|
285
|
+
policy,
|
|
286
|
+
descriptionCache.get(filePath),
|
|
178
287
|
);
|
|
179
288
|
addCompleted++;
|
|
180
289
|
fileSpinner.update({
|
|
181
290
|
text: `Adding and describing ${addCompleted}/${filesToAdd.length} file(s)...`,
|
|
182
291
|
});
|
|
183
|
-
return result
|
|
292
|
+
return result;
|
|
184
293
|
}),
|
|
185
294
|
);
|
|
186
295
|
for (const r of results) {
|
|
187
|
-
if (r)
|
|
296
|
+
if (!r) continue;
|
|
297
|
+
if (r.kind === "added") {
|
|
298
|
+
itemIds.push({ id: r.id, contextPath: r.contextPath });
|
|
299
|
+
} else if (r.kind === "conflict") {
|
|
300
|
+
conflicts.push({
|
|
301
|
+
contextPath: r.contextPath,
|
|
302
|
+
existingId: r.existingId,
|
|
303
|
+
});
|
|
304
|
+
} else if (r.kind === "skipped") {
|
|
305
|
+
skipped.push(r.contextPath);
|
|
306
|
+
}
|
|
188
307
|
}
|
|
189
308
|
}
|
|
190
309
|
|
|
@@ -216,11 +335,25 @@ export function registerContextCommand(program: Command) {
|
|
|
216
335
|
contextPath,
|
|
217
336
|
mcpxClient,
|
|
218
337
|
opts.promptAddition,
|
|
338
|
+
policy,
|
|
219
339
|
);
|
|
220
340
|
if (result.ok) {
|
|
221
341
|
urlAdded++;
|
|
222
342
|
itemIds.push({ id: result.id, contextPath });
|
|
223
343
|
console.log(` ${ansis.green("✔")} stored at ${contextPath}`);
|
|
344
|
+
} else if (result.kind === "conflict") {
|
|
345
|
+
conflicts.push({
|
|
346
|
+
contextPath,
|
|
347
|
+
existingId: result.existingId,
|
|
348
|
+
});
|
|
349
|
+
console.log(
|
|
350
|
+
` ${ansis.red("✗")} path already exists: ${contextPath}`,
|
|
351
|
+
);
|
|
352
|
+
} else if (result.kind === "skipped") {
|
|
353
|
+
skipped.push(contextPath);
|
|
354
|
+
console.log(
|
|
355
|
+
` ${ansis.yellow("⊘")} skipped (path exists): ${contextPath}`,
|
|
356
|
+
);
|
|
224
357
|
} else if (result.actionable) {
|
|
225
358
|
console.log(
|
|
226
359
|
` ${ansis.red("✗")} ${ansis.bold("action required:")}`,
|
|
@@ -245,13 +378,32 @@ export function registerContextCommand(program: Command) {
|
|
|
245
378
|
}
|
|
246
379
|
}
|
|
247
380
|
|
|
381
|
+
// Report conflicts before embeddings so the user sees them prominently
|
|
382
|
+
if (conflicts.length > 0) {
|
|
383
|
+
logger.error(
|
|
384
|
+
`${conflicts.length} path collision(s) — nothing written for these items:`,
|
|
385
|
+
);
|
|
386
|
+
for (const c of conflicts) {
|
|
387
|
+
console.log(
|
|
388
|
+
` ${ansis.red("✗")} ${c.contextPath} (existing id: ${c.existingId})`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
logger.dim(
|
|
392
|
+
"Re-run with --on-conflict=overwrite to replace, --on-conflict=skip to ignore, or --name / --prefix to place elsewhere.",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
248
396
|
// Phase 3: Chunk + embed in parallel (network I/O)
|
|
249
397
|
if (itemIds.length === 0 || !config.openai_api_key) {
|
|
250
398
|
if (!config.openai_api_key) {
|
|
251
399
|
logger.dim("Skipping embeddings (no OpenAI API key configured).");
|
|
252
400
|
}
|
|
253
401
|
const msg = `Added ${itemIds.length}/${totalCount} item(s), 0 chunks indexed.`;
|
|
254
|
-
if (
|
|
402
|
+
if (conflicts.length > 0) {
|
|
403
|
+
logger.error(msg);
|
|
404
|
+
process.exit(1);
|
|
405
|
+
}
|
|
406
|
+
if (itemIds.length === totalCount - skipped.length) {
|
|
255
407
|
logger.success(msg);
|
|
256
408
|
process.exit(0);
|
|
257
409
|
} else if (itemIds.length === 0) {
|
|
@@ -304,7 +456,11 @@ export function registerContextCommand(program: Command) {
|
|
|
304
456
|
if (filesAdded > 0) parts.push(`${filesAdded} added`);
|
|
305
457
|
if (filesUpdated > 0) parts.push(`${filesUpdated} updated`);
|
|
306
458
|
const summary = `${parts.join(", ")} — ${chunks} chunk(s) indexed (${itemIds.length}/${totalCount} item(s)).`;
|
|
307
|
-
if (
|
|
459
|
+
if (conflicts.length > 0) {
|
|
460
|
+
logger.error(summary);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
if (itemIds.length === totalCount - skipped.length) {
|
|
308
464
|
logger.success(summary);
|
|
309
465
|
process.exit(0);
|
|
310
466
|
} else {
|
|
@@ -517,28 +673,56 @@ async function resolveItems(
|
|
|
517
673
|
return listContextItemsByPrefix(conn, p, { recursive: true });
|
|
518
674
|
}
|
|
519
675
|
|
|
520
|
-
|
|
676
|
+
type ConflictPolicy = "error" | "overwrite" | "skip";
|
|
677
|
+
|
|
678
|
+
type AddFileResult =
|
|
679
|
+
| { kind: "added"; id: string; contextPath: string }
|
|
680
|
+
| { kind: "skipped"; contextPath: string }
|
|
681
|
+
| { kind: "conflict"; contextPath: string; existingId: string }
|
|
682
|
+
| { kind: "failed"; contextPath: string; error: string };
|
|
683
|
+
|
|
684
|
+
/** Upsert a file into context honoring the collision policy. */
|
|
521
685
|
async function addFile(
|
|
522
686
|
conn: DbConnection,
|
|
523
687
|
filePath: string,
|
|
524
688
|
contextPath: string,
|
|
525
689
|
config: Required<BotholomewConfig>,
|
|
526
|
-
|
|
690
|
+
policy: ConflictPolicy,
|
|
691
|
+
cachedDescription?: string,
|
|
692
|
+
): Promise<AddFileResult | null> {
|
|
527
693
|
try {
|
|
694
|
+
// Pre-flight conflict check so we don't waste a describe call.
|
|
695
|
+
if (policy !== "overwrite") {
|
|
696
|
+
const existing = await getContextItemByPath(conn, contextPath);
|
|
697
|
+
if (existing) {
|
|
698
|
+
if (policy === "skip") {
|
|
699
|
+
logger.dim(` ⊘ skipped (path exists): ${contextPath}`);
|
|
700
|
+
return { kind: "skipped", contextPath };
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
kind: "conflict",
|
|
704
|
+
contextPath,
|
|
705
|
+
existingId: existing.id,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
528
710
|
const bunFile = Bun.file(filePath);
|
|
529
711
|
const mimeType = bunFile.type.split(";")[0] || "application/octet-stream";
|
|
530
712
|
const filename = basename(filePath);
|
|
531
713
|
const textual = isText(filename) !== false;
|
|
532
714
|
const content = textual ? await bunFile.text() : null;
|
|
533
715
|
|
|
534
|
-
const description =
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
716
|
+
const description =
|
|
717
|
+
cachedDescription ??
|
|
718
|
+
(await generateDescription(config, {
|
|
719
|
+
filename,
|
|
720
|
+
mimeType,
|
|
721
|
+
content,
|
|
722
|
+
filePath,
|
|
723
|
+
}));
|
|
540
724
|
|
|
541
|
-
const
|
|
725
|
+
const itemParams = {
|
|
542
726
|
title: filename,
|
|
543
727
|
description,
|
|
544
728
|
content: content ?? undefined,
|
|
@@ -546,19 +730,36 @@ async function addFile(
|
|
|
546
730
|
sourcePath: filePath,
|
|
547
731
|
contextPath,
|
|
548
732
|
isTextual: textual,
|
|
549
|
-
}
|
|
733
|
+
} as const;
|
|
734
|
+
|
|
735
|
+
const item =
|
|
736
|
+
policy === "overwrite"
|
|
737
|
+
? await upsertContextItem(conn, itemParams)
|
|
738
|
+
: await createContextItemStrict(conn, itemParams);
|
|
550
739
|
|
|
551
|
-
return textual && content
|
|
740
|
+
return textual && content
|
|
741
|
+
? { kind: "added", id: item.id, contextPath: item.context_path }
|
|
742
|
+
: null;
|
|
552
743
|
} catch (err) {
|
|
744
|
+
if (err instanceof PathConflictError) {
|
|
745
|
+
// Race between pre-flight check and insert — still a conflict.
|
|
746
|
+
return {
|
|
747
|
+
kind: "conflict",
|
|
748
|
+
contextPath,
|
|
749
|
+
existingId: err.existingId,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
553
752
|
logger.warn(` ! ${contextPath}: ${err}`);
|
|
554
|
-
return
|
|
753
|
+
return { kind: "failed", contextPath, error: String(err) };
|
|
555
754
|
}
|
|
556
755
|
}
|
|
557
756
|
|
|
558
|
-
/** Fetch a URL and upsert into context.
|
|
757
|
+
/** Fetch a URL and upsert into context. */
|
|
559
758
|
type AddUrlResult =
|
|
560
759
|
| { ok: true; id: string }
|
|
561
|
-
| { ok: false;
|
|
760
|
+
| { ok: false; kind: "conflict"; existingId: string }
|
|
761
|
+
| { ok: false; kind: "skipped" }
|
|
762
|
+
| { ok: false; kind: "fetch-failed"; error: string; actionable: boolean };
|
|
562
763
|
|
|
563
764
|
async function addUrl(
|
|
564
765
|
conn: DbConnection,
|
|
@@ -566,8 +767,18 @@ async function addUrl(
|
|
|
566
767
|
url: string,
|
|
567
768
|
contextPath: string,
|
|
568
769
|
mcpxClient: Awaited<ReturnType<typeof createMcpxClient>>,
|
|
569
|
-
promptAddition
|
|
770
|
+
promptAddition: string | undefined,
|
|
771
|
+
policy: ConflictPolicy,
|
|
570
772
|
): Promise<AddUrlResult> {
|
|
773
|
+
// Pre-flight conflict check — skip the expensive fetch if we'd collide.
|
|
774
|
+
if (policy !== "overwrite") {
|
|
775
|
+
const existing = await getContextItemByPath(conn, contextPath);
|
|
776
|
+
if (existing) {
|
|
777
|
+
if (policy === "skip") return { ok: false, kind: "skipped" };
|
|
778
|
+
return { ok: false, kind: "conflict", existingId: existing.id };
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
571
782
|
try {
|
|
572
783
|
const fetched = await fetchUrl(url, config, mcpxClient, promptAddition);
|
|
573
784
|
|
|
@@ -577,24 +788,115 @@ async function addUrl(
|
|
|
577
788
|
content: fetched.content,
|
|
578
789
|
});
|
|
579
790
|
|
|
580
|
-
const
|
|
791
|
+
const itemParams = {
|
|
581
792
|
title: fetched.title,
|
|
582
793
|
description,
|
|
583
794
|
content: fetched.content,
|
|
584
795
|
mimeType: fetched.mimeType,
|
|
585
|
-
sourceType: "url",
|
|
796
|
+
sourceType: "url" as const,
|
|
586
797
|
sourcePath: url,
|
|
587
798
|
contextPath,
|
|
588
799
|
isTextual: true,
|
|
589
|
-
}
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const item =
|
|
803
|
+
policy === "overwrite"
|
|
804
|
+
? await upsertContextItem(conn, itemParams)
|
|
805
|
+
: await createContextItemStrict(conn, itemParams);
|
|
590
806
|
|
|
591
807
|
return { ok: true, id: item.id };
|
|
592
808
|
} catch (err) {
|
|
809
|
+
if (err instanceof PathConflictError) {
|
|
810
|
+
return { ok: false, kind: "conflict", existingId: err.existingId };
|
|
811
|
+
}
|
|
593
812
|
if (err instanceof FetchFailureError) {
|
|
594
|
-
return {
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
kind: "fetch-failed",
|
|
816
|
+
error: err.userMessage,
|
|
817
|
+
actionable: true,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
ok: false,
|
|
822
|
+
kind: "fetch-failed",
|
|
823
|
+
error: String(err),
|
|
824
|
+
actionable: false,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Build a listing of every existing path (folders + files) to feed the LLM
|
|
831
|
+
* placer. Seeing actual files in each folder helps the LLM place new content
|
|
832
|
+
* alongside similar documents instead of inventing parallel folder names.
|
|
833
|
+
*/
|
|
834
|
+
async function renderExistingTree(conn: DbConnection): Promise<string> {
|
|
835
|
+
const items = await listContextItems(conn);
|
|
836
|
+
if (items.length === 0) return "";
|
|
837
|
+
|
|
838
|
+
// Every implicit ancestor folder of every item.
|
|
839
|
+
const folders = new Set<string>();
|
|
840
|
+
for (const item of items) {
|
|
841
|
+
const parts = item.context_path.split("/").filter(Boolean);
|
|
842
|
+
const isExplicitDir = item.mime_type === "inode/directory";
|
|
843
|
+
const folderDepth = isExplicitDir ? parts.length : parts.length - 1;
|
|
844
|
+
for (let i = 1; i <= folderDepth; i++) {
|
|
845
|
+
folders.add(`/${parts.slice(0, i).join("/")}/`);
|
|
595
846
|
}
|
|
596
|
-
return { ok: false, error: String(err), actionable: false };
|
|
597
847
|
}
|
|
848
|
+
|
|
849
|
+
const files = items
|
|
850
|
+
.filter((i) => i.mime_type !== "inode/directory")
|
|
851
|
+
.map((i) => i.context_path);
|
|
852
|
+
|
|
853
|
+
const all = [...folders, ...files].sort();
|
|
854
|
+
const cap = 500;
|
|
855
|
+
const truncated = all.slice(0, cap);
|
|
856
|
+
const suffix =
|
|
857
|
+
all.length > cap ? `\n (+${all.length - cap} more entries)` : "";
|
|
858
|
+
return truncated.map((p) => ` ${p}`).join("\n") + suffix;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Call the describer LLM to suggest a path + description for a file. */
|
|
862
|
+
async function suggestPathForFile(
|
|
863
|
+
filePath: string,
|
|
864
|
+
config: Required<BotholomewConfig>,
|
|
865
|
+
existingTree: string,
|
|
866
|
+
): Promise<{ description: string; suggested_path: string } | null> {
|
|
867
|
+
try {
|
|
868
|
+
const bunFile = Bun.file(filePath);
|
|
869
|
+
const mimeType = bunFile.type.split(";")[0] || "application/octet-stream";
|
|
870
|
+
const filename = basename(filePath);
|
|
871
|
+
const textual = isText(filename) !== false;
|
|
872
|
+
const content = textual ? await bunFile.text() : null;
|
|
873
|
+
return await generateDescriptionAndPath(config, {
|
|
874
|
+
filename,
|
|
875
|
+
mimeType,
|
|
876
|
+
content,
|
|
877
|
+
filePath,
|
|
878
|
+
sourcePath: filePath,
|
|
879
|
+
existingTree,
|
|
880
|
+
});
|
|
881
|
+
} catch {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Minimal stdin-based yes/no prompt, defaults to yes (empty input accepts). */
|
|
887
|
+
async function confirmYesNo(prompt: string): Promise<boolean> {
|
|
888
|
+
process.stdout.write(prompt);
|
|
889
|
+
return new Promise((resolvePromise) => {
|
|
890
|
+
const onData = (chunk: Buffer) => {
|
|
891
|
+
const line = chunk.toString().trim().toLowerCase();
|
|
892
|
+
process.stdin.off("data", onData);
|
|
893
|
+
process.stdin.pause();
|
|
894
|
+
// Empty input (just Enter) or y/yes → accept; only n/no rejects.
|
|
895
|
+
resolvePromise(line !== "n" && line !== "no");
|
|
896
|
+
};
|
|
897
|
+
process.stdin.resume();
|
|
898
|
+
process.stdin.once("data", onData);
|
|
899
|
+
});
|
|
598
900
|
}
|
|
599
901
|
|
|
600
902
|
async function walkDirectory(dirPath: string): Promise<string[]> {
|
package/src/commands/daemon.ts
CHANGED
|
@@ -109,7 +109,9 @@ export function registerDaemonCommand(program: Command) {
|
|
|
109
109
|
daemon
|
|
110
110
|
.command("list")
|
|
111
111
|
.description("List all registered Botholomew projects on this machine")
|
|
112
|
-
.
|
|
112
|
+
.option("-l, --limit <n>", "max number of projects", Number.parseInt)
|
|
113
|
+
.option("-o, --offset <n>", "skip first N projects", Number.parseInt)
|
|
114
|
+
.action(async (opts: { limit?: number; offset?: number }) => {
|
|
113
115
|
const { listAllWatchdogProjects } = await import("../daemon/watchdog.ts");
|
|
114
116
|
try {
|
|
115
117
|
const projects = await listAllWatchdogProjects();
|
|
@@ -117,10 +119,21 @@ export function registerDaemonCommand(program: Command) {
|
|
|
117
119
|
logger.dim("No registered projects found.");
|
|
118
120
|
return;
|
|
119
121
|
}
|
|
120
|
-
|
|
122
|
+
const total = projects.length;
|
|
123
|
+
const start = opts.offset ?? 0;
|
|
124
|
+
const end = opts.limit ? start + opts.limit : undefined;
|
|
125
|
+
const page = projects.slice(start, end);
|
|
126
|
+
if (page.length === 0) {
|
|
127
|
+
logger.dim(`No projects on this page (total: ${total}).`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
for (const p of page) {
|
|
121
131
|
logger.info(p.projectDir);
|
|
122
132
|
logger.dim(` Config: ${p.configPath}`);
|
|
123
133
|
}
|
|
134
|
+
if (page.length !== total) {
|
|
135
|
+
logger.dim(`\nshowing ${page.length} of ${total} project(s)`);
|
|
136
|
+
}
|
|
124
137
|
} catch (err) {
|
|
125
138
|
logger.error(
|
|
126
139
|
`Failed to list projects: ${err instanceof Error ? err.message : err}`,
|
package/src/commands/schedule.ts
CHANGED
|
@@ -19,9 +19,18 @@ export function registerScheduleCommand(program: Command) {
|
|
|
19
19
|
.description("List all schedules")
|
|
20
20
|
.option("--enabled", "show only enabled schedules")
|
|
21
21
|
.option("--disabled", "show only disabled schedules")
|
|
22
|
+
.option("-l, --limit <n>", "max number of schedules", Number.parseInt)
|
|
23
|
+
.option("-o, --offset <n>", "skip first N schedules", Number.parseInt)
|
|
22
24
|
.action((opts) =>
|
|
23
25
|
withDb(program, async (conn) => {
|
|
24
|
-
const filters: {
|
|
26
|
+
const filters: {
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
limit?: number;
|
|
29
|
+
offset?: number;
|
|
30
|
+
} = {
|
|
31
|
+
limit: opts.limit,
|
|
32
|
+
offset: opts.offset,
|
|
33
|
+
};
|
|
25
34
|
if (opts.enabled) filters.enabled = true;
|
|
26
35
|
if (opts.disabled) filters.enabled = false;
|
|
27
36
|
|
package/src/commands/skill.ts
CHANGED
|
@@ -27,7 +27,9 @@ export function registerSkillCommand(program: Command) {
|
|
|
27
27
|
skill
|
|
28
28
|
.command("list")
|
|
29
29
|
.description("List all skills loaded from .botholomew/skills/")
|
|
30
|
-
.
|
|
30
|
+
.option("-l, --limit <n>", "max number of skills", Number.parseInt)
|
|
31
|
+
.option("-o, --offset <n>", "skip first N skills", Number.parseInt)
|
|
32
|
+
.action(async (opts: { limit?: number; offset?: number }) => {
|
|
31
33
|
const dir = program.opts().dir;
|
|
32
34
|
const skills = await loadSkills(dir);
|
|
33
35
|
|
|
@@ -39,12 +41,21 @@ export function registerSkillCommand(program: Command) {
|
|
|
39
41
|
const sorted = [...skills.values()].sort((a, b) =>
|
|
40
42
|
a.name.localeCompare(b.name),
|
|
41
43
|
);
|
|
44
|
+
const total = sorted.length;
|
|
45
|
+
const start = opts.offset ?? 0;
|
|
46
|
+
const end = opts.limit ? start + opts.limit : undefined;
|
|
47
|
+
const page = sorted.slice(start, end);
|
|
48
|
+
|
|
49
|
+
if (page.length === 0) {
|
|
50
|
+
logger.dim(`No skills on this page (total: ${total}).`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
42
53
|
|
|
43
54
|
const header = `${ansis.bold("Name".padEnd(20))} ${ansis.bold("Description".padEnd(40))} ${ansis.bold("Args".padEnd(20))} ${ansis.bold("Path")}`;
|
|
44
55
|
console.log(header);
|
|
45
56
|
console.log("-".repeat(header.length));
|
|
46
57
|
|
|
47
|
-
for (const s of
|
|
58
|
+
for (const s of page) {
|
|
48
59
|
const name = s.name.padEnd(20);
|
|
49
60
|
const desc = s.description
|
|
50
61
|
? s.description.slice(0, 39).padEnd(40)
|
|
@@ -61,7 +72,11 @@ export function registerSkillCommand(program: Command) {
|
|
|
61
72
|
console.log(`${name} ${desc} ${args} ${path}`);
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
const footer =
|
|
76
|
+
page.length === total
|
|
77
|
+
? `${total} skill(s)`
|
|
78
|
+
: `showing ${page.length} of ${total} skill(s)`;
|
|
79
|
+
console.log(`\n${ansis.dim(footer)}`);
|
|
65
80
|
});
|
|
66
81
|
|
|
67
82
|
skill
|