botholomew 0.1.1 → 0.3.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.
Files changed (58) hide show
  1. package/package.json +7 -2
  2. package/src/chat/agent.ts +216 -0
  3. package/src/chat/session.ts +136 -0
  4. package/src/cli.ts +4 -0
  5. package/src/commands/chat.ts +30 -4
  6. package/src/commands/context.ts +179 -10
  7. package/src/commands/mcpx.ts +224 -8
  8. package/src/commands/prepare.ts +16 -0
  9. package/src/commands/schedule.ts +186 -0
  10. package/src/commands/task.ts +102 -52
  11. package/src/commands/tools.ts +21 -26
  12. package/src/commands/with-db.ts +23 -0
  13. package/src/config/schemas.ts +3 -1
  14. package/src/constants.ts +3 -0
  15. package/src/context/chunker.ts +164 -0
  16. package/src/context/embedder.ts +78 -0
  17. package/src/context/ingest.ts +102 -0
  18. package/src/daemon/index.ts +13 -1
  19. package/src/daemon/llm.ts +8 -1
  20. package/src/daemon/prompt.ts +98 -10
  21. package/src/daemon/schedules.ts +142 -0
  22. package/src/daemon/tick.ts +31 -3
  23. package/src/db/connection.ts +34 -0
  24. package/src/db/context.ts +14 -34
  25. package/src/db/embeddings.ts +189 -7
  26. package/src/db/query.ts +45 -0
  27. package/src/db/schedules.ts +125 -3
  28. package/src/db/sql/1-core_tables.sql +1 -1
  29. package/src/db/tasks.ts +128 -15
  30. package/src/db/threads.ts +12 -14
  31. package/src/init/index.ts +1 -1
  32. package/src/init/templates.ts +2 -1
  33. package/src/mcpx/client.ts +67 -0
  34. package/src/tools/context/search.ts +46 -0
  35. package/src/tools/context/update-beliefs.ts +54 -0
  36. package/src/tools/context/update-goals.ts +54 -0
  37. package/src/tools/file/edit.ts +3 -0
  38. package/src/tools/file/move.ts +6 -0
  39. package/src/tools/file/write.ts +3 -0
  40. package/src/tools/mcp/exec.ts +52 -0
  41. package/src/tools/mcp/info.ts +50 -0
  42. package/src/tools/mcp/list-tools.ts +42 -0
  43. package/src/tools/mcp/search.ts +58 -0
  44. package/src/tools/registry.ts +38 -0
  45. package/src/tools/schedule/create.ts +42 -0
  46. package/src/tools/schedule/list.ts +43 -0
  47. package/src/tools/search/semantic.ts +24 -4
  48. package/src/tools/task/list.ts +49 -0
  49. package/src/tools/task/view.ts +52 -0
  50. package/src/tools/thread/list.ts +50 -0
  51. package/src/tools/thread/view.ts +63 -0
  52. package/src/tools/tool.ts +2 -0
  53. package/src/tui/App.tsx +353 -9
  54. package/src/tui/components/InputBar.tsx +126 -0
  55. package/src/tui/components/MessageList.tsx +160 -0
  56. package/src/tui/components/StatusBar.tsx +73 -0
  57. package/src/tui/components/ToolCall.tsx +38 -0
  58. package/src/tui/components/ToolPanel.tsx +328 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "An AI agent for knowledge work",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,15 +18,20 @@
18
18
  "scripts": {
19
19
  "dev": "bun run src/cli.ts",
20
20
  "test": "bun test",
21
- "build": "bun build --compile --minify --sourcemap ./src/cli.ts --outfile dist/botholomew",
21
+ "build": "bun build --compile --minify --sourcemap --external react-devtools-core ./src/cli.ts --outfile dist/botholomew",
22
22
  "lint": "tsc --noEmit && biome check ."
23
23
  },
24
24
  "dependencies": {
25
25
  "@anthropic-ai/sdk": "^0.88.0",
26
+ "@evantahler/mcpx": "0.18.3",
27
+ "@huggingface/transformers": "^4.0.1",
28
+ "@sqliteai/sqlite-vector": "^0.9.95",
26
29
  "ansis": "^4.2.0",
27
30
  "commander": "^14.0.0",
28
31
  "gray-matter": "^4.0.3",
29
32
  "ink": "^6.0.0",
33
+ "ink-spinner": "^5.0.0",
34
+ "ink-text-input": "^6.0.0",
30
35
  "istextorbinary": "^9.5.0",
31
36
  "react": "^19.1.0",
32
37
  "uuid": "^13.0.0",
@@ -0,0 +1,216 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import type {
3
+ MessageParam,
4
+ ToolResultBlockParam,
5
+ ToolUseBlock,
6
+ } from "@anthropic-ai/sdk/resources/messages";
7
+ import type { BotholomewConfig } from "../config/schemas.ts";
8
+ import { buildMetaHeader, loadPersistentContext } from "../daemon/prompt.ts";
9
+ import type { DbConnection } from "../db/connection.ts";
10
+ import { logInteraction } from "../db/threads.ts";
11
+ import { registerAllTools } from "../tools/registry.ts";
12
+ import {
13
+ getAllTools,
14
+ getTool,
15
+ type ToolContext,
16
+ toAnthropicTool,
17
+ } from "../tools/tool.ts";
18
+
19
+ registerAllTools();
20
+
21
+ /** Tools available in chat mode — no daemon terminal tools, no destructive file tools */
22
+ const CHAT_TOOL_NAMES = new Set([
23
+ "create_task",
24
+ "list_tasks",
25
+ "view_task",
26
+ "search_context",
27
+ "search_grep",
28
+ "search_semantic",
29
+ "list_threads",
30
+ "view_thread",
31
+ "create_schedule",
32
+ "list_schedules",
33
+ "update_beliefs",
34
+ "update_goals",
35
+ "mcp_list_tools",
36
+ "mcp_search",
37
+ "mcp_info",
38
+ "mcp_exec",
39
+ ]);
40
+
41
+ export function getChatTools() {
42
+ return getAllTools()
43
+ .filter((t) => CHAT_TOOL_NAMES.has(t.name))
44
+ .map(toAnthropicTool);
45
+ }
46
+
47
+ export async function buildChatSystemPrompt(
48
+ projectDir: string,
49
+ ): Promise<string> {
50
+ const parts: string[] = [];
51
+
52
+ parts.push(...buildMetaHeader(projectDir));
53
+ parts.push(...(await loadPersistentContext(projectDir)));
54
+
55
+ parts.push("## Instructions");
56
+ parts.push(
57
+ "You are Botholomew's interactive chat interface. Help the user manage tasks, review results from daemon activity, search context, and answer questions.",
58
+ );
59
+ parts.push(
60
+ "You do NOT execute long-running work directly — enqueue tasks for the daemon instead using create_task.",
61
+ );
62
+ parts.push(
63
+ "Use the available tools to look up tasks, threads, schedules, and context when the user asks about them.",
64
+ );
65
+ parts.push(
66
+ "You can update the agent's beliefs and goals files when the user asks you to.",
67
+ );
68
+ parts.push(
69
+ "Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.",
70
+ );
71
+ parts.push("");
72
+
73
+ return parts.join("\n");
74
+ }
75
+
76
+ export interface ChatTurnCallbacks {
77
+ onToken: (text: string) => void;
78
+ onToolStart: (name: string, input: string) => void;
79
+ onToolEnd: (name: string, output: string) => void;
80
+ }
81
+
82
+ /**
83
+ * Run a single chat turn: stream the assistant response, execute any tool calls,
84
+ * and loop until the model produces end_turn with no tool calls.
85
+ * Mutates `messages` in-place by appending assistant/tool messages.
86
+ */
87
+ export async function runChatTurn(input: {
88
+ messages: MessageParam[];
89
+ systemPrompt: string;
90
+ config: Required<BotholomewConfig>;
91
+ conn: DbConnection;
92
+ threadId: string;
93
+ toolCtx: ToolContext;
94
+ callbacks: ChatTurnCallbacks;
95
+ }): Promise<void> {
96
+ const { messages, systemPrompt, config, conn, threadId, toolCtx, callbacks } =
97
+ input;
98
+
99
+ const client = new Anthropic({
100
+ apiKey: config.anthropic_api_key || undefined,
101
+ });
102
+
103
+ const chatTools = getChatTools();
104
+ const maxTurns = 10;
105
+
106
+ for (let turn = 0; turn < maxTurns; turn++) {
107
+ const startTime = Date.now();
108
+
109
+ const stream = client.messages.stream({
110
+ model: config.model,
111
+ max_tokens: 4096,
112
+ system: systemPrompt,
113
+ messages,
114
+ tools: chatTools,
115
+ });
116
+
117
+ // Collect the full response
118
+ let assistantText = "";
119
+
120
+ stream.on("text", (text) => {
121
+ assistantText += text;
122
+ callbacks.onToken(text);
123
+ });
124
+
125
+ const response = await stream.finalMessage();
126
+ const durationMs = Date.now() - startTime;
127
+ const tokenCount =
128
+ response.usage.input_tokens + response.usage.output_tokens;
129
+
130
+ // Log assistant text
131
+ if (assistantText) {
132
+ await logInteraction(conn, threadId, {
133
+ role: "assistant",
134
+ kind: "message",
135
+ content: assistantText,
136
+ durationMs,
137
+ tokenCount,
138
+ });
139
+ }
140
+
141
+ // Check for tool calls
142
+ const toolUseBlocks = response.content.filter(
143
+ (block): block is ToolUseBlock => block.type === "tool_use",
144
+ );
145
+
146
+ if (toolUseBlocks.length === 0) {
147
+ // No tool calls — turn is complete
148
+ messages.push({ role: "assistant", content: response.content });
149
+ return;
150
+ }
151
+
152
+ // Add assistant response to conversation
153
+ messages.push({ role: "assistant", content: response.content });
154
+
155
+ // Execute tool calls
156
+ const toolResults: ToolResultBlockParam[] = [];
157
+
158
+ for (const toolUse of toolUseBlocks) {
159
+ const toolInput = JSON.stringify(toolUse.input);
160
+ callbacks.onToolStart(toolUse.name, toolInput);
161
+
162
+ await logInteraction(conn, threadId, {
163
+ role: "assistant",
164
+ kind: "tool_use",
165
+ content: `Calling ${toolUse.name}`,
166
+ toolName: toolUse.name,
167
+ toolInput,
168
+ });
169
+
170
+ const toolStart = Date.now();
171
+ const result = await executeChatToolCall(toolUse, toolCtx);
172
+ const toolDuration = Date.now() - toolStart;
173
+
174
+ await logInteraction(conn, threadId, {
175
+ role: "tool",
176
+ kind: "tool_result",
177
+ content: result,
178
+ toolName: toolUse.name,
179
+ durationMs: toolDuration,
180
+ });
181
+
182
+ callbacks.onToolEnd(toolUse.name, result);
183
+
184
+ toolResults.push({
185
+ type: "tool_result",
186
+ tool_use_id: toolUse.id,
187
+ content: result,
188
+ });
189
+ }
190
+
191
+ messages.push({ role: "user", content: toolResults });
192
+ // Loop to get the model's next response after tool results
193
+ }
194
+ }
195
+
196
+ async function executeChatToolCall(
197
+ toolUse: ToolUseBlock,
198
+ ctx: ToolContext,
199
+ ): Promise<string> {
200
+ const tool = getTool(toolUse.name);
201
+ if (!tool) return `Unknown tool: ${toolUse.name}`;
202
+ if (!CHAT_TOOL_NAMES.has(tool.name))
203
+ return `Tool not available in chat mode: ${tool.name}`;
204
+
205
+ const parsed = tool.inputSchema.safeParse(toolUse.input);
206
+ if (!parsed.success) {
207
+ return `Invalid input: ${JSON.stringify(parsed.error)}`;
208
+ }
209
+
210
+ try {
211
+ const result = await tool.execute(parsed.data, ctx);
212
+ return typeof result === "string" ? result : JSON.stringify(result);
213
+ } catch (err) {
214
+ return `Tool error: ${err}`;
215
+ }
216
+ }
@@ -0,0 +1,136 @@
1
+ import type { MessageParam } from "@anthropic-ai/sdk/resources/messages";
2
+ import { loadConfig } from "../config/loader.ts";
3
+ import type { BotholomewConfig } from "../config/schemas.ts";
4
+ import { getDbPath } from "../constants.ts";
5
+ import type { DbConnection } from "../db/connection.ts";
6
+ import { getConnection } from "../db/connection.ts";
7
+ import { migrate } from "../db/schema.ts";
8
+ import {
9
+ createThread,
10
+ endThread,
11
+ getThread,
12
+ logInteraction,
13
+ reopenThread,
14
+ } from "../db/threads.ts";
15
+ import { createMcpxClient } from "../mcpx/client.ts";
16
+ import type { ToolContext } from "../tools/tool.ts";
17
+ import {
18
+ buildChatSystemPrompt,
19
+ type ChatTurnCallbacks,
20
+ runChatTurn,
21
+ } from "./agent.ts";
22
+
23
+ export interface ChatSession {
24
+ conn: DbConnection;
25
+ threadId: string;
26
+ projectDir: string;
27
+ config: Required<BotholomewConfig>;
28
+ messages: MessageParam[];
29
+ systemPrompt: string;
30
+ toolCtx: ToolContext;
31
+ cleanup: () => Promise<void>;
32
+ }
33
+
34
+ export async function startChatSession(
35
+ projectDir: string,
36
+ existingThreadId?: string,
37
+ ): Promise<ChatSession> {
38
+ const config = await loadConfig(projectDir);
39
+
40
+ if (!config.anthropic_api_key) {
41
+ throw new Error(
42
+ "no API key found. add anthropic_api_key to .botholomew/config.json",
43
+ );
44
+ }
45
+
46
+ const conn = getConnection(getDbPath(projectDir));
47
+ migrate(conn);
48
+
49
+ let threadId: string;
50
+ const messages: MessageParam[] = [];
51
+
52
+ if (existingThreadId) {
53
+ // Resume existing thread
54
+ const result = await getThread(conn, existingThreadId);
55
+ if (!result) {
56
+ conn.close();
57
+ throw new Error(`Thread not found: ${existingThreadId}`);
58
+ }
59
+ threadId = existingThreadId;
60
+ await reopenThread(conn, threadId);
61
+
62
+ // Rebuild message history from interactions
63
+ for (const interaction of result.interactions) {
64
+ if (interaction.kind !== "message") continue;
65
+ if (interaction.role === "user") {
66
+ messages.push({ role: "user", content: interaction.content });
67
+ } else if (interaction.role === "assistant") {
68
+ messages.push({ role: "assistant", content: interaction.content });
69
+ }
70
+ }
71
+ } else {
72
+ threadId = await createThread(
73
+ conn,
74
+ "chat_session",
75
+ undefined,
76
+ "Interactive chat",
77
+ );
78
+ }
79
+
80
+ const systemPrompt = await buildChatSystemPrompt(projectDir);
81
+
82
+ const mcpxClient = await createMcpxClient(projectDir);
83
+
84
+ const toolCtx: ToolContext = {
85
+ conn,
86
+ projectDir,
87
+ config,
88
+ mcpxClient,
89
+ };
90
+
91
+ const cleanup = async () => {
92
+ await mcpxClient?.close();
93
+ };
94
+
95
+ return {
96
+ conn,
97
+ threadId,
98
+ projectDir,
99
+ config,
100
+ messages,
101
+ systemPrompt,
102
+ toolCtx,
103
+ cleanup,
104
+ };
105
+ }
106
+
107
+ export async function sendMessage(
108
+ session: ChatSession,
109
+ userMessage: string,
110
+ callbacks: ChatTurnCallbacks,
111
+ ): Promise<void> {
112
+ // Log and append user message
113
+ await logInteraction(session.conn, session.threadId, {
114
+ role: "user",
115
+ kind: "message",
116
+ content: userMessage,
117
+ });
118
+
119
+ session.messages.push({ role: "user", content: userMessage });
120
+
121
+ await runChatTurn({
122
+ messages: session.messages,
123
+ systemPrompt: session.systemPrompt,
124
+ config: session.config,
125
+ conn: session.conn,
126
+ threadId: session.threadId,
127
+ toolCtx: session.toolCtx,
128
+ callbacks,
129
+ });
130
+ }
131
+
132
+ export async function endChatSession(session: ChatSession): Promise<void> {
133
+ await endThread(session.conn, session.threadId);
134
+ await session.cleanup();
135
+ session.conn.close();
136
+ }
package/src/cli.ts CHANGED
@@ -8,6 +8,8 @@ import { registerContextCommand } from "./commands/context.ts";
8
8
  import { registerDaemonCommand } from "./commands/daemon.ts";
9
9
  import { registerInitCommand } from "./commands/init.ts";
10
10
  import { registerMcpxCommand } from "./commands/mcpx.ts";
11
+ import { registerPrepareCommand } from "./commands/prepare.ts";
12
+ import { registerScheduleCommand } from "./commands/schedule.ts";
11
13
  import { registerTaskCommand } from "./commands/task.ts";
12
14
  import { registerToolCommands } from "./commands/tools.ts";
13
15
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
@@ -31,10 +33,12 @@ program
31
33
  registerInitCommand(program);
32
34
  registerDaemonCommand(program);
33
35
  registerTaskCommand(program);
36
+ registerScheduleCommand(program);
34
37
  registerChatCommand(program);
35
38
  registerContextCommand(program);
36
39
  registerMcpxCommand(program);
37
40
  registerToolCommands(program);
41
+ registerPrepareCommand(program);
38
42
  registerCheckUpdateCommand(program);
39
43
  registerUpgradeCommand(program);
40
44
 
@@ -1,11 +1,37 @@
1
1
  import type { Command } from "commander";
2
- import { logger } from "../utils/logger.ts";
3
2
 
4
3
  export function registerChatCommand(program: Command) {
5
4
  program
6
5
  .command("chat")
7
- .description("Open the interactive chat TUI")
8
- .action(async () => {
9
- logger.warn("Chat TUI not yet implemented. Coming soon.");
6
+ .description(
7
+ "Open the interactive chat TUI\n\n" +
8
+ " Keyboard shortcuts:\n" +
9
+ " Enter Send message\n" +
10
+ " ⌥+Enter Insert newline (multiline input)\n" +
11
+ " ↑/↓ Browse input history\n\n" +
12
+ " Commands:\n" +
13
+ " /help Show keyboard shortcuts\n" +
14
+ " /tools Open tool call inspector\n" +
15
+ " /quit, /exit End the chat session",
16
+ )
17
+ .option("--thread-id <id>", "Resume an existing chat thread")
18
+ .action(async (opts: { threadId?: string }) => {
19
+ const { render } = await import("ink");
20
+ const React = await import("react");
21
+ const { App } = await import("../tui/App.tsx");
22
+ const dir = program.opts().dir;
23
+ const instance = render(
24
+ React.createElement(App, {
25
+ projectDir: dir,
26
+ threadId: opts.threadId,
27
+ }),
28
+ {
29
+ kittyKeyboard: {
30
+ mode: "enabled",
31
+ flags: ["disambiguateEscapeCodes", "reportEventTypes"],
32
+ },
33
+ },
34
+ );
35
+ await instance.waitUntilExit();
10
36
  });
11
37
  }
@@ -1,5 +1,25 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import ansis from "ansis";
1
4
  import type { Command } from "commander";
5
+ import { isText } from "istextorbinary";
6
+ import { loadConfig } from "../config/loader.ts";
7
+ import { embedSingle, warmupEmbedder } from "../context/embedder.ts";
8
+ import { ingestContextItem } from "../context/ingest.ts";
9
+ import type { DbConnection } from "../db/connection.ts";
10
+ import {
11
+ createContextItem,
12
+ listContextItems,
13
+ listContextItemsByPrefix,
14
+ } from "../db/context.ts";
15
+ import { hybridSearch, initVectorSearch } from "../db/embeddings.ts";
2
16
  import { logger } from "../utils/logger.ts";
17
+ import { withDb } from "./with-db.ts";
18
+
19
+ function fmtDate(d: Date): string {
20
+ const pad = (n: number) => String(n).padStart(2, "0");
21
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
22
+ }
3
23
 
4
24
  export function registerContextCommand(program: Command) {
5
25
  const ctx = program.command("context").description("Manage context items");
@@ -7,21 +27,170 @@ export function registerContextCommand(program: Command) {
7
27
  ctx
8
28
  .command("list")
9
29
  .description("List context items")
10
- .action(async () => {
11
- logger.warn("Not yet implemented. Coming soon.");
12
- });
30
+ .option("--path <prefix>", "filter by path prefix")
31
+ .option("-l, --limit <n>", "max number of items", Number.parseInt)
32
+ .action((opts) =>
33
+ withDb(program, async (conn) => {
34
+ const items = opts.path
35
+ ? await listContextItemsByPrefix(conn, opts.path, {
36
+ recursive: true,
37
+ limit: opts.limit,
38
+ })
39
+ : await listContextItems(conn, { limit: opts.limit });
40
+
41
+ if (items.length === 0) {
42
+ logger.dim("No context items found.");
43
+ return;
44
+ }
45
+
46
+ const header = `${ansis.bold("Path".padEnd(40))} ${"Title".padEnd(25)} ${"Type".padEnd(20)} ${"Updated".padEnd(18)} Indexed`;
47
+ console.log(header);
48
+ console.log("-".repeat(header.length));
49
+
50
+ for (const item of items) {
51
+ const indexed = item.indexed_at
52
+ ? ansis.green("yes")
53
+ : ansis.dim("no");
54
+ const updated = ansis.dim(fmtDate(item.updated_at).padEnd(18));
55
+ console.log(
56
+ `${item.context_path.padEnd(40)} ${item.title.slice(0, 24).padEnd(25)} ${item.mime_type.slice(0, 19).padEnd(20)} ${updated} ${indexed}`,
57
+ );
58
+ }
59
+
60
+ console.log(`\n${ansis.dim(`${items.length} item(s)`)}`);
61
+ }),
62
+ );
13
63
 
14
64
  ctx
15
- .command("add <path>")
16
- .description("Add a file or directory to context")
17
- .action(async () => {
18
- logger.warn("Not yet implemented. Coming soon.");
19
- });
65
+ .command("add <paths...>")
66
+ .description("Add files or directories to context")
67
+ .option("--prefix <prefix>", "virtual path prefix", "/")
68
+ .action((paths: string[], opts) =>
69
+ withDb(program, async (conn, dir) => {
70
+ const config = await loadConfig(dir);
71
+ await warmupEmbedder();
72
+
73
+ let added = 0;
74
+ let chunks = 0;
75
+
76
+ for (const path of paths) {
77
+ const resolvedPath = resolve(path);
78
+ const info = await stat(resolvedPath);
79
+
80
+ if (info.isDirectory()) {
81
+ const entries = await walkDirectory(resolvedPath);
82
+ for (const filePath of entries) {
83
+ const relativePath = filePath.slice(resolvedPath.length);
84
+ const contextPath = join(opts.prefix, relativePath);
85
+ const count = await addFile(conn, config, filePath, contextPath);
86
+ if (count >= 0) {
87
+ added++;
88
+ chunks += count;
89
+ }
90
+ }
91
+ } else {
92
+ const contextPath = join(opts.prefix, basename(resolvedPath));
93
+ const count = await addFile(
94
+ conn,
95
+ config,
96
+ resolvedPath,
97
+ contextPath,
98
+ );
99
+ if (count >= 0) {
100
+ added++;
101
+ chunks += count;
102
+ }
103
+ }
104
+ }
105
+
106
+ logger.success(`Added ${added} file(s), ${chunks} chunk(s) indexed.`);
107
+ }),
108
+ );
20
109
 
21
110
  ctx
22
111
  .command("search <query>")
23
112
  .description("Search context items")
24
- .action(async () => {
25
- logger.warn("Not yet implemented. Coming soon.");
113
+ .option("-k, --top-k <n>", "max results", Number.parseInt, 10)
114
+ .action((query, opts) =>
115
+ withDb(program, async (conn) => {
116
+ await warmupEmbedder();
117
+ initVectorSearch(conn);
118
+ const queryVec = await embedSingle(query);
119
+ const results = hybridSearch(conn, query, queryVec, opts.topK);
120
+
121
+ if (results.length === 0) {
122
+ logger.dim("No results found.");
123
+ return;
124
+ }
125
+
126
+ for (const [i, r] of results.entries()) {
127
+ const score = (r.score * 100).toFixed(1);
128
+ console.log(
129
+ `${ansis.bold(`${i + 1}.`)} ${ansis.cyan(r.title)} ${ansis.dim(`(${score}%)`)}`,
130
+ );
131
+ console.log(
132
+ ` ${ansis.dim(r.source_path || r.context_item_id)} ${ansis.dim(fmtDate(r.created_at))}`,
133
+ );
134
+ if (r.chunk_content) {
135
+ const snippet = r.chunk_content.slice(0, 120).replace(/\n/g, " ");
136
+ console.log(` ${snippet}...`);
137
+ }
138
+ console.log("");
139
+ }
140
+ }),
141
+ );
142
+ }
143
+
144
+ async function addFile(
145
+ conn: DbConnection,
146
+ config: Awaited<ReturnType<typeof loadConfig>>,
147
+ filePath: string,
148
+ contextPath: string,
149
+ ): Promise<number> {
150
+ try {
151
+ const bunFile = Bun.file(filePath);
152
+ const mimeType = bunFile.type.split(";")[0] || "application/octet-stream";
153
+ const filename = basename(filePath);
154
+ const textual = isText(filename) !== false;
155
+
156
+ const content = textual ? await bunFile.text() : null;
157
+
158
+ const item = await createContextItem(conn, {
159
+ title: filename,
160
+ content: content ?? undefined,
161
+ mimeType,
162
+ sourcePath: filePath,
163
+ contextPath,
164
+ isTextual: textual,
26
165
  });
166
+
167
+ if (textual && content) {
168
+ const count = await ingestContextItem(conn, item.id, config);
169
+ console.log(` + ${contextPath} (${count} chunks)`);
170
+ return count;
171
+ }
172
+
173
+ console.log(` + ${contextPath} (binary, not indexed)`);
174
+ return 0;
175
+ } catch (err) {
176
+ logger.warn(` ! ${contextPath}: ${err}`);
177
+ return -1;
178
+ }
179
+ }
180
+
181
+ async function walkDirectory(dirPath: string): Promise<string[]> {
182
+ const files: string[] = [];
183
+ const entries = await readdir(dirPath, { withFileTypes: true });
184
+
185
+ for (const entry of entries) {
186
+ const fullPath = join(dirPath, entry.name);
187
+ if (entry.isDirectory()) {
188
+ if (entry.name.startsWith(".")) continue; // skip hidden dirs
189
+ files.push(...(await walkDirectory(fullPath)));
190
+ } else if (entry.isFile()) {
191
+ files.push(fullPath);
192
+ }
193
+ }
194
+
195
+ return files;
27
196
  }