botholomew 0.9.10 → 0.9.12
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 +16 -13
- package/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/db.ts +123 -0
- package/src/db/doctor.ts +242 -0
- package/src/worker/llm.ts +19 -0
- package/src/worker/tick.ts +3 -0
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,12 +116,14 @@ 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
|
|
126
|
+
capabilities.md
|
|
124
127
|
logs/ # per-worker log files (one file per spawned worker)
|
|
125
128
|
<worker-id>.log
|
|
126
129
|
```
|
|
@@ -140,14 +143,14 @@ Everything the agent can touch is here. No surprises.
|
|
|
140
143
|
| `botholomew worker list\|status\|stop\|kill\|reap` | Inspect and manage running workers |
|
|
141
144
|
| `botholomew chat` | Interactive Ink/React TUI |
|
|
142
145
|
| `botholomew task list\|add\|view\|update\|reset\|delete` | Manage the task queue |
|
|
143
|
-
| `botholomew schedule list\|add\|enable\|trigger\|delete` | Recurring work |
|
|
144
|
-
| `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 |
|
|
145
148
|
| `botholomew capabilities` | Rescan built-in + MCPX tools and rewrite `.botholomew/capabilities.md` |
|
|
146
|
-
| `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`) |
|
|
147
150
|
| `botholomew skill list\|show\|create\|validate` | Manage slash-command skills |
|
|
148
|
-
| `botholomew context ... \| search ...` | Direct access to the agent's virtual filesystem |
|
|
149
151
|
| `botholomew thread list\|view` | Browse the agent's interaction history |
|
|
150
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 |
|
|
151
154
|
| `botholomew upgrade` | Self-update |
|
|
152
155
|
|
|
153
156
|
All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagination.
|
|
@@ -175,7 +178,7 @@ All `list` subcommands support `-l, --limit <n>` and `-o, --offset <n>` for pagi
|
|
|
175
178
|
│ ┌───────────┐ ┌──────────────┐ │
|
|
176
179
|
│ │ tasks │ │ context_items│ │
|
|
177
180
|
│ │ schedules │ │ embeddings │ │
|
|
178
|
-
│ │ workers │ │
|
|
181
|
+
│ │ workers │ │ (FTS+vector)│ │
|
|
179
182
|
│ │ threads │ │ │ │
|
|
180
183
|
│ └───────────┘ └──────────────┘ │
|
|
181
184
|
└─────┬───────────────────────────────┘
|
|
@@ -203,8 +206,8 @@ Topics worth understanding in detail:
|
|
|
203
206
|
- **[The virtual filesystem](docs/virtual-filesystem.md)** — why the agent's
|
|
204
207
|
"files" are actually DuckDB rows, and how `context_read`/`context_write` work.
|
|
205
208
|
- **[Context & hybrid search](docs/context-and-search.md)** — LLM-driven
|
|
206
|
-
chunking, OpenAI embeddings, and DuckDB
|
|
207
|
-
|
|
209
|
+
chunking, OpenAI embeddings, and DuckDB BM25 + linear-scan vector
|
|
210
|
+
search merged with reciprocal rank fusion.
|
|
208
211
|
- **[Tasks & schedules](docs/tasks-and-schedules.md)** — the claim loop, DAG
|
|
209
212
|
validation, stale-task recovery, and natural-language recurring schedules.
|
|
210
213
|
- **[The Tool class](docs/tools.md)** — one Zod definition, three consumers
|
|
@@ -226,9 +229,9 @@ Topics worth understanding in detail:
|
|
|
226
229
|
## Tech stack
|
|
227
230
|
|
|
228
231
|
- **[Bun](https://bun.sh)** + TypeScript
|
|
229
|
-
- **[DuckDB](https://duckdb.org)** via `@duckdb/node-api
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
232
235
|
- **[Anthropic SDK](https://docs.anthropic.com/en/api/client-sdks)** for
|
|
233
236
|
Claude — the reasoning model
|
|
234
237
|
- **OpenAI embeddings API** (`text-embedding-3-small`, 1536-dim) for
|
package/package.json
CHANGED
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,123 @@
|
|
|
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
|
+
isPidAlive,
|
|
7
|
+
type ProbeResult,
|
|
8
|
+
probeAllTables,
|
|
9
|
+
repairDatabase,
|
|
10
|
+
} from "../db/doctor.ts";
|
|
11
|
+
import { listWorkers, type Worker } from "../db/workers.ts";
|
|
12
|
+
import { logger } from "../utils/logger.ts";
|
|
13
|
+
|
|
14
|
+
function statusBadge(status: ProbeResult["status"]): string {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case "ok":
|
|
17
|
+
return ansis.green("ok");
|
|
18
|
+
case "empty":
|
|
19
|
+
return ansis.dim("empty");
|
|
20
|
+
case "missing":
|
|
21
|
+
return ansis.dim("missing");
|
|
22
|
+
case "corrupt":
|
|
23
|
+
return ansis.red.bold("corrupt");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function printResults(results: ProbeResult[]) {
|
|
28
|
+
const nameWidth = Math.max(...results.map((r) => r.table.length));
|
|
29
|
+
for (const r of results) {
|
|
30
|
+
const name = r.table.padEnd(nameWidth + 2);
|
|
31
|
+
const detail = r.message ? ansis.dim(` ${r.message.slice(0, 200)}`) : "";
|
|
32
|
+
console.log(` ${name}${statusBadge(r.status)}${detail}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerDbCommand(program: Command) {
|
|
37
|
+
const db = program
|
|
38
|
+
.command("db")
|
|
39
|
+
.description("Inspect and repair the project database");
|
|
40
|
+
|
|
41
|
+
db.command("doctor")
|
|
42
|
+
.description(
|
|
43
|
+
"Probe every table for primary-key index corruption and optionally repair via EXPORT/IMPORT",
|
|
44
|
+
)
|
|
45
|
+
.option(
|
|
46
|
+
"-r, --repair",
|
|
47
|
+
"Rebuild the database file from an export when corruption is detected",
|
|
48
|
+
)
|
|
49
|
+
.action((opts) => doctor(program, opts.repair === true));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function doctor(program: Command, repair: boolean): Promise<void> {
|
|
53
|
+
const dir = program.opts().dir as string;
|
|
54
|
+
const dbPath = getDbPath(dir);
|
|
55
|
+
|
|
56
|
+
logger.info(`Probing tables in ${dbPath}`);
|
|
57
|
+
const results = await probeAllTables(dbPath);
|
|
58
|
+
printResults(results);
|
|
59
|
+
|
|
60
|
+
const corrupt = results.filter((r) => r.status === "corrupt");
|
|
61
|
+
if (corrupt.length === 0) {
|
|
62
|
+
logger.success("No corruption detected.");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
logger.error(
|
|
67
|
+
`${corrupt.length} table(s) have corrupted indexes: ${corrupt
|
|
68
|
+
.map((r) => r.table)
|
|
69
|
+
.join(", ")}`,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (!repair) {
|
|
73
|
+
console.log("");
|
|
74
|
+
console.log(
|
|
75
|
+
ansis.yellow(
|
|
76
|
+
"Re-run with --repair to rebuild the database file (creates a timestamped backup).",
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Repair requires exclusive access — refuse if any worker is actually
|
|
83
|
+
// running, otherwise the EXPORT would race with the worker's writes.
|
|
84
|
+
// Stale `status='running'` rows whose PID is dead (the exact case that
|
|
85
|
+
// tends to coexist with workers-table corruption) are reported but do
|
|
86
|
+
// not block repair: trying to flip them to `stopped` would just trip
|
|
87
|
+
// the same corruption we're about to fix.
|
|
88
|
+
const running = await coreWithDb(dbPath, async (conn) => {
|
|
89
|
+
try {
|
|
90
|
+
return await listWorkers(conn, { status: "running" });
|
|
91
|
+
} catch {
|
|
92
|
+
return [] as Worker[];
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const live = running.filter((w) => isPidAlive(w.pid));
|
|
96
|
+
const stale = running.filter((w) => !isPidAlive(w.pid));
|
|
97
|
+
if (live.length > 0) {
|
|
98
|
+
logger.error(
|
|
99
|
+
`${live.length} worker(s) actually running. Stop them first: botholomew worker stop <id>`,
|
|
100
|
+
);
|
|
101
|
+
for (const w of live) {
|
|
102
|
+
logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
|
|
103
|
+
}
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
if (stale.length > 0) {
|
|
107
|
+
logger.warn(
|
|
108
|
+
`${stale.length} worker row(s) marked 'running' but PID is dead — proceeding (rows will be carried through repair, then reapable):`,
|
|
109
|
+
);
|
|
110
|
+
for (const w of stale) {
|
|
111
|
+
logger.dim(` ${w.id} (pid ${w.pid}, mode=${w.mode})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
logger.phase("repair", "EXPORT DATABASE → swap files → IMPORT DATABASE");
|
|
116
|
+
const result = await repairDatabase(dbPath);
|
|
117
|
+
logger.success(
|
|
118
|
+
`Repaired in ${result.durationMs}ms. Backup: ${result.backupDbPath}`,
|
|
119
|
+
);
|
|
120
|
+
logger.dim(
|
|
121
|
+
" Re-run `botholomew db doctor` to confirm. Delete the backup once you're sure.",
|
|
122
|
+
);
|
|
123
|
+
}
|
package/src/db/doctor.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
// Discard the child's stderr. When the probe panics, Bun writes a multi-
|
|
89
|
+
// line crash banner there which would otherwise spill into our table
|
|
90
|
+
// output via the fallback message. The exit code alone tells us what we
|
|
91
|
+
// need to know.
|
|
92
|
+
const proc = Bun.spawn(["bun", "-e", script], {
|
|
93
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
94
|
+
});
|
|
95
|
+
const [stdout, exitCode] = await Promise.all([
|
|
96
|
+
new Response(proc.stdout).text(),
|
|
97
|
+
proc.exited,
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
// Bun panic: process killed by SIGTRAP / non-zero exit with no stdout
|
|
101
|
+
// verdict. Treat any unrecognized exit as corruption — better to flag
|
|
102
|
+
// for repair than to silently miss a problem.
|
|
103
|
+
if (stdout.startsWith("OK")) return { table, status: "ok" };
|
|
104
|
+
if (stdout.startsWith("EMPTY")) return { table, status: "empty" };
|
|
105
|
+
if (stdout.startsWith("MISSING:")) {
|
|
106
|
+
return {
|
|
107
|
+
table,
|
|
108
|
+
status: "missing",
|
|
109
|
+
message: firstLine(stdout.slice("MISSING:".length)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (stdout.startsWith("CORRUPT:")) {
|
|
113
|
+
return {
|
|
114
|
+
table,
|
|
115
|
+
status: "corrupt",
|
|
116
|
+
message: firstLine(stdout.slice("CORRUPT:".length)),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
table,
|
|
121
|
+
status: "corrupt",
|
|
122
|
+
message: `child exited with code ${exitCode} (likely native panic)`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run probes for every known table. Sequential rather than parallel so we
|
|
128
|
+
* cooperate with DuckDB's per-process file lock and don't multiply the
|
|
129
|
+
* blast radius of a panic.
|
|
130
|
+
*/
|
|
131
|
+
export async function probeAllTables(dbPath: string): Promise<ProbeResult[]> {
|
|
132
|
+
const results: ProbeResult[] = [];
|
|
133
|
+
for (const { name, pk } of PROBE_TABLES) {
|
|
134
|
+
results.push(await probeTable(dbPath, name, pk));
|
|
135
|
+
}
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface RepairResult {
|
|
140
|
+
backupDbPath: string;
|
|
141
|
+
exportDir: string;
|
|
142
|
+
durationMs: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Repair `dbPath` by exporting its contents and importing into a fresh
|
|
147
|
+
* file. EXPORT DATABASE reads via sequential scans, not via PK indexes,
|
|
148
|
+
* so it survives the kind of index corruption that breaks UPDATE/DELETE.
|
|
149
|
+
* IMPORT DATABASE rebuilds every index from the data, which restores
|
|
150
|
+
* write integrity.
|
|
151
|
+
*
|
|
152
|
+
* Steps:
|
|
153
|
+
* 1. CHECKPOINT (best-effort) to flush WAL.
|
|
154
|
+
* 2. EXPORT DATABASE to `<dotDir>/.export-<timestamp>`.
|
|
155
|
+
* 3. Move `data.duckdb` (and `.wal`) to `data.duckdb.bak-<timestamp>`.
|
|
156
|
+
* 4. Open a fresh DB at the original path and IMPORT DATABASE.
|
|
157
|
+
* 5. Leave the export dir on disk — cheap insurance if step 4 ever fails
|
|
158
|
+
* mid-way; cleanup on the next successful run.
|
|
159
|
+
*
|
|
160
|
+
* The caller is responsible for ensuring no other process holds the DB
|
|
161
|
+
* (no running workers, no chat session, no TUI).
|
|
162
|
+
*/
|
|
163
|
+
export async function repairDatabase(dbPath: string): Promise<RepairResult> {
|
|
164
|
+
const start = Date.now();
|
|
165
|
+
const dotDir = dirname(dbPath);
|
|
166
|
+
await mkdir(dotDir, { recursive: true });
|
|
167
|
+
|
|
168
|
+
const stamp = new Date()
|
|
169
|
+
.toISOString()
|
|
170
|
+
.replace(/[:.]/g, "-")
|
|
171
|
+
.replace(/Z$/, "");
|
|
172
|
+
const exportDir = join(dotDir, `.export-${stamp}`);
|
|
173
|
+
const backupDbPath = `${dbPath}.bak-${stamp}`;
|
|
174
|
+
const walPath = `${dbPath}.wal`;
|
|
175
|
+
const backupWalPath = `${backupDbPath}.wal`;
|
|
176
|
+
|
|
177
|
+
await withDb(dbPath, async (conn) => {
|
|
178
|
+
try {
|
|
179
|
+
await conn.exec("CHECKPOINT");
|
|
180
|
+
} catch {
|
|
181
|
+
// CHECKPOINT can fail on an already-invalidated DB; the EXPORT
|
|
182
|
+
// below is what actually matters.
|
|
183
|
+
}
|
|
184
|
+
await conn.exec(`EXPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await rename(dbPath, backupDbPath);
|
|
188
|
+
if (await pathExists(walPath)) {
|
|
189
|
+
await rename(walPath, backupWalPath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await withDb(dbPath, async (conn) => {
|
|
193
|
+
await conn.exec(`IMPORT DATABASE '${exportDir.replace(/'/g, "''")}'`);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Best-effort cleanup of the export dir. Leave it on failure — the user
|
|
197
|
+
// still has data.duckdb (rebuilt) and the backup.
|
|
198
|
+
try {
|
|
199
|
+
await rm(exportDir, { recursive: true, force: true });
|
|
200
|
+
} catch {
|
|
201
|
+
// ignore
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
backupDbPath,
|
|
206
|
+
exportDir,
|
|
207
|
+
durationMs: Date.now() - start,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function pathExists(p: string): Promise<boolean> {
|
|
212
|
+
try {
|
|
213
|
+
await stat(p);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function firstLine(s: string): string {
|
|
221
|
+
const trimmed = s.trim();
|
|
222
|
+
const nl = trimmed.indexOf("\n");
|
|
223
|
+
return nl === -1 ? trimmed : trimmed.slice(0, nl);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send signal 0 to test whether `pid` corresponds to a live process. Returns
|
|
228
|
+
* false on ESRCH (no such process) and on any other error (including EPERM,
|
|
229
|
+
* which we conservatively treat as "not ours, not relevant"). Used by the
|
|
230
|
+
* doctor's safety gate to distinguish workers actually running from rows
|
|
231
|
+
* that say `status = 'running'` because the worker crashed before flipping
|
|
232
|
+
* its row to `stopped` or `dead`.
|
|
233
|
+
*/
|
|
234
|
+
export function isPidAlive(pid: number): boolean {
|
|
235
|
+
if (!pid || pid < 1) return false;
|
|
236
|
+
try {
|
|
237
|
+
process.kill(pid, 0);
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
package/src/worker/llm.ts
CHANGED
|
@@ -11,12 +11,17 @@ import { getTask, type Task } from "../db/tasks.ts";
|
|
|
11
11
|
import { logInteraction } from "../db/threads.ts";
|
|
12
12
|
import { registerAllTools } from "../tools/registry.ts";
|
|
13
13
|
import { getTool, type ToolContext, toAnthropicTools } from "../tools/tool.ts";
|
|
14
|
+
import { logger } from "../utils/logger.ts";
|
|
14
15
|
import { fitToContextWindow, getMaxInputTokens } from "./context.ts";
|
|
15
16
|
import { clearLargeResults, maybeStoreResult } from "./large-results.ts";
|
|
16
17
|
import { createLlmClient } from "./llm-client.ts";
|
|
17
18
|
|
|
18
19
|
registerAllTools();
|
|
19
20
|
|
|
21
|
+
function truncate(s: string, max: number): string {
|
|
22
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export interface WorkerStreamCallbacks {
|
|
21
26
|
onToken: (text: string) => void;
|
|
22
27
|
onToolStart: (name: string, input: string) => void;
|
|
@@ -153,6 +158,9 @@ export async function runAgentLoop(input: {
|
|
|
153
158
|
tokenCount,
|
|
154
159
|
}),
|
|
155
160
|
);
|
|
161
|
+
if (!callbacks) {
|
|
162
|
+
logger.phase("assistant", block.text);
|
|
163
|
+
}
|
|
156
164
|
}
|
|
157
165
|
}
|
|
158
166
|
|
|
@@ -175,6 +183,12 @@ export async function runAgentLoop(input: {
|
|
|
175
183
|
for (const toolUse of toolUseBlocks) {
|
|
176
184
|
const toolInput = JSON.stringify(toolUse.input);
|
|
177
185
|
callbacks?.onToolStart(toolUse.name, toolInput);
|
|
186
|
+
if (!callbacks) {
|
|
187
|
+
logger.phase(
|
|
188
|
+
"tool-call",
|
|
189
|
+
`${toolUse.name} ${truncate(toolInput, 200)}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
178
192
|
await withDb(dbPath, (conn) =>
|
|
179
193
|
logInteraction(conn, threadId, {
|
|
180
194
|
role: "assistant",
|
|
@@ -222,6 +236,11 @@ export async function runAgentLoop(input: {
|
|
|
222
236
|
durationMs,
|
|
223
237
|
}),
|
|
224
238
|
);
|
|
239
|
+
if (!callbacks) {
|
|
240
|
+
const seconds = (durationMs / 1000).toFixed(1);
|
|
241
|
+
const status = result.isError ? "err" : "ok";
|
|
242
|
+
logger.phase("tool-result", `${toolUse.name} ${status} in ${seconds}s`);
|
|
243
|
+
}
|
|
225
244
|
|
|
226
245
|
if (result.terminal && result.agentResult) {
|
|
227
246
|
return result.agentResult;
|
package/src/worker/tick.ts
CHANGED
|
@@ -133,6 +133,9 @@ async function runClaimedTask(opts: {
|
|
|
133
133
|
const { projectDir, dbPath, config, mcpxClient, callbacks, task } = opts;
|
|
134
134
|
|
|
135
135
|
logger.info(`Claimed task: ${task.name} (${task.id})`);
|
|
136
|
+
if (!callbacks && task.description) {
|
|
137
|
+
logger.dim(task.description);
|
|
138
|
+
}
|
|
136
139
|
callbacks?.onTaskStart(task);
|
|
137
140
|
|
|
138
141
|
const threadId = await withDb(dbPath, (conn) =>
|