botholomew 0.6.1 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botholomew",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "An AI agent for knowledge work",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "dependencies": {
25
25
  "@anthropic-ai/sdk": "^0.88.0",
26
26
  "@duckdb/node-api": "^1.5.2-r.1",
27
- "@evantahler/mcpx": "0.18.3",
27
+ "@evantahler/mcpx": "0.18.6",
28
28
  "ansis": "^4.2.0",
29
29
  "commander": "^14.0.0",
30
30
  "gray-matter": "^4.0.3",
package/src/chat/agent.ts CHANGED
@@ -25,7 +25,7 @@ const CHAT_TOOL_NAMES = new Set([
25
25
  "create_task",
26
26
  "list_tasks",
27
27
  "view_task",
28
- "search_context",
28
+ "context_search",
29
29
  "search_grep",
30
30
  "search_semantic",
31
31
  "list_threads",
@@ -13,6 +13,8 @@ import {
13
13
  reopenThread,
14
14
  } from "../db/threads.ts";
15
15
  import { createMcpxClient } from "../mcpx/client.ts";
16
+ import { loadSkills } from "../skills/loader.ts";
17
+ import type { SkillDefinition } from "../skills/parser.ts";
16
18
  import type { ToolContext } from "../tools/tool.ts";
17
19
  import { generateThreadTitle } from "../utils/title.ts";
18
20
  import {
@@ -29,6 +31,7 @@ export interface ChatSession {
29
31
  messages: MessageParam[];
30
32
  systemPrompt: string;
31
33
  toolCtx: ToolContext;
34
+ skills: Map<string, SkillDefinition>;
32
35
  cleanup: () => Promise<void>;
33
36
  }
34
37
 
@@ -83,6 +86,7 @@ export async function startChatSession(
83
86
  const systemPrompt = await buildChatSystemPrompt(projectDir);
84
87
 
85
88
  const mcpxClient = await createMcpxClient(projectDir);
89
+ const skills = await loadSkills(projectDir);
86
90
 
87
91
  const toolCtx: ToolContext = {
88
92
  conn,
@@ -103,6 +107,7 @@ export async function startChatSession(
103
107
  messages,
104
108
  systemPrompt,
105
109
  toolCtx,
110
+ skills,
106
111
  cleanup,
107
112
  };
108
113
  }
package/src/cli.ts CHANGED
@@ -10,9 +10,9 @@ import { registerInitCommand } from "./commands/init.ts";
10
10
  import { registerMcpxCommand } from "./commands/mcpx.ts";
11
11
  import { registerPrepareCommand } from "./commands/prepare.ts";
12
12
  import { registerScheduleCommand } from "./commands/schedule.ts";
13
+ import { registerSkillCommand } from "./commands/skill.ts";
13
14
  import { registerTaskCommand } from "./commands/task.ts";
14
15
  import { registerThreadCommand } from "./commands/thread.ts";
15
- import { registerToolCommands } from "./commands/tools.ts";
16
16
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
17
17
  import { maybeCheckForUpdate } from "./update/background.ts";
18
18
 
@@ -39,7 +39,7 @@ registerScheduleCommand(program);
39
39
  registerChatCommand(program);
40
40
  registerContextCommand(program);
41
41
  registerMcpxCommand(program);
42
- registerToolCommands(program);
42
+ registerSkillCommand(program);
43
43
  registerPrepareCommand(program);
44
44
  registerCheckUpdateCommand(program);
45
45
  registerUpgradeCommand(program);
@@ -17,14 +17,18 @@ import type { DbConnection } from "../db/connection.ts";
17
17
  import {
18
18
  type ContextItem,
19
19
  deleteContextItemByPath,
20
- getContextItemByPath,
21
20
  listContextItems,
22
21
  listContextItemsByPrefix,
22
+ resolveContextItem,
23
23
  updateContextItem,
24
24
  upsertContextItem,
25
25
  } from "../db/context.ts";
26
26
  import { getEmbeddingsForItem, hybridSearch } from "../db/embeddings.ts";
27
27
  import { logger } from "../utils/logger.ts";
28
+ import {
29
+ registerContextToolSubcommands,
30
+ registerSearchToolSubcommands,
31
+ } from "./tools.ts";
28
32
  import { withDb } from "./with-db.ts";
29
33
 
30
34
  function fmtDate(d: Date): string {
@@ -33,11 +37,11 @@ function fmtDate(d: Date): string {
33
37
  }
34
38
 
35
39
  export function registerContextCommand(program: Command) {
36
- const ctx = program.command("context").description("Manage context items");
40
+ const ctx = program.command("context").description("Manage context");
37
41
 
38
42
  ctx
39
43
  .command("list")
40
- .description("List context items")
44
+ .description("List context entries")
41
45
  .option("--path <prefix>", "filter by path prefix")
42
46
  .option("-l, --limit <n>", "max number of items", Number.parseInt)
43
47
  .option("-o, --offset <n>", "skip first N items", Number.parseInt)
@@ -55,7 +59,7 @@ export function registerContextCommand(program: Command) {
55
59
  });
56
60
 
57
61
  if (items.length === 0) {
58
- logger.dim("No context items found.");
62
+ logger.dim("No context entries found.");
59
63
  return;
60
64
  }
61
65
 
@@ -80,37 +84,6 @@ export function registerContextCommand(program: Command) {
80
84
  }),
81
85
  );
82
86
 
83
- ctx
84
- .command("show <path>")
85
- .description("Show details and content of a context item")
86
- .action((path: string) =>
87
- withDb(program, async (conn) => {
88
- const item = await getContextItemByPath(conn, path);
89
- if (!item) {
90
- logger.error(`Context item not found: ${path}`);
91
- process.exit(1);
92
- }
93
-
94
- console.log(ansis.bold(item.title));
95
- if (item.description) console.log(` Description: ${item.description}`);
96
- console.log(` Path: ${item.context_path}`);
97
- console.log(` MIME type: ${item.mime_type}`);
98
- if (item.source_path) console.log(` Source: ${item.source_path}`);
99
- const indexed = item.indexed_at
100
- ? `${ansis.green("yes")} (${fmtDate(item.indexed_at)})`
101
- : ansis.dim("no");
102
- console.log(` Indexed: ${indexed}`);
103
- console.log(` Created: ${fmtDate(item.created_at)}`);
104
- console.log(` Updated: ${fmtDate(item.updated_at)}`);
105
-
106
- if (item.is_textual && item.content) {
107
- console.log(`\n${"─".repeat(60)}\n${item.content}`);
108
- } else if (!item.is_textual) {
109
- console.log(ansis.dim("\n (binary content not shown)"));
110
- }
111
- }),
112
- );
113
-
114
87
  ctx
115
88
  .command("add <paths...>")
116
89
  .description("Add files or directories to context")
@@ -236,12 +209,17 @@ export function registerContextCommand(program: Command) {
236
209
  }),
237
210
  );
238
211
 
239
- ctx
240
- .command("search <query>")
241
- .description("Search context items")
212
+ const search = ctx
213
+ .command("search")
214
+ .description("Search context entries")
215
+ .argument("[query]", "search query (hybrid keyword + semantic)")
242
216
  .option("-k, --top-k <n>", "max results", Number.parseInt, 10)
243
217
  .action((query, opts) =>
244
218
  withDb(program, async (conn, dir) => {
219
+ if (!query) {
220
+ search.help();
221
+ return;
222
+ }
245
223
  const config = await loadConfig(dir);
246
224
  const queryVec = await embedSingle(query, config);
247
225
  const results = await hybridSearch(conn, query, queryVec, opts.topK);
@@ -267,27 +245,29 @@ export function registerContextCommand(program: Command) {
267
245
  }
268
246
  }),
269
247
  );
248
+
249
+ registerSearchToolSubcommands(search);
270
250
  ctx
271
251
  .command("delete <path>")
272
- .description("Delete a context item by path")
252
+ .description("Delete a context entry by path")
273
253
  .action((path: string) =>
274
254
  withDb(program, async (conn) => {
275
255
  const deleted = await deleteContextItemByPath(conn, path);
276
256
  if (!deleted) {
277
- logger.error(`Context item not found: ${path}`);
257
+ logger.error(`Context entry not found: ${path}`);
278
258
  process.exit(1);
279
259
  }
280
- logger.success(`Deleted context item: ${path}`);
260
+ logger.success(`Deleted context entry: ${path}`);
281
261
  }),
282
262
  );
283
263
  ctx
284
264
  .command("chunks <path>")
285
- .description("Show chunks and embeddings for a context item")
265
+ .description("Show chunks and embeddings for a context entry")
286
266
  .action((path: string) =>
287
267
  withDb(program, async (conn) => {
288
- const item = await getContextItemByPath(conn, path);
268
+ const item = await resolveContextItem(conn, path);
289
269
  if (!item) {
290
- logger.error(`Context item not found: ${path}`);
270
+ logger.error(`Context entry not found: ${path}`);
291
271
  process.exit(1);
292
272
  }
293
273
 
@@ -336,7 +316,7 @@ export function registerContextCommand(program: Command) {
336
316
  withDb(program, async (conn, dir) => {
337
317
  const items = await resolveItems(conn, path, !!opts.all);
338
318
  if (items.length === 0) {
339
- logger.error("No matching context items found.");
319
+ logger.error("No matching context entries found.");
340
320
  process.exit(1);
341
321
  }
342
322
 
@@ -436,6 +416,10 @@ export function registerContextCommand(program: Command) {
436
416
  );
437
417
  }),
438
418
  );
419
+
420
+ // Register context tool subcommands (read, write, edit, list-dir, etc.)
421
+ // Must come after management subcommands so collision detection works.
422
+ registerContextToolSubcommands(ctx);
439
423
  }
440
424
 
441
425
  async function resolveItems(
@@ -449,12 +433,12 @@ async function resolveItems(
449
433
  }
450
434
  if (all) return listContextItems(conn);
451
435
  const p = path as string;
452
- const exact = await getContextItemByPath(conn, p);
436
+ const exact = await resolveContextItem(conn, p);
453
437
  if (exact) return [exact];
454
438
  return listContextItemsByPrefix(conn, p, { recursive: true });
455
439
  }
456
440
 
457
- /** Upsert a file into the context DB. Returns the item ID if textual, null otherwise. */
441
+ /** Upsert a file into context. Returns the item ID if textual, null otherwise. */
458
442
  async function addFile(
459
443
  conn: DbConnection,
460
444
  filePath: string,
@@ -0,0 +1,100 @@
1
+ import { join } from "node:path";
2
+ import ansis from "ansis";
3
+ import type { Command } from "commander";
4
+ import { getSkillsDir } from "../constants.ts";
5
+ import { loadSkills } from "../skills/loader.ts";
6
+ import { parseSkillFile } from "../skills/parser.ts";
7
+ import { logger } from "../utils/logger.ts";
8
+
9
+ export function registerSkillCommand(program: Command) {
10
+ const skill = program
11
+ .command("skill")
12
+ .description("Manage skill slash-commands");
13
+
14
+ skill
15
+ .command("validate [file]")
16
+ .description("Validate skill files in .botholomew/skills/")
17
+ .action(async (file?: string) => {
18
+ const dir = program.opts().dir;
19
+
20
+ if (file) {
21
+ await validateSingleFile(file);
22
+ } else {
23
+ await validateAllSkills(dir);
24
+ }
25
+ });
26
+
27
+ skill
28
+ .command("create <name>")
29
+ .description("Create a new skill file from a template")
30
+ .option("--force", "overwrite existing file")
31
+ .action(async (name: string, opts: { force?: boolean }) => {
32
+ const dir = program.opts().dir;
33
+ const skillsDir = getSkillsDir(dir);
34
+ const normalized = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
35
+ const filePath = join(skillsDir, `${normalized}.md`);
36
+
37
+ if (!opts.force && (await Bun.file(filePath).exists())) {
38
+ logger.error(`Skill file already exists: ${filePath}`);
39
+ logger.dim("Use --force to overwrite.");
40
+ process.exit(1);
41
+ }
42
+
43
+ const template = `---
44
+ name: ${normalized}
45
+ description: ""
46
+ arguments: []
47
+ ---
48
+
49
+ Your prompt template here. Use $ARGUMENTS for the full argument string
50
+ or $1, $2, etc. for positional arguments.
51
+ `;
52
+
53
+ await Bun.write(filePath, template);
54
+ logger.success(`Created skill: ${filePath}`);
55
+ });
56
+ }
57
+
58
+ async function validateSingleFile(filePath: string): Promise<void> {
59
+ try {
60
+ const raw = await Bun.file(filePath).text();
61
+ const skill = parseSkillFile(raw, filePath);
62
+ logger.success(`${ansis.bold(skill.name)} — valid`);
63
+ if (skill.description) logger.dim(` ${skill.description}`);
64
+ if (skill.arguments.length > 0) {
65
+ logger.dim(` ${skill.arguments.length} argument(s)`);
66
+ }
67
+ } catch (e) {
68
+ logger.error(`${filePath}: ${e instanceof Error ? e.message : e}`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ async function validateAllSkills(dir: string): Promise<void> {
74
+ let hasErrors = false;
75
+
76
+ try {
77
+ const skills = await loadSkills(dir);
78
+
79
+ if (skills.size === 0) {
80
+ logger.dim("No skill files found.");
81
+ return;
82
+ }
83
+
84
+ for (const [name, skill] of skills) {
85
+ const argCount = skill.arguments.length;
86
+ const args = argCount > 0 ? `${argCount} arg(s)` : "no args";
87
+ logger.success(
88
+ `${ansis.bold(name).padEnd(20)} ${skill.description || ansis.dim("(no description)")} ${ansis.dim(`[${args}]`)}`,
89
+ );
90
+ }
91
+
92
+ logger.info("");
93
+ logger.dim(`${skills.size} skill(s) validated.`);
94
+ } catch (e) {
95
+ logger.error(`Validation failed: ${e instanceof Error ? e.message : e}`);
96
+ hasErrors = true;
97
+ }
98
+
99
+ if (hasErrors) process.exit(1);
100
+ }
@@ -1,7 +1,7 @@
1
+ import ansis from "ansis";
1
2
  import type { Command } from "commander";
2
3
  import { z } from "zod";
3
- import type { BotholomewConfig } from "../config/schemas.ts";
4
- import { DEFAULT_CONFIG } from "../config/schemas.ts";
4
+ import { loadConfig } from "../config/loader.ts";
5
5
  import { registerAllTools } from "../tools/registry.ts";
6
6
  import {
7
7
  type AnyToolDefinition,
@@ -13,31 +13,42 @@ import { withDb } from "./with-db.ts";
13
13
 
14
14
  registerAllTools();
15
15
 
16
- const GROUP_DESCRIPTIONS: Record<string, string> = {
17
- dir: "Directory operations on the virtual filesystem",
18
- file: "File operations on the virtual filesystem",
19
- search: "Search the virtual filesystem",
20
- };
21
-
22
- export function registerToolCommands(program: Command) {
23
- for (const group of ["dir", "file", "search"]) {
24
- const groupCmd = program
25
- .command(group)
26
- .description(GROUP_DESCRIPTIONS[group] ?? `${group} tools`);
16
+ /**
17
+ * Register context tool subcommands (read, write, edit, etc.) onto an
18
+ * existing Commander command. Skips tools whose derived subcommand name
19
+ * collides with an already-registered subcommand on the parent.
20
+ */
21
+ /** Context tools that are agent-only (not exposed as CLI subcommands) */
22
+ const AGENT_ONLY_TOOLS = new Set(["update_beliefs", "update_goals"]);
23
+
24
+ export function registerContextToolSubcommands(parent: Command) {
25
+ const existing = new Set(parent.commands.map((c: Command) => c.name()));
26
+
27
+ for (const tool of getToolsByGroup("context")) {
28
+ if (AGENT_ONLY_TOOLS.has(tool.name)) continue;
29
+ const subName = deriveSubName(tool.name);
30
+ if (existing.has(subName)) continue; // skip conflicts with management subcommands
31
+ registerToolAsCLI(parent, tool);
32
+ }
33
+ }
27
34
 
28
- for (const tool of getToolsByGroup(group)) {
29
- registerToolAsCLI(groupCmd, tool, program);
30
- }
35
+ /**
36
+ * Register search tool subcommands (grep, semantic) onto an
37
+ * existing Commander command (e.g. the "context search" group).
38
+ */
39
+ export function registerSearchToolSubcommands(parent: Command) {
40
+ for (const tool of getToolsByGroup("search")) {
41
+ registerToolAsCLI(parent, tool);
31
42
  }
32
43
  }
33
44
 
34
- function registerToolAsCLI(
35
- parent: Command,
36
- tool: AnyToolDefinition,
37
- program: Command,
38
- ) {
39
- // Derive subcommand name: "file_read" → "read", "file_count_lines" "count-lines"
40
- const subName = tool.name.replace(/^[^_]+_/, "").replace(/_/g, "-");
45
+ /** Derive CLI subcommand name from tool name: "context_read" → "read", "context_list_dir" → "list-dir" */
46
+ function deriveSubName(toolName: string): string {
47
+ return toolName.replace(/^[^_]+_/, "").replace(/_/g, "-");
48
+ }
49
+
50
+ function registerToolAsCLI(parent: Command, tool: AnyToolDefinition) {
51
+ const subName = deriveSubName(tool.name);
41
52
 
42
53
  // Inspect zod schema to determine positional args and options
43
54
  const shape = tool.inputSchema.shape as Record<string, z.ZodType>;
@@ -92,15 +103,17 @@ function registerToolAsCLI(
92
103
  }
93
104
  }
94
105
 
95
- cmd.action((...args: unknown[]) =>
96
- withDb(program, async (conn, dir) => {
106
+ cmd.action((...args: unknown[]) => {
107
+ let root: Command = parent;
108
+ while (root.parent) root = root.parent;
109
+ return withDb(root, async (conn, dir) => {
97
110
  try {
98
111
  const input = buildInput(tool, positionals, options, shape, args);
99
112
 
100
113
  const ctx: ToolContext = {
101
114
  conn,
102
115
  projectDir: dir,
103
- config: DEFAULT_CONFIG as Required<BotholomewConfig>,
116
+ config: await loadConfig(dir),
104
117
  mcpxClient: null,
105
118
  };
106
119
 
@@ -110,8 +123,8 @@ function registerToolAsCLI(
110
123
  logger.error(String(err));
111
124
  process.exit(1);
112
125
  }
113
- }),
114
- );
126
+ });
127
+ });
115
128
  }
116
129
 
117
130
  function buildInput(
@@ -214,6 +227,29 @@ function formatOutput(result: unknown, _toolName: string) {
214
227
  return;
215
228
  }
216
229
 
230
+ if ("results" in obj && Array.isArray(obj.results)) {
231
+ for (const [i, r] of (
232
+ obj.results as {
233
+ path: string;
234
+ title: string;
235
+ score: number;
236
+ snippet: string;
237
+ }[]
238
+ ).entries()) {
239
+ const score = (r.score * 100).toFixed(1);
240
+ console.log(
241
+ `${ansis.bold(`${i + 1}.`)} ${ansis.cyan(r.title)} ${ansis.dim(`(${score}%)`)}`,
242
+ );
243
+ console.log(` ${ansis.dim(r.path)}`);
244
+ if (r.snippet) {
245
+ const snippet = r.snippet.slice(0, 120).replace(/\n/g, " ");
246
+ console.log(` ${snippet}...`);
247
+ }
248
+ console.log("");
249
+ }
250
+ return;
251
+ }
252
+
217
253
  // Default: print as JSON
218
254
  console.log(JSON.stringify(obj, null, 2));
219
255
  } else {
@@ -224,20 +260,20 @@ function formatOutput(result: unknown, _toolName: string) {
224
260
  function isPositionalArg(key: string, toolName: string): boolean {
225
261
  // These keys are treated as positional arguments
226
262
  const positionalKeys: Record<string, string[]> = {
227
- dir_create: ["path"],
228
- dir_list: ["path"],
229
- dir_tree: ["path"],
230
- dir_size: ["path"],
231
- file_read: ["path"],
232
- file_write: ["path"],
233
- file_edit: ["path"],
234
- file_delete: ["path"],
235
- file_copy: ["src", "dst"],
236
- file_move: ["src", "dst"],
237
- file_info: ["path"],
238
- file_exists: ["path"],
239
- file_count_lines: ["path"],
240
- search_find: ["pattern"],
263
+ context_create_dir: ["path"],
264
+ context_list_dir: ["path"],
265
+ context_tree: ["path"],
266
+ context_dir_size: ["path"],
267
+ context_read: ["path"],
268
+ context_write: ["path"],
269
+ context_edit: ["path"],
270
+ context_delete: ["path"],
271
+ context_copy: ["src", "dst"],
272
+ context_move: ["src", "dst"],
273
+ context_info: ["path"],
274
+ context_exists: ["path"],
275
+ context_count_lines: ["path"],
276
+ context_search: ["query"],
241
277
  search_grep: ["pattern"],
242
278
  search_semantic: ["query"],
243
279
  };
package/src/constants.ts CHANGED
@@ -17,6 +17,7 @@ export const PID_FILENAME = "daemon.pid";
17
17
  export const LOG_FILENAME = "daemon.log";
18
18
  export const CONFIG_FILENAME = "config.json";
19
19
  export const MCPX_DIR = "mcpx";
20
+ export const SKILLS_DIR = "skills";
20
21
  export const MCPX_SERVERS_FILENAME = "servers.json";
21
22
  export const EMBEDDING_DIMENSION = 1536;
22
23
  export const EMBEDDING_MODEL = "text-embedding-3-small";
@@ -50,6 +51,10 @@ export function getMcpxDir(projectDir: string): string {
50
51
  return join(projectDir, BOTHOLOMEW_DIR, MCPX_DIR);
51
52
  }
52
53
 
54
+ export function getSkillsDir(projectDir: string): string {
55
+ return join(projectDir, BOTHOLOMEW_DIR, SKILLS_DIR);
56
+ }
57
+
53
58
  export function getWatchdogLogPath(projectDir: string): string {
54
59
  return join(projectDir, BOTHOLOMEW_DIR, WATCHDOG_LOG_FILENAME);
55
60
  }
package/src/db/context.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection } from "./connection.ts";
2
2
  import { buildSetClauses, buildWhereClause, sanitizeInt } from "./query.ts";
3
- import { uuidv7 } from "./uuid.ts";
3
+ import { isUuid, uuidv7 } from "./uuid.ts";
4
4
 
5
5
  export interface ContextItem {
6
6
  id: string;
@@ -90,7 +90,7 @@ export async function createContextItem(
90
90
  *
91
91
  * DuckDB implements UPDATE as delete+insert on tables with unique indexes,
92
92
  * which violates foreign keys from the embeddings table. We must delete
93
- * embeddings before updating; callers (context add, file_write) re-create
93
+ * embeddings before updating; callers (context add, context_write) re-create
94
94
  * them in their ingestion phase.
95
95
  */
96
96
  export async function upsertContextItem(
@@ -140,6 +140,30 @@ export async function getContextItemByPath(
140
140
  return row ? rowToContextItem(row) : null;
141
141
  }
142
142
 
143
+ /**
144
+ * Look up a context item by UUID (if the value looks like one) or by context_path.
145
+ */
146
+ export async function resolveContextItem(
147
+ db: DbConnection,
148
+ pathOrId: string,
149
+ ): Promise<ContextItem | null> {
150
+ return isUuid(pathOrId)
151
+ ? getContextItem(db, pathOrId)
152
+ : getContextItemByPath(db, pathOrId);
153
+ }
154
+
155
+ /**
156
+ * Like resolveContextItem but throws if not found.
157
+ */
158
+ export async function resolveContextItemOrThrow(
159
+ db: DbConnection,
160
+ pathOrId: string,
161
+ ): Promise<ContextItem> {
162
+ const item = await resolveContextItem(db, pathOrId);
163
+ if (!item) throw new Error(`Not found: ${pathOrId}`);
164
+ return item;
165
+ }
166
+
143
167
  export async function listContextItems(
144
168
  db: DbConnection,
145
169
  filters?: {
@@ -207,6 +231,29 @@ export async function contextPathExists(
207
231
  return row != null;
208
232
  }
209
233
 
234
+ export async function countContextItemsByPrefix(
235
+ db: DbConnection,
236
+ prefix: string,
237
+ opts?: { recursive?: boolean },
238
+ ): Promise<number> {
239
+ const normalizedPrefix = prefix.endsWith("/") ? prefix : `${prefix}/`;
240
+ let row: { cnt: number } | null;
241
+ if (opts?.recursive !== false) {
242
+ row = await db.queryGet<{ cnt: number }>(
243
+ `SELECT COUNT(*) AS cnt FROM context_items WHERE context_path LIKE ?1`,
244
+ `${normalizedPrefix}%`,
245
+ );
246
+ } else {
247
+ row = await db.queryGet<{ cnt: number }>(
248
+ `SELECT COUNT(*) AS cnt FROM context_items
249
+ WHERE context_path LIKE ?1 AND context_path NOT LIKE ?2`,
250
+ `${normalizedPrefix}%`,
251
+ `${normalizedPrefix}%/%`,
252
+ );
253
+ }
254
+ return row ? Number(row.cnt) : 0;
255
+ }
256
+
210
257
  export async function getDistinctDirectories(
211
258
  db: DbConnection,
212
259
  prefix?: string,
package/src/db/uuid.ts CHANGED
@@ -1 +1,8 @@
1
1
  export { v7 as uuidv7 } from "uuid";
2
+
3
+ const UUID_RE =
4
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5
+
6
+ export function isUuid(value: string): boolean {
7
+ return UUID_RE.test(value);
8
+ }
package/src/init/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { getBotholomewDir, getDbPath, getMcpxDir } from "../constants.ts";
3
+ import {
4
+ getBotholomewDir,
5
+ getDbPath,
6
+ getMcpxDir,
7
+ getSkillsDir,
8
+ } from "../constants.ts";
4
9
  import { getConnection } from "../db/connection.ts";
5
10
  import { migrate } from "../db/schema.ts";
6
11
  import { logger } from "../utils/logger.ts";
@@ -10,6 +15,8 @@ import {
10
15
  DEFAULT_MCPX_SERVERS,
11
16
  GOALS_MD,
12
17
  SOUL_MD,
18
+ STANDUP_SKILL,
19
+ SUMMARIZE_SKILL,
13
20
  } from "./templates.ts";
14
21
 
15
22
  export async function initProject(
@@ -18,6 +25,7 @@ export async function initProject(
18
25
  ): Promise<void> {
19
26
  const dotDir = getBotholomewDir(projectDir);
20
27
  const mcpxDir = getMcpxDir(projectDir);
28
+ const skillsDir = getSkillsDir(projectDir);
21
29
 
22
30
  // Check if already initialized
23
31
  const dirExists = await Bun.file(join(dotDir, "soul.md")).exists();
@@ -30,12 +38,17 @@ export async function initProject(
30
38
  // Create directories
31
39
  await mkdir(dotDir, { recursive: true });
32
40
  await mkdir(mcpxDir, { recursive: true });
41
+ await mkdir(skillsDir, { recursive: true });
33
42
 
34
43
  // Write template files
35
44
  await Bun.write(join(dotDir, "soul.md"), SOUL_MD);
36
45
  await Bun.write(join(dotDir, "beliefs.md"), BELIEFS_MD);
37
46
  await Bun.write(join(dotDir, "goals.md"), GOALS_MD);
38
47
 
48
+ // Write default skills
49
+ await Bun.write(join(skillsDir, "summarize.md"), SUMMARIZE_SKILL);
50
+ await Bun.write(join(skillsDir, "standup.md"), STANDUP_SKILL);
51
+
39
52
  // Write config (with placeholder API key)
40
53
  await Bun.write(
41
54
  join(dotDir, "config.json"),