botholomew 0.12.3 → 0.13.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.
- package/README.md +91 -68
- package/package.json +3 -3
- package/src/chat/agent.ts +42 -82
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +177 -926
- package/src/commands/db.ts +9 -13
- package/src/commands/init.ts +4 -1
- package/src/commands/nuke.ts +57 -90
- package/src/commands/schedule.ts +103 -124
- package/src/commands/skill.ts +2 -2
- package/src/commands/task.ts +86 -95
- package/src/commands/thread.ts +107 -112
- package/src/commands/worker.ts +88 -88
- package/src/constants.ts +93 -16
- package/src/context/capabilities.ts +10 -10
- package/src/context/fetcher.ts +9 -10
- package/src/context/reindex.ts +189 -0
- package/src/context/store.ts +630 -0
- package/src/db/doctor.ts +1 -8
- package/src/db/embeddings.ts +227 -175
- package/src/db/sql/19-disk_backed_index.sql +36 -0
- package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
- package/src/fs/atomic.ts +217 -0
- package/src/fs/compat.ts +86 -0
- package/src/fs/sandbox.ts +279 -0
- package/src/init/index.ts +69 -52
- package/src/init/templates.ts +1 -1
- package/src/mcpx/client.ts +1 -1
- package/src/schedules/schema.ts +19 -0
- package/src/schedules/store.ts +296 -0
- package/src/skills/commands.ts +1 -3
- package/src/tasks/schema.ts +47 -0
- package/src/tasks/store.ts +486 -0
- package/src/threads/store.ts +559 -0
- package/src/tools/capabilities/refresh.ts +42 -21
- package/src/tools/context/pipe.ts +15 -71
- package/src/tools/context/update-beliefs.ts +3 -3
- package/src/tools/context/update-goals.ts +3 -3
- package/src/tools/dir/create.ts +26 -23
- package/src/tools/dir/size.ts +46 -17
- package/src/tools/dir/tree.ts +73 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +44 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +25 -85
- package/src/tools/file/move.ts +39 -24
- package/src/tools/file/read.ts +32 -80
- package/src/tools/file/write.ts +14 -91
- package/src/tools/registry.ts +3 -7
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/list.ts +7 -3
- package/src/tools/search/fuse.ts +12 -33
- package/src/tools/search/index.ts +36 -43
- package/src/tools/search/regexp.ts +29 -17
- package/src/tools/search/semantic.ts +137 -51
- package/src/tools/skill/delete.ts +1 -1
- package/src/tools/skill/list.ts +1 -1
- package/src/tools/skill/write.ts +1 -1
- package/src/tools/task/create.ts +41 -16
- package/src/tools/task/delete.ts +3 -3
- package/src/tools/task/list.ts +6 -3
- package/src/tools/task/update.ts +31 -9
- package/src/tools/task/view.ts +6 -6
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/thread/search.ts +208 -0
- package/src/tools/thread/view.ts +50 -5
- package/src/tools/worker/spawn.ts +28 -14
- package/src/tui/App.tsx +12 -19
- package/src/tui/components/ContextPanel.tsx +83 -316
- package/src/tui/components/SchedulePanel.tsx +34 -48
- package/src/tui/components/StatusBar.tsx +15 -15
- package/src/tui/components/TaskPanel.tsx +34 -38
- package/src/tui/components/ThreadPanel.tsx +29 -38
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- package/src/types/file-imports.d.ts +9 -0
- package/src/utils/title.ts +5 -7
- package/src/utils/v7-date.ts +47 -0
- package/src/worker/heartbeat.ts +46 -24
- package/src/worker/index.ts +13 -15
- package/src/worker/llm.ts +30 -37
- package/src/worker/prompt.ts +19 -41
- package/src/worker/schedules.ts +48 -69
- package/src/worker/spawn.ts +11 -11
- package/src/worker/tick.ts +39 -43
- package/src/workers/store.ts +247 -0
- package/src/commands/tools.ts +0 -367
- package/src/context/describer.ts +0 -140
- package/src/context/drives.ts +0 -110
- package/src/context/ingest.ts +0 -162
- package/src/context/refresh.ts +0 -183
- package/src/db/context.ts +0 -637
- package/src/db/daemon-state.ts +0 -6
- package/src/db/reembed.ts +0 -113
- package/src/db/schedules.ts +0 -213
- package/src/db/tasks.ts +0 -347
- package/src/db/threads.ts +0 -276
- package/src/db/workers.ts +0 -212
- package/src/tools/context/list-drives.ts +0 -36
- package/src/tools/context/refresh.ts +0 -165
- package/src/tools/context/search.ts +0 -54
package/src/init/index.ts
CHANGED
|
@@ -2,14 +2,30 @@ import { mkdir } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadConfig } from "../config/loader.ts";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
CONFIG_DIR,
|
|
6
|
+
CONFIG_FILENAME,
|
|
7
|
+
CONTEXT_DIR,
|
|
8
|
+
getConfigPath,
|
|
6
9
|
getDbPath,
|
|
7
10
|
getMcpxDir,
|
|
11
|
+
getPromptsDir,
|
|
12
|
+
getSchedulesDir,
|
|
13
|
+
getSchedulesLockDir,
|
|
8
14
|
getSkillsDir,
|
|
15
|
+
getTasksDir,
|
|
16
|
+
getTasksLockDir,
|
|
17
|
+
getThreadsDir,
|
|
18
|
+
getWorkersDir,
|
|
19
|
+
LOCKS_SUBDIR,
|
|
20
|
+
LOGS_DIR,
|
|
21
|
+
MCPX_SERVERS_FILENAME,
|
|
22
|
+
SCHEDULES_DIR,
|
|
23
|
+
TASKS_DIR,
|
|
9
24
|
} from "../constants.ts";
|
|
10
25
|
import { writeCapabilitiesFile } from "../context/capabilities.ts";
|
|
11
26
|
import { getConnection } from "../db/connection.ts";
|
|
12
27
|
import { migrate } from "../db/schema.ts";
|
|
28
|
+
import { assertCompatibleFilesystem } from "../fs/compat.ts";
|
|
13
29
|
import { createMcpxClient } from "../mcpx/client.ts";
|
|
14
30
|
import { registerAllTools } from "../tools/registry.ts";
|
|
15
31
|
import { logger } from "../utils/logger.ts";
|
|
@@ -29,56 +45,62 @@ export async function initProject(
|
|
|
29
45
|
projectDir: string,
|
|
30
46
|
opts: { force?: boolean } = {},
|
|
31
47
|
): Promise<void> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
// Refuse to operate inside iCloud/Dropbox/etc unless --force is passed.
|
|
49
|
+
// Sync overlays break atomic rename / O_EXCL semantics that tasks and
|
|
50
|
+
// schedules depend on.
|
|
51
|
+
assertCompatibleFilesystem(projectDir, !!opts.force);
|
|
35
52
|
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
53
|
+
const configPath = getConfigPath(projectDir);
|
|
54
|
+
const alreadyInitialized = await Bun.file(configPath).exists();
|
|
55
|
+
if (alreadyInitialized && !opts.force) {
|
|
39
56
|
throw new Error(
|
|
40
|
-
|
|
57
|
+
`Botholomew project already initialized in ${projectDir} (found ${CONFIG_DIR}/${CONFIG_FILENAME}). Use --force to reinitialize.`,
|
|
41
58
|
);
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
//
|
|
45
|
-
await mkdir(
|
|
46
|
-
await mkdir(
|
|
47
|
-
await mkdir(
|
|
61
|
+
// Top-level directories
|
|
62
|
+
await mkdir(join(projectDir, CONFIG_DIR), { recursive: true });
|
|
63
|
+
await mkdir(getPromptsDir(projectDir), { recursive: true });
|
|
64
|
+
await mkdir(getSkillsDir(projectDir), { recursive: true });
|
|
65
|
+
await mkdir(getMcpxDir(projectDir), { recursive: true });
|
|
66
|
+
await mkdir(join(projectDir, CONTEXT_DIR), { recursive: true });
|
|
67
|
+
await mkdir(getTasksDir(projectDir), { recursive: true });
|
|
68
|
+
await mkdir(getTasksLockDir(projectDir), { recursive: true });
|
|
69
|
+
await mkdir(getSchedulesDir(projectDir), { recursive: true });
|
|
70
|
+
await mkdir(getSchedulesLockDir(projectDir), { recursive: true });
|
|
71
|
+
await mkdir(getWorkersDir(projectDir), { recursive: true });
|
|
72
|
+
await mkdir(getThreadsDir(projectDir), { recursive: true });
|
|
73
|
+
await mkdir(join(projectDir, LOGS_DIR), { recursive: true });
|
|
48
74
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
await Bun.write(join(
|
|
52
|
-
await Bun.write(join(
|
|
53
|
-
await Bun.write(join(
|
|
75
|
+
// Persistent-context template files
|
|
76
|
+
const pcDir = getPromptsDir(projectDir);
|
|
77
|
+
await Bun.write(join(pcDir, "soul.md"), SOUL_MD);
|
|
78
|
+
await Bun.write(join(pcDir, "beliefs.md"), BELIEFS_MD);
|
|
79
|
+
await Bun.write(join(pcDir, "goals.md"), GOALS_MD);
|
|
80
|
+
await Bun.write(join(pcDir, "capabilities.md"), CAPABILITIES_MD);
|
|
54
81
|
|
|
55
|
-
//
|
|
82
|
+
// Default skills
|
|
83
|
+
const skillsDir = getSkillsDir(projectDir);
|
|
56
84
|
await Bun.write(join(skillsDir, "summarize.md"), SUMMARIZE_SKILL);
|
|
57
85
|
await Bun.write(join(skillsDir, "standup.md"), STANDUP_SKILL);
|
|
58
86
|
await Bun.write(join(skillsDir, "capabilities.md"), CAPABILITIES_SKILL);
|
|
59
87
|
|
|
60
|
-
//
|
|
61
|
-
await Bun.write(
|
|
62
|
-
join(dotDir, "config.json"),
|
|
63
|
-
`${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`,
|
|
64
|
-
);
|
|
88
|
+
// Config
|
|
89
|
+
await Bun.write(configPath, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
|
65
90
|
|
|
66
|
-
//
|
|
91
|
+
// mcpx servers config
|
|
67
92
|
await Bun.write(
|
|
68
|
-
join(
|
|
93
|
+
join(getMcpxDir(projectDir), MCPX_SERVERS_FILENAME),
|
|
69
94
|
`${JSON.stringify(DEFAULT_MCPX_SERVERS, null, 2)}\n`,
|
|
70
95
|
);
|
|
71
96
|
|
|
72
|
-
// Initialize database
|
|
97
|
+
// Initialize the index database (search index sidecar; rebuildable).
|
|
73
98
|
const dbPath = getDbPath(projectDir);
|
|
74
99
|
const conn = await getConnection(dbPath);
|
|
75
100
|
await migrate(conn);
|
|
76
101
|
conn.close();
|
|
77
102
|
|
|
78
|
-
// Populate capabilities.md with the real tool inventory.
|
|
79
|
-
// servers.json has no entries on first init, so this lists only the
|
|
80
|
-
// built-in tools; running `botholomew capabilities` later after
|
|
81
|
-
// adding MCPX servers picks those up.
|
|
103
|
+
// Populate capabilities.md with the real tool inventory.
|
|
82
104
|
registerAllTools();
|
|
83
105
|
const config = await loadConfig(projectDir);
|
|
84
106
|
const mcpxClient = await createMcpxClient(projectDir);
|
|
@@ -88,33 +110,28 @@ export async function initProject(
|
|
|
88
110
|
await mcpxClient?.close();
|
|
89
111
|
}
|
|
90
112
|
|
|
91
|
-
// Update .gitignore
|
|
92
|
-
await updateGitignore(projectDir);
|
|
93
|
-
|
|
94
113
|
logger.success("Initialized Botholomew project");
|
|
95
|
-
logger.dim(`
|
|
96
|
-
logger.dim(`
|
|
114
|
+
logger.dim(` Project root: ${projectDir}`);
|
|
115
|
+
logger.dim(` Config: ${CONFIG_DIR}/${CONFIG_FILENAME}`);
|
|
116
|
+
logger.dim(` Index DB: ${dbPath}`);
|
|
117
|
+
logger.dim("");
|
|
118
|
+
logger.dim("Layout:");
|
|
119
|
+
logger.dim(` ${CONFIG_DIR}/ settings`);
|
|
120
|
+
logger.dim(` prompts/ soul, beliefs, goals, capabilities`);
|
|
121
|
+
logger.dim(` ${CONTEXT_DIR}/ agent-writable knowledge tree`);
|
|
122
|
+
logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
|
|
123
|
+
logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
|
|
124
|
+
logger.dim(` ${SCHEDULES_DIR}/ one markdown file per schedule`);
|
|
125
|
+
logger.dim(` threads/ one CSV per conversation, by UTC date`);
|
|
126
|
+
logger.dim(` workers/ one JSON pidfile per worker (heartbeats)`);
|
|
127
|
+
logger.dim(` skills/, mcpx/, models/, logs/`);
|
|
97
128
|
logger.dim("");
|
|
98
129
|
logger.dim("Next steps:");
|
|
99
|
-
logger.dim(
|
|
130
|
+
logger.dim(
|
|
131
|
+
` 1. Set ANTHROPIC_API_KEY or add it to ${CONFIG_DIR}/${CONFIG_FILENAME}`,
|
|
132
|
+
);
|
|
100
133
|
logger.dim(" 2. Run 'botholomew task add' to create your first task");
|
|
101
134
|
logger.dim(
|
|
102
135
|
" 3. Run 'botholomew worker start --persist' to start a background worker",
|
|
103
136
|
);
|
|
104
137
|
}
|
|
105
|
-
|
|
106
|
-
async function updateGitignore(projectDir: string): Promise<void> {
|
|
107
|
-
const gitignorePath = join(projectDir, ".gitignore");
|
|
108
|
-
const file = Bun.file(gitignorePath);
|
|
109
|
-
|
|
110
|
-
let content = "";
|
|
111
|
-
if (await file.exists()) {
|
|
112
|
-
content = await file.text();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const entry = ".botholomew/";
|
|
116
|
-
if (content.includes(entry)) return;
|
|
117
|
-
|
|
118
|
-
const section = `\n# Botholomew (auto-generated)\n${entry}\n`;
|
|
119
|
-
await Bun.write(gitignorePath, `${content.trimEnd()}\n${section}`);
|
|
120
|
-
}
|
package/src/init/templates.ts
CHANGED
|
@@ -60,7 +60,7 @@ description: "Refresh capabilities.md — rescan internal and MCPX tools"
|
|
|
60
60
|
arguments: []
|
|
61
61
|
---
|
|
62
62
|
|
|
63
|
-
Call \`capabilities_refresh\` to rescan every available tool (built-in and MCPX) and rewrite
|
|
63
|
+
Call \`capabilities_refresh\` to rescan every available tool (built-in and MCPX) and rewrite \`prompts/capabilities.md\`. After it finishes, give me a one-line summary of the counts.
|
|
64
64
|
`;
|
|
65
65
|
|
|
66
66
|
export const SUMMARIZE_SKILL = `---
|
package/src/mcpx/client.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type CallToolResult, McpxClient } from "@evantahler/mcpx";
|
|
|
4
4
|
import { getMcpxDir, MCPX_SERVERS_FILENAME } from "../constants.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Create an McpxClient from the project's
|
|
7
|
+
* Create an McpxClient from the project's mcpx/servers.json.
|
|
8
8
|
* Returns null if the file is missing or has no servers configured.
|
|
9
9
|
*/
|
|
10
10
|
export async function createMcpxClient(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const ScheduleFrontmatterSchema = z.object({
|
|
4
|
+
id: z.string().min(1),
|
|
5
|
+
name: z.string(),
|
|
6
|
+
description: z.string().default(""),
|
|
7
|
+
frequency: z.string(),
|
|
8
|
+
enabled: z.boolean().default(true),
|
|
9
|
+
last_run_at: z.string().nullable().default(null),
|
|
10
|
+
created_at: z.string(),
|
|
11
|
+
updated_at: z.string(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type ScheduleFrontmatter = z.infer<typeof ScheduleFrontmatterSchema>;
|
|
15
|
+
|
|
16
|
+
export interface Schedule extends ScheduleFrontmatter {
|
|
17
|
+
mtimeMs: number;
|
|
18
|
+
body: string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { readdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { getSchedulesDir, getSchedulesLockDir } from "../constants.ts";
|
|
5
|
+
import { uuidv7 } from "../db/uuid.ts";
|
|
6
|
+
import {
|
|
7
|
+
acquireLock,
|
|
8
|
+
atomicWrite,
|
|
9
|
+
atomicWriteIfUnchanged,
|
|
10
|
+
LockHeldError,
|
|
11
|
+
readLockHolder,
|
|
12
|
+
readWithMtime,
|
|
13
|
+
releaseLock,
|
|
14
|
+
} from "../fs/atomic.ts";
|
|
15
|
+
import { logger } from "../utils/logger.ts";
|
|
16
|
+
import {
|
|
17
|
+
type Schedule,
|
|
18
|
+
type ScheduleFrontmatter,
|
|
19
|
+
ScheduleFrontmatterSchema,
|
|
20
|
+
} from "./schema.ts";
|
|
21
|
+
|
|
22
|
+
function scheduleFilePath(projectDir: string, id: string): string {
|
|
23
|
+
return join(getSchedulesDir(projectDir), `${id}.md`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scheduleLockPath(projectDir: string, id: string): string {
|
|
27
|
+
return join(getSchedulesLockDir(projectDir), `${id}.lock`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function serializeSchedule(fm: ScheduleFrontmatter, body: string): string {
|
|
31
|
+
return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ParseOk {
|
|
35
|
+
ok: true;
|
|
36
|
+
schedule: Schedule;
|
|
37
|
+
}
|
|
38
|
+
interface ParseFail {
|
|
39
|
+
ok: false;
|
|
40
|
+
reason: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseScheduleFile(raw: string, mtimeMs: number): ParseOk | ParseFail {
|
|
44
|
+
let parsed: matter.GrayMatterFile<string>;
|
|
45
|
+
try {
|
|
46
|
+
parsed = matter(raw);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { ok: false, reason: `frontmatter parse error: ${err}` };
|
|
49
|
+
}
|
|
50
|
+
const result = ScheduleFrontmatterSchema.safeParse(parsed.data);
|
|
51
|
+
if (!result.success) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: `frontmatter validation failed: ${result.error.message}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
schedule: {
|
|
60
|
+
...result.data,
|
|
61
|
+
mtimeMs,
|
|
62
|
+
body: parsed.content.trim(),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listScheduleFiles(projectDir: string): Promise<string[]> {
|
|
68
|
+
const dir = getSchedulesDir(projectDir);
|
|
69
|
+
try {
|
|
70
|
+
const names = await readdir(dir);
|
|
71
|
+
return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -3));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function getSchedule(
|
|
79
|
+
projectDir: string,
|
|
80
|
+
id: string,
|
|
81
|
+
): Promise<Schedule | null> {
|
|
82
|
+
const file = await readWithMtime(scheduleFilePath(projectDir, id));
|
|
83
|
+
if (!file) return null;
|
|
84
|
+
const parsed = parseScheduleFile(file.content, file.mtimeMs);
|
|
85
|
+
if (!parsed.ok) {
|
|
86
|
+
logger.warn(`Schedule ${id} is malformed: ${parsed.reason}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return parsed.schedule;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function listSchedules(
|
|
93
|
+
projectDir: string,
|
|
94
|
+
filters?: { enabled?: boolean; limit?: number; offset?: number },
|
|
95
|
+
): Promise<Schedule[]> {
|
|
96
|
+
const ids = await listScheduleFiles(projectDir);
|
|
97
|
+
const out: Schedule[] = [];
|
|
98
|
+
for (const id of ids) {
|
|
99
|
+
const s = await getSchedule(projectDir, id);
|
|
100
|
+
if (!s) continue;
|
|
101
|
+
if (filters?.enabled !== undefined && s.enabled !== filters.enabled)
|
|
102
|
+
continue;
|
|
103
|
+
out.push(s);
|
|
104
|
+
}
|
|
105
|
+
out.sort((a, b) => (a.created_at < b.created_at ? -1 : 1));
|
|
106
|
+
const offset = filters?.offset ?? 0;
|
|
107
|
+
const limit = filters?.limit ?? out.length;
|
|
108
|
+
return out.slice(offset, offset + limit);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function createSchedule(
|
|
112
|
+
projectDir: string,
|
|
113
|
+
params: {
|
|
114
|
+
name: string;
|
|
115
|
+
description?: string;
|
|
116
|
+
frequency: string;
|
|
117
|
+
enabled?: boolean;
|
|
118
|
+
},
|
|
119
|
+
): Promise<Schedule> {
|
|
120
|
+
const id = uuidv7();
|
|
121
|
+
const now = new Date().toISOString();
|
|
122
|
+
const fm: ScheduleFrontmatter = {
|
|
123
|
+
id,
|
|
124
|
+
name: params.name,
|
|
125
|
+
description: params.description ?? "",
|
|
126
|
+
frequency: params.frequency,
|
|
127
|
+
enabled: params.enabled ?? true,
|
|
128
|
+
last_run_at: null,
|
|
129
|
+
created_at: now,
|
|
130
|
+
updated_at: now,
|
|
131
|
+
};
|
|
132
|
+
await atomicWrite(
|
|
133
|
+
scheduleFilePath(projectDir, id),
|
|
134
|
+
serializeSchedule(fm, params.description ?? ""),
|
|
135
|
+
);
|
|
136
|
+
const fresh = await getSchedule(projectDir, id);
|
|
137
|
+
if (!fresh) throw new Error(`Failed to read freshly created schedule ${id}`);
|
|
138
|
+
return fresh;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function updateSchedule(
|
|
142
|
+
projectDir: string,
|
|
143
|
+
id: string,
|
|
144
|
+
updates: Partial<
|
|
145
|
+
Pick<ScheduleFrontmatter, "name" | "description" | "frequency" | "enabled">
|
|
146
|
+
>,
|
|
147
|
+
): Promise<Schedule | null> {
|
|
148
|
+
const s = await getSchedule(projectDir, id);
|
|
149
|
+
if (!s) return null;
|
|
150
|
+
// Drop undefined keys so an omitted field doesn't overwrite the on-disk
|
|
151
|
+
// value with `undefined` (YAML can't serialize undefined).
|
|
152
|
+
const definedUpdates = Object.fromEntries(
|
|
153
|
+
Object.entries(updates).filter(([, v]) => v !== undefined),
|
|
154
|
+
);
|
|
155
|
+
const fm: ScheduleFrontmatter = {
|
|
156
|
+
...s,
|
|
157
|
+
...definedUpdates,
|
|
158
|
+
updated_at: new Date().toISOString(),
|
|
159
|
+
};
|
|
160
|
+
await atomicWriteIfUnchanged(
|
|
161
|
+
scheduleFilePath(projectDir, id),
|
|
162
|
+
serializeSchedule(fm, s.body),
|
|
163
|
+
s.mtimeMs,
|
|
164
|
+
);
|
|
165
|
+
return getSchedule(projectDir, id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function deleteSchedule(
|
|
169
|
+
projectDir: string,
|
|
170
|
+
id: string,
|
|
171
|
+
): Promise<boolean> {
|
|
172
|
+
try {
|
|
173
|
+
await unlink(scheduleFilePath(projectDir, id));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
await releaseLock(scheduleLockPath(projectDir, id));
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function deleteAllSchedules(projectDir: string): Promise<number> {
|
|
183
|
+
const ids = await listScheduleFiles(projectDir);
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (const id of ids) {
|
|
186
|
+
if (await deleteSchedule(projectDir, id)) n++;
|
|
187
|
+
}
|
|
188
|
+
return n;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ClaimOptions {
|
|
192
|
+
/** Minimum gap (seconds) between schedule runs; protects against double-fire. */
|
|
193
|
+
minIntervalSeconds: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Acquire a schedule's lockfile, verify min-interval, and call `fn` with the
|
|
198
|
+
* locked Schedule. The caller mutates last_run_at via `markScheduleRun`
|
|
199
|
+
* before the lock is dropped. If another worker holds the lock or the
|
|
200
|
+
* schedule ran too recently, returns null without calling `fn`.
|
|
201
|
+
*
|
|
202
|
+
* Important: enabled and last_run_at are checked BOTH before and after lock
|
|
203
|
+
* acquisition. The pre-lock read is a cheap fast-fail; the post-lock read
|
|
204
|
+
* is what `fn` actually receives, so a schedule that gets disabled, deleted,
|
|
205
|
+
* or just-fired between the cheap-check and the lock acquisition does not
|
|
206
|
+
* leak through.
|
|
207
|
+
*/
|
|
208
|
+
export async function withScheduleLock<T>(
|
|
209
|
+
projectDir: string,
|
|
210
|
+
id: string,
|
|
211
|
+
workerId: string,
|
|
212
|
+
opts: ClaimOptions,
|
|
213
|
+
fn: (s: Schedule) => Promise<T>,
|
|
214
|
+
): Promise<T | null> {
|
|
215
|
+
// Pre-lock fast path: skip work if obviously not eligible.
|
|
216
|
+
const pre = await getSchedule(projectDir, id);
|
|
217
|
+
if (!pre?.enabled) return null;
|
|
218
|
+
if (pre.last_run_at) {
|
|
219
|
+
const last = Date.parse(pre.last_run_at);
|
|
220
|
+
if (Date.now() - last < opts.minIntervalSeconds * 1000) return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const lockPath = scheduleLockPath(projectDir, id);
|
|
224
|
+
try {
|
|
225
|
+
await acquireLock(lockPath, workerId);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (err instanceof LockHeldError) return null;
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
// Re-read under the lock. The schedule may have been disabled, deleted,
|
|
232
|
+
// or fired by another worker between the pre-check and the lock.
|
|
233
|
+
const fresh = await getSchedule(projectDir, id);
|
|
234
|
+
if (!fresh?.enabled) return null;
|
|
235
|
+
if (fresh.last_run_at) {
|
|
236
|
+
const last = Date.parse(fresh.last_run_at);
|
|
237
|
+
if (Date.now() - last < opts.minIntervalSeconds * 1000) return null;
|
|
238
|
+
}
|
|
239
|
+
return await fn(fresh);
|
|
240
|
+
} finally {
|
|
241
|
+
await releaseLock(lockPath);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Update last_run_at on a schedule. Uses atomic-write-if-unchanged so a
|
|
247
|
+
* concurrent edit aborts the run instead of clobbering it.
|
|
248
|
+
*/
|
|
249
|
+
export async function markScheduleRun(
|
|
250
|
+
projectDir: string,
|
|
251
|
+
id: string,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const s = await getSchedule(projectDir, id);
|
|
254
|
+
if (!s) return;
|
|
255
|
+
const fm: ScheduleFrontmatter = {
|
|
256
|
+
...s,
|
|
257
|
+
last_run_at: new Date().toISOString(),
|
|
258
|
+
updated_at: new Date().toISOString(),
|
|
259
|
+
};
|
|
260
|
+
await atomicWriteIfUnchanged(
|
|
261
|
+
scheduleFilePath(projectDir, id),
|
|
262
|
+
serializeSchedule(fm, s.body),
|
|
263
|
+
s.mtimeMs,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function reapOrphanScheduleLocks(
|
|
268
|
+
projectDir: string,
|
|
269
|
+
isWorkerAlive: (workerId: string) => Promise<boolean>,
|
|
270
|
+
): Promise<string[]> {
|
|
271
|
+
const dir = getSchedulesLockDir(projectDir);
|
|
272
|
+
let names: string[];
|
|
273
|
+
try {
|
|
274
|
+
names = await readdir(dir);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
const released: string[] = [];
|
|
280
|
+
for (const name of names) {
|
|
281
|
+
if (!name.endsWith(".lock")) continue;
|
|
282
|
+
const id = name.slice(0, -".lock".length);
|
|
283
|
+
const lockPath = join(dir, name);
|
|
284
|
+
const holder = await readLockHolder(lockPath);
|
|
285
|
+
if (!holder) {
|
|
286
|
+
await releaseLock(lockPath);
|
|
287
|
+
released.push(id);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!(await isWorkerAlive(holder))) {
|
|
291
|
+
await releaseLock(lockPath);
|
|
292
|
+
released.push(id);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return released;
|
|
296
|
+
}
|
package/src/skills/commands.ts
CHANGED
|
@@ -122,9 +122,7 @@ export function handleSlashCommand(
|
|
|
122
122
|
|
|
123
123
|
if (name === "skills") {
|
|
124
124
|
if (ctx.skills.size === 0) {
|
|
125
|
-
ctx.addSystemMessage(
|
|
126
|
-
"No skills loaded. Add .md files to .botholomew/skills/",
|
|
127
|
-
);
|
|
125
|
+
ctx.addSystemMessage("No skills loaded. Add .md files to skills/");
|
|
128
126
|
} else {
|
|
129
127
|
const lines = ["Available skills:"];
|
|
130
128
|
for (const [skillName, skill] of ctx.skills) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const TASK_PRIORITIES = ["low", "medium", "high"] as const;
|
|
4
|
+
export const TASK_STATUSES = [
|
|
5
|
+
"pending",
|
|
6
|
+
"in_progress",
|
|
7
|
+
"failed",
|
|
8
|
+
"complete",
|
|
9
|
+
"waiting",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type TaskPriority = (typeof TASK_PRIORITIES)[number];
|
|
13
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Frontmatter validator for `tasks/<id>.md`. Strict so a hand-edited or stale
|
|
17
|
+
* file doesn't silently round-trip with bad data; a parse failure quarantines
|
|
18
|
+
* the file (skip claim, log) per the doctor policy.
|
|
19
|
+
*/
|
|
20
|
+
export const TaskFrontmatterSchema = z.object({
|
|
21
|
+
id: z.string().min(1),
|
|
22
|
+
name: z.string(),
|
|
23
|
+
description: z.string().default(""),
|
|
24
|
+
priority: z.enum(TASK_PRIORITIES).default("medium"),
|
|
25
|
+
status: z.enum(TASK_STATUSES).default("pending"),
|
|
26
|
+
blocked_by: z.array(z.string()).default([]),
|
|
27
|
+
context_paths: z.array(z.string()).default([]),
|
|
28
|
+
output: z.string().nullable().default(null),
|
|
29
|
+
waiting_reason: z.string().nullable().default(null),
|
|
30
|
+
claimed_by: z.string().nullable().default(null),
|
|
31
|
+
claimed_at: z.string().nullable().default(null),
|
|
32
|
+
created_at: z.string(),
|
|
33
|
+
updated_at: z.string(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type TaskFrontmatter = z.infer<typeof TaskFrontmatterSchema>;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* In-memory task representation: frontmatter parsed + filesystem mtime so
|
|
40
|
+
* callers can detect concurrent edits before committing a write.
|
|
41
|
+
*/
|
|
42
|
+
export interface Task extends TaskFrontmatter {
|
|
43
|
+
/** Filesystem mtime in epoch ms, used for atomic-write-if-unchanged. */
|
|
44
|
+
mtimeMs: number;
|
|
45
|
+
/** Markdown body (everything after the frontmatter). */
|
|
46
|
+
body: string;
|
|
47
|
+
}
|