botholomew 0.9.9 → 0.9.11
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 +18 -14
- package/package.json +1 -1
- package/src/chat/agent.ts +5 -1
- package/src/cli.ts +2 -0
- package/src/commands/db.ts +112 -0
- package/src/commands/skill.ts +21 -0
- package/src/constants.ts +7 -4
- package/src/db/doctor.ts +214 -0
- package/src/db/sql/17-worker_log_path.sql +3 -0
- package/src/db/workers.ts +7 -2
- package/src/init/templates.ts +2 -0
- package/src/skills/commands.ts +29 -4
- package/src/skills/parser.ts +41 -1
- package/src/tools/registry.ts +2 -0
- package/src/tools/skill/delete.ts +56 -0
- package/src/tui/App.tsx +17 -11
- package/src/tui/components/WorkerPanel.tsx +240 -6
- package/src/worker/index.ts +15 -1
- package/src/worker/log-reader.ts +35 -0
- package/src/worker/prompt.ts +10 -0
- package/src/worker/run.ts +10 -2
- package/src/worker/spawn.ts +23 -5
package/README.md
CHANGED
|
@@ -31,8 +31,9 @@ through MCP servers wired up via [MCPX](https://github.com/evantahler/mcpx).
|
|
|
31
31
|
DuckDB. Copy it, share it, check it in (or `.gitignore` it).
|
|
32
32
|
- **Your data, your disk.** Project state — tasks, threads, ingested
|
|
33
33
|
context, embeddings — lives in `.botholomew/`, indexed in DuckDB with
|
|
34
|
-
|
|
35
|
-
any further reach is scoped to
|
|
34
|
+
BM25 keyword search and `array_cosine_distance` vector search. Model
|
|
35
|
+
calls go direct to Anthropic and OpenAI; any further reach is scoped to
|
|
36
|
+
the MCP servers you add.
|
|
36
37
|
- **Extensible.** External tools come from MCP servers via
|
|
37
38
|
[MCPX](https://github.com/evantahler/mcpx) — run them locally (Gmail,
|
|
38
39
|
Slack, GitHub) or connect through an MCP gateway like
|
|
@@ -115,13 +116,16 @@ my-project/
|
|
|
115
116
|
soul.md # always-loaded identity (not agent-editable)
|
|
116
117
|
beliefs.md # always-loaded, agent-editable priors
|
|
117
118
|
goals.md # always-loaded, agent-editable goals
|
|
119
|
+
capabilities.md # always-loaded, agent-editable tool inventory
|
|
118
120
|
config.json # models, tick interval, API keys
|
|
119
121
|
data.duckdb # tasks, schedules, context, embeddings, logs
|
|
120
122
|
mcpx/servers.json # external MCP servers (Gmail, Slack, …)
|
|
121
|
-
skills/ # user-defined
|
|
123
|
+
skills/ # slash commands (built-ins + user-defined)
|
|
122
124
|
summarize.md
|
|
123
125
|
standup.md
|
|
124
|
-
|
|
126
|
+
capabilities.md
|
|
127
|
+
logs/ # per-worker log files (one file per spawned worker)
|
|
128
|
+
<worker-id>.log
|
|
125
129
|
```
|
|
126
130
|
|
|
127
131
|
Everything the agent can touch is here. No surprises.
|
|
@@ -139,14 +143,14 @@ Everything the agent can touch is here. No surprises.
|
|
|
139
143
|
| `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
|
|
140
144
|
| `botholomew chat` | Interactive Ink/React TUI |
|
|
141
145
|
| `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
|
|
142
|
-
| `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
|
|
143
|
-
| `botholomew context add\|list\|
|
|
146
|
+
| `botholomew schedule list\|add\|view\|enable\|disable\|trigger\|delete` | Recurring work |
|
|
147
|
+
| `botholomew context add\|list\|search\|chunks\|refresh\|delete` | Ingest & browse knowledge (files, folders, URLs); also exposes the agent's `read`/`write`/`tree`/`edit`/… tools as subcommands |
|
|
144
148
|
| `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `.botholomew/capabilities.md` |
|
|
145
|
-
| `botholomew mcpx servers\|add\|remove\|info\|search\|exec\|ping\|auth\|import-global
|
|
149
|
+
| `botholomew mcpx servers\|list\|add\|remove\|info\|search\|exec\|ping\|auth\|deauth\|import-global\|…` | Configure external MCP servers (passthrough to `mcpx`) |
|
|
146
150
|
| `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
|
|
147
|
-
| `botholomew context ... \| search ...` | Direct access to the agent's virtual filesystem |
|
|
148
151
|
| `botholomew thread list\|view` | Browse the agent's interaction history |
|
|
149
152
|
| `botholomew nuke context\|tasks\|schedules\|threads\|all` | Bulk-erase sections of the database |
|
|
153
|
+
| `botholomew db doctor [--repair]` | Probe each table for primary-key index corruption; rebuild via EXPORT/IMPORT |
|
|
150
154
|
| `botholomew upgrade` | Self-update |
|
|
151
155
|
|
|
152
156
|
All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
|
|
@@ -174,7 +178,7 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
|
|
|
174
178
|
│ ┌───────────┐ ┌──────────────┐ │
|
|
175
179
|
│ │ tasks │ │ context_items│ │
|
|
176
180
|
│ │ schedules │ │ embeddings │ │
|
|
177
|
-
│ │ workers │ │
|
|
181
|
+
│ │ workers │ │ (FTS+vector)│ │
|
|
178
182
|
│ │ threads │ │ │ │
|
|
179
183
|
│ └───────────┘ └──────────────┘ │
|
|
180
184
|
└─────┬───────────────────────────────┘
|
|
@@ -202,8 +206,8 @@ Topics worth understanding in detail:
|
|
|
202
206
|
- **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
|
|
203
207
|
"files" are actually DuckDB rows, and how `context_read`/`context_write` work.
|
|
204
208
|
- **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
|
|
205
|
-
chunking, OpenAI embeddings, and DuckDB
|
|
206
|
-
|
|
209
|
+
chunking, OpenAI embeddings, and DuckDB BM25 + linear-scan vector
|
|
210
|
+
search merged with reciprocal rank fusion.
|
|
207
211
|
- **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
|
|
208
212
|
validation, stale-task recovery, and natural-language recurring schedules.
|
|
209
213
|
- **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
|
|
@@ -225,9 +229,9 @@ Topics worth understanding in detail:
|
|
|
225
229
|
## Tech stack
|
|
226
230
|
|
|
227
231
|
- **[Bun](https://bun.sh)** + TypeScript
|
|
228
|
-
- **[DuckDB](https://duckdb.org)** via `@duckdb/node-api
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
- **[DuckDB](https://duckdb.org)** via `@duckdb/node-api` —
|
|
233
|
+
`array_cosine_distance()` (core DuckDB) for vector search, plus the
|
|
234
|
+
built-in FTS extension for BM25 keyword search
|
|
231
235
|
- **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
|
|
232
236
|
Claude — the reasoning model
|
|
233
237
|
- **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
|
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
buildMetaHeader,
|
|
25
25
|
extractKeywords,
|
|
26
26
|
loadPersistentContext,
|
|
27
|
+
STYLE_RULES,
|
|
27
28
|
} from "../worker/prompt.ts";
|
|
28
29
|
|
|
29
30
|
registerAllTools();
|
|
@@ -58,6 +59,7 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
58
59
|
"skill_write",
|
|
59
60
|
"skill_edit",
|
|
60
61
|
"skill_search",
|
|
62
|
+
"skill_delete",
|
|
61
63
|
]);
|
|
62
64
|
|
|
63
65
|
export function getChatTools() {
|
|
@@ -114,7 +116,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
|
|
|
114
116
|
Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Context items live under a drive (disk / url / agent / google-docs / github / …); use \`context_list_drives\` to discover which drives have content, then \`context_tree\`, \`context_info\`, \`context_search\`, or \`context_refresh\` as needed.
|
|
115
117
|
When multiple tool calls are independent of each other (i.e., one does not depend on the result of another), call them all in a single response. They will be executed in parallel, which is faster than calling them one at a time.
|
|
116
118
|
You can update the agent's beliefs and goals files when the user asks you to.
|
|
117
|
-
You can author and refine slash-command skills (reusable prompt templates stored in \`.botholomew/skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, and \`
|
|
119
|
+
You can author and refine slash-command skills (reusable prompt templates stored in \`.botholomew/skills/\`) via \`skill_list\`, \`skill_search\`, \`skill_read\`, \`skill_write\`, \`skill_edit\`, and \`skill_delete\`. New or edited skills are usable as \`/<name>\` on the user's next message.
|
|
118
120
|
Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
|
|
119
121
|
`;
|
|
120
122
|
|
|
@@ -151,6 +153,8 @@ Skip step 2 only if you already called \`mcp_info\` for that exact server+tool e
|
|
|
151
153
|
`;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
prompt += `\n${STYLE_RULES}`;
|
|
157
|
+
|
|
154
158
|
return prompt;
|
|
155
159
|
}
|
|
156
160
|
|
package/src/cli.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { registerCapabilitiesCommand } from "./commands/capabilities.ts";
|
|
|
6
6
|
import { registerChatCommand } from "./commands/chat.ts";
|
|
7
7
|
import { registerCheckUpdateCommand } from "./commands/check-update.ts";
|
|
8
8
|
import { registerContextCommand } from "./commands/context.ts";
|
|
9
|
+
import { registerDbCommand } from "./commands/db.ts";
|
|
9
10
|
import { registerInitCommand } from "./commands/init.ts";
|
|
10
11
|
import { registerMcpxCommand } from "./commands/mcpx.ts";
|
|
11
12
|
import { registerNukeCommand } from "./commands/nuke.ts";
|
|
@@ -40,6 +41,7 @@ registerThreadCommand(program);
|
|
|
40
41
|
registerScheduleCommand(program);
|
|
41
42
|
registerChatCommand(program);
|
|
42
43
|
registerContextCommand(program);
|
|
44
|
+
registerDbCommand(program);
|
|
43
45
|
registerCapabilitiesCommand(program);
|
|
44
46
|
registerMcpxCommand(program);
|
|
45
47
|
registerSkillCommand(program);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import ansis from "ansis";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { getDbPath } from "../constants.ts";
|
|
4
|
+
import { withDb as coreWithDb } from "../db/connection.ts";
|
|
5
|
+
import {
|
|
6
|
+
type ProbeResult,
|
|
7
|
+
probeAllTables,
|
|
8
|
+
repairDatabase,
|
|
9
|
+
} from "../db/doctor.ts";
|
|
10
|
+
import { listWorkers } from "../db/workers.ts";
|
|
11
|
+
import { logger } from "../utils/logger.ts";
|
|
12
|
+
|
|
13
|
+
function statusBadge(status: ProbeResult["status"]): string {
|
|
14
|
+
switch (status) {
|
|
15
|
+
case "ok":
|
|
16
|
+
return ansis.green("ok");
|
|
17
|
+
case "empty":
|
|
18
|
+
return ansis.dim("empty");
|
|
19
|
+
case "missing":
|
|
20
|
+
return ansis.dim("missing");
|
|
21
|
+
case "corrupt":
|
|
22
|
+
return ansis.red.bold("corrupt");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function printResults(results: ProbeResult[]) {
|
|
27
|
+
const nameWidth = Math.max(...results.map((r) => r.table.length));
|
|
28
|
+
for (const r of results) {
|
|
29
|
+
const name = r.table.padEnd(nameWidth + 2);
|
|
30
|
+
const detail = r.message ? ansis.dim(` ${r.message.slice(0, 200)}`) : "";
|
|
31
|
+
console.log(` ${name}${statusBadge(r.status)}${detail}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerDbCommand(program: Command) {
|
|
36
|
+
const db = program
|
|
37
|
+
.command("db")
|
|
38
|
+
.description("Inspect and repair the project database");
|
|
39
|
+
|
|
40
|
+
db.command("doctor")
|
|
41
|
+
.description(
|
|
42
|
+
"Probe every table for primary-key index corruption and optionally repair via EXPORT/IMPORT",
|
|
43
|
+
)
|
|
44
|
+
.option(
|
|
45
|
+
"-r, --repair",
|
|
46
|
+
"Rebuild the database file from an export when corruption is detected",
|
|
47
|
+
)
|
|
48
|
+
.action((opts) => doctor(program, opts.repair === true));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function doctor(program: Command, repair: boolean): Promise<void> {
|
|
52
|
+
const dir = program.opts().dir as string;
|
|
53
|
+
const dbPath = getDbPath(dir);
|
|
54
|
+
|
|
55
|
+
logger.info(`Probing tables in ${dbPath}`);
|
|
56
|
+
const results = await probeAllTables(dbPath);
|
|
57
|
+
printResults(results);
|
|
58
|
+
|
|
59
|
+
const corrupt = results.filter((r) => r.status === "corrupt");
|
|
60
|
+
if (corrupt.length === 0) {
|
|
61
|
+
logger.success("No corruption detected.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.error(
|
|
66
|
+
`${corrupt.length} table(s) have corrupted indexes: ${corrupt
|
|
67
|
+
.map((r) => r.table)
|
|
68
|
+
.join(", ")}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (!repair) {
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log(
|
|
74
|
+
ansis.yellow(
|
|
75
|
+
"Re-run with --repair to rebuild the database file (creates a timestamped backup).",
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Repair requires exclusive access — refuse if any worker is registered
|
|
82
|
+
// as running, otherwise the EXPORT would race with the worker's writes.
|
|
83
|
+
const running = await coreWithDb(dbPath, async (conn) => {
|
|
84
|
+
try {
|
|
85
|
+
return await listWorkers(conn, { status: "running" });
|
|
86
|
+
} catch {
|
|
87
|
+
// If listWorkers itself trips the corruption we're about to fix,
|
|
88
|
+
// fall through and let repair proceed; the user is on their own
|
|
89
|
+
// for confirming no live workers, which `worker reap` would also
|
|
90
|
+
// be unable to do anyway.
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
if (running.length > 0) {
|
|
95
|
+
logger.error(
|
|
96
|
+
`${running.length} worker(s) registered as running. Stop them first: botholomew worker stop <id>`,
|
|
97
|
+
);
|
|
98
|
+
for (const w of running) {
|
|
99
|
+
logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
|
|
100
|
+
}
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
logger.phase("repair", "EXPORT DATABASE → swap files → IMPORT DATABASE");
|
|
105
|
+
const result = await repairDatabase(dbPath);
|
|
106
|
+
logger.success(
|
|
107
|
+
`Repaired in ${result.durationMs}ms. Backup: ${result.backupDbPath}`,
|
|
108
|
+
);
|
|
109
|
+
logger.dim(
|
|
110
|
+
" Re-run `botholomew db doctor` to confirm. Delete the backup once you're sure.",
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/commands/skill.ts
CHANGED
|
@@ -129,6 +129,27 @@ or $1, $2, etc. for positional arguments.
|
|
|
129
129
|
await Bun.write(filePath, template);
|
|
130
130
|
logger.success(`Created skill: ${filePath}`);
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
skill
|
|
134
|
+
.command("delete <name>")
|
|
135
|
+
.description("Delete a skill file")
|
|
136
|
+
.action(async (name: string) => {
|
|
137
|
+
const dir = program.opts().dir;
|
|
138
|
+
const skills = await loadSkills(dir);
|
|
139
|
+
const s = skills.get(name.toLowerCase());
|
|
140
|
+
|
|
141
|
+
if (!s) {
|
|
142
|
+
logger.error(`Skill not found: ${name}`);
|
|
143
|
+
if (skills.size > 0) {
|
|
144
|
+
const available = [...skills.keys()].sort().join(", ");
|
|
145
|
+
console.error(ansis.dim(`Available: ${available}`));
|
|
146
|
+
}
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await Bun.file(s.filePath).delete();
|
|
151
|
+
logger.success(`Deleted skill: ${s.filePath}`);
|
|
152
|
+
});
|
|
132
153
|
}
|
|
133
154
|
|
|
134
155
|
async function validateSingleFile(filePath: string): Promise<void> {
|
package/src/constants.ts
CHANGED
|
@@ -13,14 +13,13 @@ export const DEFAULTS = {
|
|
|
13
13
|
UPDATE_CHECK_TIMEOUT_MS: 5_000,
|
|
14
14
|
} as const;
|
|
15
15
|
export const DB_FILENAME = "data.duckdb";
|
|
16
|
-
export const
|
|
16
|
+
export const LOGS_DIR = "logs";
|
|
17
17
|
export const CONFIG_FILENAME = "config.json";
|
|
18
18
|
export const MCPX_DIR = "mcpx";
|
|
19
19
|
export const SKILLS_DIR = "skills";
|
|
20
20
|
export const MCPX_SERVERS_FILENAME = "servers.json";
|
|
21
21
|
export const EMBEDDING_DIMENSION = 1536;
|
|
22
22
|
export const EMBEDDING_MODEL = "text-embedding-3-small";
|
|
23
|
-
export const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
24
23
|
|
|
25
24
|
export function getBotholomewDir(projectDir: string): string {
|
|
26
25
|
return join(projectDir, BOTHOLOMEW_DIR);
|
|
@@ -30,8 +29,12 @@ export function getDbPath(projectDir: string): string {
|
|
|
30
29
|
return join(projectDir, BOTHOLOMEW_DIR, DB_FILENAME);
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
export function
|
|
34
|
-
return join(projectDir, BOTHOLOMEW_DIR,
|
|
32
|
+
export function getWorkerLogsDir(projectDir: string): string {
|
|
33
|
+
return join(projectDir, BOTHOLOMEW_DIR, LOGS_DIR);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getWorkerLogPath(projectDir: string, workerId: string): string {
|
|
37
|
+
return join(projectDir, BOTHOLOMEW_DIR, LOGS_DIR, `${workerId}.log`);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export function getConfigPath(projectDir: string): string {
|
package/src/db/doctor.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { mkdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { withDb } from "./connection.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tables we probe for primary-key index integrity. Every user table has a
|
|
7
|
+
* single-column PK that we exercise with a self-update (SET pk = pk WHERE
|
|
8
|
+
* pk = ...). DuckDB still walks the index for the SET, which surfaces
|
|
9
|
+
* "Failed to delete all rows from index" FATAL errors when the index is
|
|
10
|
+
* out of sync with the row data. `_migrations` is excluded — it is small,
|
|
11
|
+
* append-only, and rebuilding it would defeat its purpose.
|
|
12
|
+
*/
|
|
13
|
+
export const PROBE_TABLES: ReadonlyArray<{ name: string; pk: string }> = [
|
|
14
|
+
{ name: "workers", pk: "id" },
|
|
15
|
+
{ name: "tasks", pk: "id" },
|
|
16
|
+
{ name: "schedules", pk: "id" },
|
|
17
|
+
{ name: "threads", pk: "id" },
|
|
18
|
+
{ name: "interactions", pk: "id" },
|
|
19
|
+
{ name: "context_items", pk: "id" },
|
|
20
|
+
{ name: "embeddings", pk: "id" },
|
|
21
|
+
{ name: "daemon_state", pk: "key" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export type ProbeStatus = "ok" | "empty" | "missing" | "corrupt";
|
|
25
|
+
|
|
26
|
+
export interface ProbeResult {
|
|
27
|
+
table: string;
|
|
28
|
+
status: ProbeStatus;
|
|
29
|
+
/** Detail message when status is corrupt or missing. */
|
|
30
|
+
message?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Probe a single table for index corruption by spawning a child Bun
|
|
35
|
+
* process. We use a child process because a corrupt PK index in DuckDB
|
|
36
|
+
* surfaces as a Bun panic (a C++ exception that unwinds past the NAPI
|
|
37
|
+
* boundary), which would kill the doctor itself. The child reports its
|
|
38
|
+
* verdict on stdout and exits.
|
|
39
|
+
*
|
|
40
|
+
* Uses absolute import path resolved against this file so the spawned
|
|
41
|
+
* Bun process picks up the same `@duckdb/node-api` install.
|
|
42
|
+
*/
|
|
43
|
+
export async function probeTable(
|
|
44
|
+
dbPath: string,
|
|
45
|
+
table: string,
|
|
46
|
+
pk: string,
|
|
47
|
+
): Promise<ProbeResult> {
|
|
48
|
+
const script = `
|
|
49
|
+
const { DuckDBInstance } = await import("@duckdb/node-api");
|
|
50
|
+
const dbPath = ${JSON.stringify(dbPath)};
|
|
51
|
+
const table = ${JSON.stringify(table)};
|
|
52
|
+
const pk = ${JSON.stringify(pk)};
|
|
53
|
+
let inst;
|
|
54
|
+
try {
|
|
55
|
+
inst = await DuckDBInstance.create(dbPath);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
process.stdout.write("MISSING:" + (e?.message ?? String(e)));
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
const c = await inst.connect();
|
|
61
|
+
try {
|
|
62
|
+
const r = await c.runAndReadAll(\`SELECT \${pk} FROM \${table} LIMIT 1\`);
|
|
63
|
+
if (r.getRows().length === 0) {
|
|
64
|
+
process.stdout.write("EMPTY");
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
const msg = String(e?.message ?? e);
|
|
69
|
+
// Table doesn't exist yet (e.g., schema older than this doctor) — not
|
|
70
|
+
// a corruption signal, just skip it.
|
|
71
|
+
if (msg.includes("does not exist") || msg.includes("Catalog Error")) {
|
|
72
|
+
process.stdout.write("MISSING:" + msg);
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
process.stdout.write("CORRUPT:" + msg);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await c.run(\`UPDATE \${table} SET \${pk} = \${pk} WHERE \${pk} = (SELECT \${pk} FROM \${table} LIMIT 1)\`);
|
|
80
|
+
process.stdout.write("OK");
|
|
81
|
+
process.exit(0);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
process.stdout.write("CORRUPT:" + (e?.message ?? String(e)));
|
|
84
|
+
process.exit(2);
|
|
85
|
+
}
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const proc = Bun.spawn(["bun", "-e", script], {
|
|
89
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
90
|
+
});
|
|
91
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
92
|
+
new Response(proc.stdout).text(),
|
|
93
|
+
new Response(proc.stderr).text(),
|
|
94
|
+
proc.exited,
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
// Bun panic: process killed by SIGTRAP / non-zero exit with no stdout
|
|
98
|
+
// verdict. Treat any unrecognized exit as corruption — better to flag
|
|
99
|
+
// for repair than to silently miss a problem.
|
|
100
|
+
if (stdout.startsWith("OK")) return { table, status: "ok" };
|
|
101
|
+
if (stdout.startsWith("EMPTY")) return { table, status: "empty" };
|
|
102
|
+
if (stdout.startsWith("MISSING:")) {
|
|
103
|
+
return {
|
|
104
|
+
table,
|
|
105
|
+
status: "missing",
|
|
106
|
+
message: stdout.slice("MISSING:".length),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (stdout.startsWith("CORRUPT:")) {
|
|
110
|
+
return {
|
|
111
|
+
table,
|
|
112
|
+
status: "corrupt",
|
|
113
|
+
message: stdout.slice("CORRUPT:".length),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const reason =
|
|
117
|
+
stderr.trim() ||
|
|
118
|
+
`child exited with code ${exitCode} and no verdict (likely native panic)`;
|
|
119
|
+
return { table, status: "corrupt", message: reason };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Run probes for every known table. Sequential rather than parallel so we
|
|
124
|
+
* cooperate with DuckDB's per-process file lock and don't multiply the
|
|
125
|
+
* blast radius of a panic.
|
|
126
|
+
*/
|
|
127
|
+
export async function probeAllTables(dbPath: string): Promise<ProbeResult[]> {
|
|
128
|
+
const results: ProbeResult[] = [];
|
|
129
|
+
for (const { name, pk } of PROBE_TABLES) {
|
|
130
|
+
results.push(await probeTable(dbPath, name, pk));
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RepairResult {
|
|
136
|
+
backupDbPath: string;
|
|
137
|
+
exportDir: string;
|
|
138
|
+
durationMs: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Repair `dbPath` by exporting its contents and importing into a fresh
|
|
143
|
+
* file. EXPORT DATABASE reads via sequential scans, not via PK indexes,
|
|
144
|
+
* so it survives the kind of index corruption that breaks UPDATE/DELETE.
|
|
145
|
+
* IMPORT DATABASE rebuilds every index from the data, which restores
|
|
146
|
+
* write integrity.
|
|
147
|
+
*
|
|
148
|
+
* Steps:
|
|
149
|
+
* 1. CHECKPOINT (best-effort) to flush WAL.
|
|
150
|
+
* 2. EXPORT DATABASE to `<dotDir>/.export-<timestamp>`.
|
|
151
|
+
* 3. Move `data.duckdb` (and `.wal`) to `data.duckdb.bak-<timestamp>`.
|
|
152
|
+
* 4. Open a fresh DB at the original path and IMPORT DATABASE.
|
|
153
|
+
* 5. Leave the export dir on disk — cheap insurance if step 4 ever fails
|
|
154
|
+
* mid-way; cleanup on the next successful run.
|
|
155
|
+
*
|
|
156
|
+
* The caller is responsible for ensuring no other process holds the DB
|
|
157
|
+
* (no running workers, no chat session, no TUI).
|
|
158
|
+
*/
|
|
159
|
+
export async function repairDatabase(dbPath: string): Promise<RepairResult> {
|
|
160
|
+
const start = Date.now();
|
|
161
|
+
const dotDir = dirname(dbPath);
|
|
162
|
+
await mkdir(dotDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
const stamp = new Date()
|
|
165
|
+
.toISOString()
|
|
166
|
+
.replace(/[:.]/g, "-")
|
|
167
|
+
.replace(/Z$/, "");
|
|
168
|
+
const exportDir = join(dotDir, `.export-${stamp}`);
|
|
169
|
+
const backupDbPath = `${dbPath}.bak-${stamp}`;
|
|
170
|
+
const walPath = `${dbPath}.wal`;
|
|
171
|
+
const backupWalPath = `${backupDbPath}.wal`;
|
|
172
|
+
|
|
173
|
+
await withDb(dbPath, async (conn) => {
|
|
174
|
+
try {
|
|
175
|
+
await conn.exec("CHECKPOINT");
|
|
176
|
+
} catch {
|
|
177
|
+
// CHECKPOINT can fail on an already-invalidated DB; the EXPORT
|
|
178
|
+
// below is what actually matters.
|
|
179
|
+
}
|
|
180
|
+
await conn.exec(`EXPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await rename(dbPath, backupDbPath);
|
|
184
|
+
if (await pathExists(walPath)) {
|
|
185
|
+
await rename(walPath, backupWalPath);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await withDb(dbPath, async (conn) => {
|
|
189
|
+
await conn.exec(`IMPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Best-effort cleanup of the export dir. Leave it on failure — the user
|
|
193
|
+
// still has data.duckdb (rebuilt) and the backup.
|
|
194
|
+
try {
|
|
195
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
backupDbPath,
|
|
202
|
+
exportDir,
|
|
203
|
+
durationMs: Date.now() - start,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
208
|
+
try {
|
|
209
|
+
await stat(p);
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/db/workers.ts
CHANGED
|
@@ -14,6 +14,7 @@ export interface Worker {
|
|
|
14
14
|
started_at: Date;
|
|
15
15
|
last_heartbeat_at: Date;
|
|
16
16
|
stopped_at: Date | null;
|
|
17
|
+
log_path: string | null;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
interface WorkerRow {
|
|
@@ -26,6 +27,7 @@ interface WorkerRow {
|
|
|
26
27
|
started_at: string;
|
|
27
28
|
last_heartbeat_at: string;
|
|
28
29
|
stopped_at: string | null;
|
|
30
|
+
log_path: string | null;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
function rowToWorker(row: WorkerRow): Worker {
|
|
@@ -39,6 +41,7 @@ function rowToWorker(row: WorkerRow): Worker {
|
|
|
39
41
|
started_at: new Date(row.started_at),
|
|
40
42
|
last_heartbeat_at: new Date(row.last_heartbeat_at),
|
|
41
43
|
stopped_at: row.stopped_at ? new Date(row.stopped_at) : null,
|
|
44
|
+
log_path: row.log_path,
|
|
42
45
|
};
|
|
43
46
|
}
|
|
44
47
|
|
|
@@ -50,17 +53,19 @@ export async function registerWorker(
|
|
|
50
53
|
hostname: string;
|
|
51
54
|
mode: Worker["mode"];
|
|
52
55
|
taskId?: string | null;
|
|
56
|
+
logPath?: string | null;
|
|
53
57
|
},
|
|
54
58
|
): Promise<Worker> {
|
|
55
59
|
const row = await db.queryGet<WorkerRow>(
|
|
56
|
-
`INSERT INTO workers (id, pid, hostname, mode, task_id, status)
|
|
57
|
-
VALUES (?1, ?2, ?3, ?4, ?5, 'running')
|
|
60
|
+
`INSERT INTO workers (id, pid, hostname, mode, task_id, status, log_path)
|
|
61
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'running', ?6)
|
|
58
62
|
RETURNING *`,
|
|
59
63
|
params.id,
|
|
60
64
|
params.pid,
|
|
61
65
|
params.hostname,
|
|
62
66
|
params.mode,
|
|
63
67
|
params.taskId ?? null,
|
|
68
|
+
params.logPath ?? null,
|
|
64
69
|
);
|
|
65
70
|
if (!row) throw new Error("INSERT did not return a row");
|
|
66
71
|
return rowToWorker(row);
|
package/src/init/templates.ts
CHANGED
|
@@ -8,6 +8,8 @@ agent-modification: false
|
|
|
8
8
|
You are Botholomew, an AI agent for knowledge work, personified by a wise owl. You help humans manage information, research topics, organize knowledge, and complete intellectual tasks.
|
|
9
9
|
|
|
10
10
|
You are thoughtful, thorough, and proactive. You work through your task queue methodically, prioritizing appropriately and asking for clarification when needed.
|
|
11
|
+
|
|
12
|
+
You are direct: lead with the answer, skip preambles, disagree when you have reason to, and never flatter.
|
|
11
13
|
`;
|
|
12
14
|
|
|
13
15
|
export const BELIEFS_MD = `---
|
package/src/skills/commands.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SkillDefinition } from "./parser.ts";
|
|
2
|
-
import { renderSkill } from "./parser.ts";
|
|
2
|
+
import { renderSkill, validateSkillArgs } from "./parser.ts";
|
|
3
3
|
|
|
4
4
|
export interface SlashCommand {
|
|
5
5
|
name: string;
|
|
@@ -14,14 +14,32 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|
|
14
14
|
{ name: "exit", description: "End the chat session" },
|
|
15
15
|
];
|
|
16
16
|
|
|
17
|
+
export interface QueueUserMessageOptions {
|
|
18
|
+
display?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
export interface SlashCommandContext {
|
|
18
22
|
skills: Map<string, SkillDefinition>;
|
|
19
23
|
addSystemMessage: (content: string) => void;
|
|
20
|
-
queueUserMessage: (content: string) => void;
|
|
24
|
+
queueUserMessage: (content: string, opts?: QueueUserMessageOptions) => void;
|
|
21
25
|
exit: () => void;
|
|
22
26
|
clearChat?: () => void;
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
export function formatSkillUsage(skill: SkillDefinition): string {
|
|
30
|
+
const parts = [`/${skill.name}`];
|
|
31
|
+
for (const arg of skill.arguments) {
|
|
32
|
+
if (arg.required && arg.default === undefined) {
|
|
33
|
+
parts.push(`<${arg.name}>`);
|
|
34
|
+
} else if (arg.default !== undefined) {
|
|
35
|
+
parts.push(`[${arg.name}=${arg.default}]`);
|
|
36
|
+
} else {
|
|
37
|
+
parts.push(`[${arg.name}]`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return parts.join(" ");
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
/**
|
|
26
44
|
* Handle a slash-command input. Returns true if the command was consumed
|
|
27
45
|
* (recognized or errored), false if it should fall through.
|
|
@@ -70,9 +88,16 @@ export function handleSlashCommand(
|
|
|
70
88
|
// Skill dispatch
|
|
71
89
|
const skill = ctx.skills.get(name);
|
|
72
90
|
if (skill) {
|
|
91
|
+
const { missing } = validateSkillArgs(skill, rawArgs);
|
|
92
|
+
if (missing.length > 0) {
|
|
93
|
+
ctx.addSystemMessage(
|
|
94
|
+
`/${skill.name}: missing required argument(s): ${missing.join(", ")}\n` +
|
|
95
|
+
`Usage: ${formatSkillUsage(skill)}`,
|
|
96
|
+
);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
73
99
|
const rendered = renderSkill(skill, rawArgs);
|
|
74
|
-
ctx.
|
|
75
|
-
ctx.queueUserMessage(rendered);
|
|
100
|
+
ctx.queueUserMessage(rendered, { display: input });
|
|
76
101
|
return true;
|
|
77
102
|
}
|
|
78
103
|
|