botholomew 0.7.8 → 0.7.10
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 +168 -12
- 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/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 +54 -38
- package/src/db/connection.ts +143 -14
- package/src/db/context.ts +13 -0
- package/src/db/schedules.ts +7 -3
- package/src/db/tasks.ts +4 -4
- package/src/db/threads.ts +6 -4
- 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/README.md
CHANGED
|
@@ -128,6 +128,8 @@ Everything the agent can touch is here. No surprises.
|
|
|
128
128
|
| `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
|
|
129
129
|
| `botholomew upgrade` | Self-update |
|
|
130
130
|
|
|
131
|
+
All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
|
|
132
|
+
|
|
131
133
|
---
|
|
132
134
|
|
|
133
135
|
## How it works
|
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
ToolResultBlockParam,
|
|
5
5
|
ToolUseBlock,
|
|
6
6
|
} from "@anthropic-ai/sdk/resources/messages";
|
|
7
|
+
import type { McpxClient } from "@evantahler/mcpx";
|
|
7
8
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
8
9
|
import { embedSingle } from "../context/embedder.ts";
|
|
9
10
|
import { fitToContextWindow, getMaxInputTokens } from "../daemon/context.ts";
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
extractKeywords,
|
|
14
15
|
loadPersistentContext,
|
|
15
16
|
} from "../daemon/prompt.ts";
|
|
16
|
-
import
|
|
17
|
+
import { withDb } from "../db/connection.ts";
|
|
17
18
|
import { hybridSearch } from "../db/embeddings.ts";
|
|
18
19
|
import { logInteraction } from "../db/threads.ts";
|
|
19
20
|
import { registerAllTools } from "../tools/registry.ts";
|
|
@@ -60,7 +61,7 @@ export async function buildChatSystemPrompt(
|
|
|
60
61
|
projectDir: string,
|
|
61
62
|
options?: {
|
|
62
63
|
keywordSource?: string;
|
|
63
|
-
|
|
64
|
+
dbPath?: string;
|
|
64
65
|
config?: Required<BotholomewConfig>;
|
|
65
66
|
},
|
|
66
67
|
): Promise<string> {
|
|
@@ -74,12 +75,14 @@ export async function buildChatSystemPrompt(
|
|
|
74
75
|
parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
|
|
75
76
|
|
|
76
77
|
// Relevant context from embeddings search
|
|
77
|
-
const
|
|
78
|
+
const dbPath = options?.dbPath;
|
|
78
79
|
const config = options?.config;
|
|
79
|
-
if (
|
|
80
|
+
if (dbPath && config?.openai_api_key && keywordSource) {
|
|
80
81
|
try {
|
|
81
82
|
const queryVec = await embedSingle(keywordSource, config);
|
|
82
|
-
const results = await
|
|
83
|
+
const results = await withDb(dbPath, (conn) =>
|
|
84
|
+
hybridSearch(conn, keywordSource, queryVec, 5),
|
|
85
|
+
);
|
|
83
86
|
|
|
84
87
|
if (results.length > 0) {
|
|
85
88
|
parts.push("## Relevant Context");
|
|
@@ -160,13 +163,20 @@ export async function runChatTurn(input: {
|
|
|
160
163
|
messages: MessageParam[];
|
|
161
164
|
projectDir: string;
|
|
162
165
|
config: Required<BotholomewConfig>;
|
|
163
|
-
|
|
166
|
+
dbPath: string;
|
|
164
167
|
threadId: string;
|
|
165
|
-
|
|
168
|
+
mcpxClient: McpxClient | null;
|
|
166
169
|
callbacks: ChatTurnCallbacks;
|
|
167
170
|
}): Promise<void> {
|
|
168
|
-
const {
|
|
169
|
-
|
|
171
|
+
const {
|
|
172
|
+
messages,
|
|
173
|
+
projectDir,
|
|
174
|
+
config,
|
|
175
|
+
dbPath,
|
|
176
|
+
threadId,
|
|
177
|
+
mcpxClient,
|
|
178
|
+
callbacks,
|
|
179
|
+
} = input;
|
|
170
180
|
|
|
171
181
|
const client = new Anthropic({
|
|
172
182
|
apiKey: config.anthropic_api_key || undefined,
|
|
@@ -190,7 +200,7 @@ export async function runChatTurn(input: {
|
|
|
190
200
|
const keywordSource = findLastUserText(messages);
|
|
191
201
|
const systemPrompt = await buildChatSystemPrompt(projectDir, {
|
|
192
202
|
keywordSource,
|
|
193
|
-
|
|
203
|
+
dbPath,
|
|
194
204
|
config,
|
|
195
205
|
});
|
|
196
206
|
|
|
@@ -230,13 +240,15 @@ export async function runChatTurn(input: {
|
|
|
230
240
|
|
|
231
241
|
// Log assistant text
|
|
232
242
|
if (assistantText) {
|
|
233
|
-
await
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
await withDb(dbPath, (conn) =>
|
|
244
|
+
logInteraction(conn, threadId, {
|
|
245
|
+
role: "assistant",
|
|
246
|
+
kind: "message",
|
|
247
|
+
content: assistantText,
|
|
248
|
+
durationMs,
|
|
249
|
+
tokenCount,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
// Check for tool calls
|
|
@@ -260,20 +272,29 @@ export async function runChatTurn(input: {
|
|
|
260
272
|
callbacks.onToolStart(toolUse.id, toolUse.name, toolInput);
|
|
261
273
|
}
|
|
262
274
|
|
|
263
|
-
await
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
275
|
+
await withDb(dbPath, (conn) =>
|
|
276
|
+
logInteraction(conn, threadId, {
|
|
277
|
+
role: "assistant",
|
|
278
|
+
kind: "tool_use",
|
|
279
|
+
content: `Calling ${toolUse.name}`,
|
|
280
|
+
toolName: toolUse.name,
|
|
281
|
+
toolInput,
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
270
284
|
}
|
|
271
285
|
|
|
272
|
-
// Execute all tools in parallel
|
|
286
|
+
// Execute all tools in parallel. Each tool call opens its own short-lived
|
|
287
|
+
// connection; parallel calls share the process-local DuckDB instance and
|
|
288
|
+
// release the file lock as soon as the last one finishes.
|
|
273
289
|
const execResults = await Promise.all(
|
|
274
290
|
toolUseBlocks.map(async (toolUse) => {
|
|
275
291
|
const start = Date.now();
|
|
276
|
-
const result = await executeChatToolCall(toolUse,
|
|
292
|
+
const result = await executeChatToolCall(toolUse, {
|
|
293
|
+
dbPath,
|
|
294
|
+
projectDir,
|
|
295
|
+
config,
|
|
296
|
+
mcpxClient,
|
|
297
|
+
});
|
|
277
298
|
const durationMs = Date.now() - start;
|
|
278
299
|
const stored = maybeStoreResult(toolUse.name, result.output);
|
|
279
300
|
const meta: ToolEndMeta | undefined = stored.stored
|
|
@@ -293,13 +314,15 @@ export async function runChatTurn(input: {
|
|
|
293
314
|
// Log results and collect tool_result messages
|
|
294
315
|
const toolResults: ToolResultBlockParam[] = [];
|
|
295
316
|
for (const { toolUse, result, durationMs, stored } of execResults) {
|
|
296
|
-
await
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
317
|
+
await withDb(dbPath, (conn) =>
|
|
318
|
+
logInteraction(conn, threadId, {
|
|
319
|
+
role: "tool",
|
|
320
|
+
kind: "tool_result",
|
|
321
|
+
content: result.output,
|
|
322
|
+
toolName: toolUse.name,
|
|
323
|
+
durationMs,
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
303
326
|
|
|
304
327
|
toolResults.push({
|
|
305
328
|
type: "tool_result",
|
|
@@ -314,9 +337,16 @@ export async function runChatTurn(input: {
|
|
|
314
337
|
}
|
|
315
338
|
}
|
|
316
339
|
|
|
340
|
+
interface ChatToolCallCtx {
|
|
341
|
+
dbPath: string;
|
|
342
|
+
projectDir: string;
|
|
343
|
+
config: Required<BotholomewConfig>;
|
|
344
|
+
mcpxClient: McpxClient | null;
|
|
345
|
+
}
|
|
346
|
+
|
|
317
347
|
async function executeChatToolCall(
|
|
318
348
|
toolUse: ToolUseBlock,
|
|
319
|
-
|
|
349
|
+
baseCtx: ChatToolCallCtx,
|
|
320
350
|
): Promise<{ output: string; isError: boolean }> {
|
|
321
351
|
const tool = getTool(toolUse.name);
|
|
322
352
|
if (!tool) return { output: `Unknown tool: ${toolUse.name}`, isError: true };
|
|
@@ -335,7 +365,10 @@ async function executeChatToolCall(
|
|
|
335
365
|
}
|
|
336
366
|
|
|
337
367
|
try {
|
|
338
|
-
const result = await
|
|
368
|
+
const result = await withDb(baseCtx.dbPath, (conn) => {
|
|
369
|
+
const ctx: ToolContext = { ...baseCtx, conn };
|
|
370
|
+
return tool.execute(parsed.data, ctx);
|
|
371
|
+
});
|
|
339
372
|
const isError =
|
|
340
373
|
typeof result === "object" && result !== null && "is_error" in result
|
|
341
374
|
? (result as { is_error: boolean }).is_error
|
package/src/chat/session.ts
CHANGED
|
@@ -2,8 +2,7 @@ import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
|
|
|
2
2
|
import { loadConfig } from "../config/loader.ts";
|
|
3
3
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
4
4
|
import { getDbPath } from "../constants.ts";
|
|
5
|
-
import
|
|
6
|
-
import { getConnection } from "../db/connection.ts";
|
|
5
|
+
import { withDb } from "../db/connection.ts";
|
|
7
6
|
import { migrate } from "../db/schema.ts";
|
|
8
7
|
import {
|
|
9
8
|
createThread,
|
|
@@ -15,18 +14,18 @@ import {
|
|
|
15
14
|
import { createMcpxClient } from "../mcpx/client.ts";
|
|
16
15
|
import { loadSkills } from "../skills/loader.ts";
|
|
17
16
|
import type { SkillDefinition } from "../skills/parser.ts";
|
|
18
|
-
import type { ToolContext } from "../tools/tool.ts";
|
|
19
17
|
import { generateThreadTitle } from "../utils/title.ts";
|
|
20
18
|
import { type ChatTurnCallbacks, runChatTurn } from "./agent.ts";
|
|
21
19
|
|
|
22
20
|
export interface ChatSession {
|
|
23
|
-
|
|
21
|
+
dbPath: string;
|
|
24
22
|
threadId: string;
|
|
25
23
|
projectDir: string;
|
|
26
24
|
config: Required<BotholomewConfig>;
|
|
27
25
|
messages: MessageParam[];
|
|
28
|
-
toolCtx: ToolContext;
|
|
29
26
|
skills: Map<string, SkillDefinition>;
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: mcpx client
|
|
28
|
+
mcpxClient: any;
|
|
30
29
|
cleanup: () => Promise<void>;
|
|
31
30
|
}
|
|
32
31
|
|
|
@@ -42,21 +41,22 @@ export async function startChatSession(
|
|
|
42
41
|
);
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
const
|
|
46
|
-
await migrate(conn);
|
|
44
|
+
const dbPath = getDbPath(projectDir);
|
|
45
|
+
await withDb(dbPath, (conn) => migrate(conn));
|
|
47
46
|
|
|
48
47
|
let threadId: string;
|
|
49
48
|
const messages: MessageParam[] = [];
|
|
50
49
|
|
|
51
50
|
if (existingThreadId) {
|
|
52
51
|
// Resume existing thread
|
|
53
|
-
const result = await
|
|
52
|
+
const result = await withDb(dbPath, (conn) =>
|
|
53
|
+
getThread(conn, existingThreadId),
|
|
54
|
+
);
|
|
54
55
|
if (!result) {
|
|
55
|
-
conn.close();
|
|
56
56
|
throw new Error(`Thread not found: ${existingThreadId}`);
|
|
57
57
|
}
|
|
58
58
|
threadId = existingThreadId;
|
|
59
|
-
await reopenThread(conn, threadId);
|
|
59
|
+
await withDb(dbPath, (conn) => reopenThread(conn, threadId));
|
|
60
60
|
|
|
61
61
|
// Rebuild message history from interactions
|
|
62
62
|
let firstUserMessage: string | undefined;
|
|
@@ -72,34 +72,29 @@ export async function startChatSession(
|
|
|
72
72
|
|
|
73
73
|
// Backfill title for threads that still have the default
|
|
74
74
|
if (result.thread.title === "New chat" && firstUserMessage) {
|
|
75
|
-
void generateThreadTitle(config,
|
|
75
|
+
void generateThreadTitle(config, dbPath, threadId, firstUserMessage);
|
|
76
76
|
}
|
|
77
77
|
} else {
|
|
78
|
-
threadId = await
|
|
78
|
+
threadId = await withDb(dbPath, (conn) =>
|
|
79
|
+
createThread(conn, "chat_session", undefined, "New chat"),
|
|
80
|
+
);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
const mcpxClient = await createMcpxClient(projectDir);
|
|
82
84
|
const skills = await loadSkills(projectDir);
|
|
83
85
|
|
|
84
|
-
const toolCtx: ToolContext = {
|
|
85
|
-
conn,
|
|
86
|
-
projectDir,
|
|
87
|
-
config,
|
|
88
|
-
mcpxClient,
|
|
89
|
-
};
|
|
90
|
-
|
|
91
86
|
const cleanup = async () => {
|
|
92
87
|
await mcpxClient?.close();
|
|
93
88
|
};
|
|
94
89
|
|
|
95
90
|
return {
|
|
96
|
-
|
|
91
|
+
dbPath,
|
|
97
92
|
threadId,
|
|
98
93
|
projectDir,
|
|
99
94
|
config,
|
|
100
95
|
messages,
|
|
101
|
-
toolCtx,
|
|
102
96
|
skills,
|
|
97
|
+
mcpxClient,
|
|
103
98
|
cleanup,
|
|
104
99
|
};
|
|
105
100
|
}
|
|
@@ -110,11 +105,13 @@ export async function sendMessage(
|
|
|
110
105
|
callbacks: ChatTurnCallbacks,
|
|
111
106
|
): Promise<void> {
|
|
112
107
|
// Log and append user message
|
|
113
|
-
await
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
await withDb(session.dbPath, (conn) =>
|
|
109
|
+
logInteraction(conn, session.threadId, {
|
|
110
|
+
role: "user",
|
|
111
|
+
kind: "message",
|
|
112
|
+
content: userMessage,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
118
115
|
|
|
119
116
|
session.messages.push({ role: "user", content: userMessage });
|
|
120
117
|
|
|
@@ -122,7 +119,7 @@ export async function sendMessage(
|
|
|
122
119
|
if (session.messages.length === 1) {
|
|
123
120
|
void generateThreadTitle(
|
|
124
121
|
session.config,
|
|
125
|
-
session.
|
|
122
|
+
session.dbPath,
|
|
126
123
|
session.threadId,
|
|
127
124
|
userMessage,
|
|
128
125
|
);
|
|
@@ -132,15 +129,14 @@ export async function sendMessage(
|
|
|
132
129
|
messages: session.messages,
|
|
133
130
|
projectDir: session.projectDir,
|
|
134
131
|
config: session.config,
|
|
135
|
-
|
|
132
|
+
dbPath: session.dbPath,
|
|
136
133
|
threadId: session.threadId,
|
|
137
|
-
|
|
134
|
+
mcpxClient: session.mcpxClient,
|
|
138
135
|
callbacks,
|
|
139
136
|
});
|
|
140
137
|
}
|
|
141
138
|
|
|
142
139
|
export async function endChatSession(session: ChatSession): Promise<void> {
|
|
143
|
-
await
|
|
140
|
+
await withDb(session.dbPath, (conn) => endThread(conn, session.threadId));
|
|
144
141
|
await session.cleanup();
|
|
145
|
-
session.conn.close();
|
|
146
142
|
}
|
package/src/commands/context.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
createContextItemStrict,
|
|
26
26
|
deleteContextItemByPath,
|
|
27
27
|
getContextItemByPath,
|
|
28
|
+
getContextItemBySourcePath,
|
|
28
29
|
listContextItems,
|
|
29
30
|
listContextItemsByPrefix,
|
|
30
31
|
PathConflictError,
|
|
@@ -193,9 +194,126 @@ export function registerContextCommand(program: Command) {
|
|
|
193
194
|
text: `Found ${totalCount} item(s) to add (${filesToAdd.length} file(s), ${urlsToAdd.length} URL(s)).`,
|
|
194
195
|
});
|
|
195
196
|
|
|
196
|
-
// Phase 1.5: LLM placement for files without an explicit path
|
|
197
197
|
const config = await loadConfig(dir);
|
|
198
198
|
const CONCURRENCY = 10;
|
|
199
|
+
|
|
200
|
+
// Phase 0: Source-path dedup — items whose source_path is already in
|
|
201
|
+
// context are routed per --on-conflict before we pay for LLM placement.
|
|
202
|
+
type AlreadyInContext = {
|
|
203
|
+
sourcePath: string;
|
|
204
|
+
sourceType: "file" | "url";
|
|
205
|
+
existing: ContextItem;
|
|
206
|
+
};
|
|
207
|
+
const alreadyInContext: AlreadyInContext[] = [];
|
|
208
|
+
const remainingFiles: FileToAdd[] = [];
|
|
209
|
+
const remainingUrls: { url: string; contextPath: string }[] = [];
|
|
210
|
+
|
|
211
|
+
for (const f of filesToAdd) {
|
|
212
|
+
const existing = await getContextItemBySourcePath(
|
|
213
|
+
conn,
|
|
214
|
+
f.filePath,
|
|
215
|
+
"file",
|
|
216
|
+
);
|
|
217
|
+
if (existing) {
|
|
218
|
+
alreadyInContext.push({
|
|
219
|
+
sourcePath: f.filePath,
|
|
220
|
+
sourceType: "file",
|
|
221
|
+
existing,
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
remainingFiles.push(f);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const u of urlsToAdd) {
|
|
228
|
+
const existing = await getContextItemBySourcePath(conn, u.url, "url");
|
|
229
|
+
if (existing) {
|
|
230
|
+
alreadyInContext.push({
|
|
231
|
+
sourcePath: u.url,
|
|
232
|
+
sourceType: "url",
|
|
233
|
+
existing,
|
|
234
|
+
});
|
|
235
|
+
} else {
|
|
236
|
+
remainingUrls.push(u);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let refreshedCount = 0;
|
|
241
|
+
let refreshedChunks = 0;
|
|
242
|
+
const dedupSkipped: string[] = [];
|
|
243
|
+
|
|
244
|
+
if (alreadyInContext.length > 0) {
|
|
245
|
+
if (policy === "error") {
|
|
246
|
+
logger.error(
|
|
247
|
+
`${alreadyInContext.length} item(s) already in context (matched by source path):`,
|
|
248
|
+
);
|
|
249
|
+
for (const a of alreadyInContext) {
|
|
250
|
+
console.log(
|
|
251
|
+
` ${ansis.red("✗")} ${a.sourcePath} → ${a.existing.context_path} (id: ${a.existing.id})`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
logger.dim(
|
|
255
|
+
"Re-run with --on-conflict=skip to ignore these items or --on-conflict=overwrite to refresh them from disk.",
|
|
256
|
+
);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (policy === "skip") {
|
|
261
|
+
for (const a of alreadyInContext) {
|
|
262
|
+
logger.dim(
|
|
263
|
+
`⊘ already in context: ${a.sourcePath} → ${a.existing.context_path}`,
|
|
264
|
+
);
|
|
265
|
+
dedupSkipped.push(a.existing.context_path);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// overwrite: refresh existing items (diff + selective re-embed),
|
|
269
|
+
// preserving their original context_path.
|
|
270
|
+
const itemsToRefresh = alreadyInContext.map((a) => a.existing);
|
|
271
|
+
const hasUrls = itemsToRefresh.some((i) => i.source_type === "url");
|
|
272
|
+
const mcpxClient = hasUrls ? await createMcpxClient(dir) : null;
|
|
273
|
+
|
|
274
|
+
const refreshSpinner = createSpinner(
|
|
275
|
+
`Refreshing 0/${itemsToRefresh.length} existing item(s)...`,
|
|
276
|
+
).start();
|
|
277
|
+
const refreshResult = await refreshContextItems(
|
|
278
|
+
conn,
|
|
279
|
+
itemsToRefresh,
|
|
280
|
+
config,
|
|
281
|
+
mcpxClient,
|
|
282
|
+
{
|
|
283
|
+
onItemProgress: (done, total) => {
|
|
284
|
+
refreshSpinner.update({
|
|
285
|
+
text: `Refreshing ${done}/${total} existing item(s)...`,
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
refreshSpinner.success({
|
|
291
|
+
text: `Refreshed ${refreshResult.checked} existing item(s): ${refreshResult.updated} updated, ${refreshResult.unchanged} unchanged, ${refreshResult.missing} missing.`,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Count everything we processed OK (updated + unchanged) as
|
|
295
|
+
// "refreshed" for the summary. Missing/error items are reported
|
|
296
|
+
// inline below and don't count toward success.
|
|
297
|
+
refreshedCount = refreshResult.updated + refreshResult.unchanged;
|
|
298
|
+
refreshedChunks = refreshResult.chunks;
|
|
299
|
+
for (const item of refreshResult.items) {
|
|
300
|
+
if (item.status === "missing") {
|
|
301
|
+
logger.warn(` Missing: ${item.source_path}`);
|
|
302
|
+
} else if (item.status === "error") {
|
|
303
|
+
logger.warn(
|
|
304
|
+
` Error refreshing ${item.source_path}: ${item.error}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Drop already-handled items from the work lists so downstream phases
|
|
312
|
+
// (LLM placement, description, insert, embed) see only truly-new items.
|
|
313
|
+
filesToAdd.splice(0, filesToAdd.length, ...remainingFiles);
|
|
314
|
+
urlsToAdd.splice(0, urlsToAdd.length, ...remainingUrls);
|
|
315
|
+
|
|
316
|
+
// Phase 1.5: LLM placement for files without an explicit path
|
|
199
317
|
const needsPlacement = filesToAdd.filter((f) => f.contextPath === null);
|
|
200
318
|
// description cache keyed by filePath — populated when LLM placement runs,
|
|
201
319
|
// reused in addFile to avoid a second describe call.
|
|
@@ -378,10 +496,13 @@ export function registerContextCommand(program: Command) {
|
|
|
378
496
|
}
|
|
379
497
|
}
|
|
380
498
|
|
|
381
|
-
// Report conflicts before embeddings so the user sees them prominently
|
|
499
|
+
// Report conflicts before embeddings so the user sees them prominently.
|
|
500
|
+
// Phase 0 already handled source-path matches, so anything here is a
|
|
501
|
+
// target-path collision — an LLM-suggested (or explicit) path that
|
|
502
|
+
// another unrelated item already occupies.
|
|
382
503
|
if (conflicts.length > 0) {
|
|
383
504
|
logger.error(
|
|
384
|
-
`${conflicts.length} path collision(s) — nothing written for these items:`,
|
|
505
|
+
`${conflicts.length} target-path collision(s) — nothing written for these items:`,
|
|
385
506
|
);
|
|
386
507
|
for (const c of conflicts) {
|
|
387
508
|
console.log(
|
|
@@ -389,24 +510,34 @@ export function registerContextCommand(program: Command) {
|
|
|
389
510
|
);
|
|
390
511
|
}
|
|
391
512
|
logger.dim(
|
|
392
|
-
"Re-run with --
|
|
513
|
+
"The suggested path is already in use by a different source. Re-run with --prefix to place these items elsewhere, or delete the existing item first.",
|
|
393
514
|
);
|
|
394
515
|
}
|
|
395
516
|
|
|
517
|
+
// Merge Phase 0 skips into the skip list used by the final summary.
|
|
518
|
+
skipped.push(...dedupSkipped);
|
|
519
|
+
|
|
396
520
|
// Phase 3: Chunk + embed in parallel (network I/O)
|
|
397
521
|
if (itemIds.length === 0 || !config.openai_api_key) {
|
|
398
522
|
if (!config.openai_api_key) {
|
|
399
523
|
logger.dim("Skipping embeddings (no OpenAI API key configured).");
|
|
400
524
|
}
|
|
401
|
-
const msg =
|
|
525
|
+
const msg = buildSummary({
|
|
526
|
+
added: itemIds.length,
|
|
527
|
+
refreshed: refreshedCount,
|
|
528
|
+
skipped: skipped.length,
|
|
529
|
+
chunks: refreshedChunks,
|
|
530
|
+
totalCount,
|
|
531
|
+
handled: itemIds.length + refreshedCount + skipped.length,
|
|
532
|
+
});
|
|
402
533
|
if (conflicts.length > 0) {
|
|
403
534
|
logger.error(msg);
|
|
404
535
|
process.exit(1);
|
|
405
536
|
}
|
|
406
|
-
if (itemIds.length
|
|
537
|
+
if (itemIds.length + skipped.length + refreshedCount >= totalCount) {
|
|
407
538
|
logger.success(msg);
|
|
408
539
|
process.exit(0);
|
|
409
|
-
} else if (itemIds.length === 0) {
|
|
540
|
+
} else if (itemIds.length === 0 && refreshedCount === 0) {
|
|
410
541
|
logger.error(msg);
|
|
411
542
|
process.exit(1);
|
|
412
543
|
} else {
|
|
@@ -452,15 +583,20 @@ export function registerContextCommand(program: Command) {
|
|
|
452
583
|
else filesAdded++;
|
|
453
584
|
}
|
|
454
585
|
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
586
|
+
const summary = buildSummary({
|
|
587
|
+
added: filesAdded,
|
|
588
|
+
updated: filesUpdated,
|
|
589
|
+
refreshed: refreshedCount,
|
|
590
|
+
skipped: skipped.length,
|
|
591
|
+
chunks: chunks + refreshedChunks,
|
|
592
|
+
totalCount,
|
|
593
|
+
handled: itemIds.length + refreshedCount + skipped.length,
|
|
594
|
+
});
|
|
459
595
|
if (conflicts.length > 0) {
|
|
460
596
|
logger.error(summary);
|
|
461
597
|
process.exit(1);
|
|
462
598
|
}
|
|
463
|
-
if (itemIds.length
|
|
599
|
+
if (itemIds.length + skipped.length + refreshedCount >= totalCount) {
|
|
464
600
|
logger.success(summary);
|
|
465
601
|
process.exit(0);
|
|
466
602
|
} else {
|
|
@@ -675,6 +811,26 @@ async function resolveItems(
|
|
|
675
811
|
|
|
676
812
|
type ConflictPolicy = "error" | "overwrite" | "skip";
|
|
677
813
|
|
|
814
|
+
/** Format the final "X added, Y refreshed, Z skipped — N chunks" line. */
|
|
815
|
+
function buildSummary(args: {
|
|
816
|
+
added: number;
|
|
817
|
+
updated?: number;
|
|
818
|
+
refreshed: number;
|
|
819
|
+
skipped: number;
|
|
820
|
+
chunks: number;
|
|
821
|
+
totalCount: number;
|
|
822
|
+
handled?: number;
|
|
823
|
+
}): string {
|
|
824
|
+
const parts: string[] = [];
|
|
825
|
+
if (args.added > 0) parts.push(`${args.added} added`);
|
|
826
|
+
if (args.updated && args.updated > 0) parts.push(`${args.updated} updated`);
|
|
827
|
+
if (args.refreshed > 0) parts.push(`${args.refreshed} refreshed`);
|
|
828
|
+
if (args.skipped > 0) parts.push(`${args.skipped} skipped`);
|
|
829
|
+
const body = parts.length > 0 ? parts.join(", ") : "0 added";
|
|
830
|
+
const handled = args.handled ?? args.added + args.refreshed + args.skipped;
|
|
831
|
+
return `${body} — ${args.chunks} chunk(s) indexed (${handled}/${args.totalCount} item(s)).`;
|
|
832
|
+
}
|
|
833
|
+
|
|
678
834
|
type AddFileResult =
|
|
679
835
|
| { kind: "added"; id: string; contextPath: string }
|
|
680
836
|
| { kind: "skipped"; contextPath: 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
|
|