botholomew 0.15.4 → 0.15.6
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 +1 -1
- package/src/chat/agent.ts +6 -4
- package/src/context/locks.ts +146 -0
- package/src/context/reindex.ts +10 -1
- package/src/context/store.ts +122 -90
- package/src/fs/atomic.ts +28 -4
- package/src/fs/patches.ts +39 -0
- package/src/schedules/store.ts +11 -5
- package/src/tasks/store.ts +6 -6
- package/src/tools/file/copy.ts +3 -1
- package/src/tools/file/delete.ts +1 -0
- package/src/tools/file/edit.ts +16 -11
- package/src/tools/file/move.ts +7 -2
- package/src/tools/file/write.ts +1 -1
- package/src/tools/prompt/edit.ts +148 -0
- package/src/tools/prompt/read.ts +70 -0
- package/src/tools/registry.ts +9 -4
- package/src/tools/schedule/edit.ts +126 -0
- package/src/tools/skill/edit.ts +3 -33
- package/src/tools/task/edit.ts +214 -0
- package/src/tools/tool.ts +9 -0
- package/src/tui/App.tsx +95 -12
- package/src/worker/heartbeat.ts +20 -0
- package/src/worker/llm.ts +4 -0
- package/src/worker/tick.ts +6 -1
- package/src/tools/context/update-beliefs.ts +0 -64
- package/src/tools/context/update-goals.ts +0 -64
package/package.json
CHANGED
package/src/chat/agent.ts
CHANGED
|
@@ -52,9 +52,11 @@ const CHAT_TOOL_NAMES = new Set([
|
|
|
52
52
|
"view_thread",
|
|
53
53
|
"search_threads",
|
|
54
54
|
"create_schedule",
|
|
55
|
+
"schedule_edit",
|
|
55
56
|
"list_schedules",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
57
|
+
"prompt_read",
|
|
58
|
+
"prompt_edit",
|
|
59
|
+
"task_edit",
|
|
58
60
|
"capabilities_refresh",
|
|
59
61
|
"mcp_list_tools",
|
|
60
62
|
"mcp_search",
|
|
@@ -100,7 +102,7 @@ You do NOT execute long-running work directly — enqueue tasks for a background
|
|
|
100
102
|
Use the available tools to look up tasks, threads, schedules, and context when the user asks about them. Files the agent can read and write live under \`context/\` as project-relative paths (e.g. \`notes/foo.md\`). Use \`context_tree\` to see what's there, \`search\` (hybrid regexp + semantic) to find content, then \`context_read\` / \`context_info\` to drill in.
|
|
101
103
|
Past conversations live in CSV files under \`threads/\`; use \`list_threads\`, \`search_threads\`, and \`view_thread\` to find and page through them.
|
|
102
104
|
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.
|
|
103
|
-
You can
|
|
105
|
+
You can read and edit the agent's prompt files (beliefs, goals, capabilities, soul) under \`prompts/\` via \`prompt_read\` and \`prompt_edit\` (line-range patches). Files marked \`agent-modification: false\` (e.g. soul.md) are read-only.
|
|
104
106
|
You can author and refine slash-command skills (reusable prompt templates stored in \`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.
|
|
105
107
|
Format your responses using Markdown. Use headings, bold, italic, lists, and code blocks to make your responses clear and well-structured.
|
|
106
108
|
`;
|
|
@@ -252,7 +254,7 @@ export async function runChatTurn(input: {
|
|
|
252
254
|
// Rebuild the system prompt every iteration so that:
|
|
253
255
|
// (1) `loading: contextual` files get matched against the latest user
|
|
254
256
|
// message, and
|
|
255
|
-
// (2) any
|
|
257
|
+
// (2) any prompt_edit tool call in the previous
|
|
256
258
|
// iteration is reflected in the next LLM call.
|
|
257
259
|
const keywordSource = findLastUserText(messages);
|
|
258
260
|
const systemPrompt = await buildChatSystemPrompt(projectDir, {
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { CONTEXT_DIR, LOCKS_SUBDIR } from "../constants.ts";
|
|
5
|
+
import {
|
|
6
|
+
acquireLock,
|
|
7
|
+
LockHeldError,
|
|
8
|
+
readLockHolder,
|
|
9
|
+
releaseLock,
|
|
10
|
+
} from "../fs/atomic.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-path mutex for `context/` mutations. Tasks/schedules already serialize
|
|
14
|
+
* their own writes via O_EXCL lockfiles; this gives the same guarantee for
|
|
15
|
+
* `context_write` / `context_edit` / `context_delete` / `context_mv` so two
|
|
16
|
+
* tools (worker + chat, or two workers on the same path) can't race on
|
|
17
|
+
* read-modify-write or rename ordering.
|
|
18
|
+
*
|
|
19
|
+
* Lockfiles live at `<projectDir>/context/.locks/<sha1(path)>.lock`. We hash
|
|
20
|
+
* the path so the lock filename is bounded-length and slash-free, and so a
|
|
21
|
+
* leading-dot path doesn't accidentally collide with `walk()`'s dotfile skip
|
|
22
|
+
* in `src/context/store.ts`. The `.locks/` dir itself is invisible to
|
|
23
|
+
* `context_list` (walk skips dot-prefixed names at every depth).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Retries are exponential-ish with jitter. Total worst-case wait is
|
|
27
|
+
// ~5 seconds — comfortable for a small herd of concurrent writers (the
|
|
28
|
+
// per-path critical section is just a stat + tmp write + rename, on the
|
|
29
|
+
// order of 1-10 ms each), and short enough that a stuck holder surfaces
|
|
30
|
+
// to the caller instead of hanging an LLM tool call indefinitely.
|
|
31
|
+
const ACQUIRE_RETRIES = 32;
|
|
32
|
+
const ACQUIRE_BASE_BACKOFF_MS = 10;
|
|
33
|
+
const ACQUIRE_MAX_BACKOFF_MS = 200;
|
|
34
|
+
|
|
35
|
+
export function getContextLocksDir(projectDir: string): string {
|
|
36
|
+
return join(projectDir, CONTEXT_DIR, LOCKS_SUBDIR);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function contextLockPath(
|
|
40
|
+
projectDir: string,
|
|
41
|
+
normalizedPath: string,
|
|
42
|
+
): string {
|
|
43
|
+
const hash = createHash("sha1").update(normalizedPath).digest("hex");
|
|
44
|
+
return join(getContextLocksDir(projectDir), `${hash}.lock`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run `fn` while holding the per-path context lock. Retries a few times with
|
|
49
|
+
* a small backoff if another caller has the lock — concurrent context tools
|
|
50
|
+
* are expected to converge, not surface "try again" errors to the LLM.
|
|
51
|
+
*
|
|
52
|
+
* `holderId` is stored in the lockfile body so the reaper (and humans
|
|
53
|
+
* inspecting `context/.locks/`) can identify the owner. Pass the worker id
|
|
54
|
+
* when called from a worker; chat sessions pass `"chat:<sessionId>"` or
|
|
55
|
+
* just `"chat"` — anything stable for the duration of the operation.
|
|
56
|
+
*/
|
|
57
|
+
export async function withContextLock<T>(
|
|
58
|
+
projectDir: string,
|
|
59
|
+
normalizedPath: string,
|
|
60
|
+
holderId: string,
|
|
61
|
+
fn: () => Promise<T>,
|
|
62
|
+
): Promise<T> {
|
|
63
|
+
const lockPath = contextLockPath(projectDir, normalizedPath);
|
|
64
|
+
for (let attempt = 0; ; attempt++) {
|
|
65
|
+
try {
|
|
66
|
+
await acquireLock(lockPath, holderId);
|
|
67
|
+
try {
|
|
68
|
+
return await fn();
|
|
69
|
+
} finally {
|
|
70
|
+
await releaseLock(lockPath);
|
|
71
|
+
}
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof LockHeldError && attempt < ACQUIRE_RETRIES) {
|
|
74
|
+
const exp = Math.min(
|
|
75
|
+
ACQUIRE_MAX_BACKOFF_MS,
|
|
76
|
+
ACQUIRE_BASE_BACKOFF_MS * 2 ** attempt,
|
|
77
|
+
);
|
|
78
|
+
const jittered = exp * (0.5 + Math.random());
|
|
79
|
+
await new Promise((res) => setTimeout(res, jittered));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True if `<projectDir>/context/.locks/<sha1(path)>.lock` currently exists.
|
|
89
|
+
* Used by the reindex orphan-prune to skip paths that a worker is mid-write
|
|
90
|
+
* on — without this guard the prune can drop the search-index rows of a
|
|
91
|
+
* file that's about to land on disk.
|
|
92
|
+
*/
|
|
93
|
+
export async function isContextPathLocked(
|
|
94
|
+
projectDir: string,
|
|
95
|
+
normalizedPath: string,
|
|
96
|
+
): Promise<boolean> {
|
|
97
|
+
try {
|
|
98
|
+
await stat(contextLockPath(projectDir, normalizedPath));
|
|
99
|
+
return true;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reaper: walk `context/.locks/`, drop any lockfile whose holder is no
|
|
108
|
+
* longer running per `isHolderAlive`. Mirrors `reapOrphanLocks` in
|
|
109
|
+
* `src/tasks/store.ts` so the worker reaper can clean stale context locks
|
|
110
|
+
* left behind by a crashed worker.
|
|
111
|
+
*
|
|
112
|
+
* `isHolderAlive` receives the raw holder id — the caller decides what
|
|
113
|
+
* counts as alive (typically: workers/<id>.json status === "running").
|
|
114
|
+
* Holders that don't match the worker convention (e.g. `"chat"` from a
|
|
115
|
+
* chat session) are conservatively treated as alive — not our business
|
|
116
|
+
* to expire those.
|
|
117
|
+
*/
|
|
118
|
+
export async function reapOrphanContextLocks(
|
|
119
|
+
projectDir: string,
|
|
120
|
+
isHolderAlive: (holderId: string) => Promise<boolean>,
|
|
121
|
+
): Promise<string[]> {
|
|
122
|
+
const dir = getContextLocksDir(projectDir);
|
|
123
|
+
let names: string[];
|
|
124
|
+
try {
|
|
125
|
+
names = await readdir(dir);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
const released: string[] = [];
|
|
131
|
+
for (const name of names) {
|
|
132
|
+
if (!name.endsWith(".lock")) continue;
|
|
133
|
+
const lockPath = join(dir, name);
|
|
134
|
+
const holder = await readLockHolder(lockPath);
|
|
135
|
+
if (!holder) {
|
|
136
|
+
await releaseLock(lockPath);
|
|
137
|
+
released.push(name);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!(await isHolderAlive(holder))) {
|
|
141
|
+
await releaseLock(lockPath);
|
|
142
|
+
released.push(name);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return released;
|
|
146
|
+
}
|
package/src/context/reindex.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { logger } from "../utils/logger.ts";
|
|
16
16
|
import { chunkByTextSplit } from "./chunker.ts";
|
|
17
17
|
import { embed as defaultEmbed } from "./embedder.ts";
|
|
18
|
+
import { isContextPathLocked } from "./locks.ts";
|
|
18
19
|
import { listContextDir } from "./store.ts";
|
|
19
20
|
|
|
20
21
|
/** Embed function shape — exported for tests that want to inject a fake. */
|
|
@@ -110,8 +111,16 @@ export async function reindexContext(
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
// 4. Anything left in indexedByPath is in the index but not on disk →
|
|
113
|
-
// delete its rows so search results don't surface ghost files.
|
|
114
|
+
// delete its rows so search results don't surface ghost files. Skip
|
|
115
|
+
// paths with an active per-path write lock: a worker may have just
|
|
116
|
+
// written the file *after* our `collectDiskFiles` walk snapshot, and
|
|
117
|
+
// pruning now would drop the index row for a real file. Best-effort —
|
|
118
|
+
// the next reindex will reconcile.
|
|
114
119
|
for (const orphan of indexedByPath.keys()) {
|
|
120
|
+
if (await isContextPathLocked(projectDir, orphan)) {
|
|
121
|
+
logger.debug(`reindex: skipping orphan-prune for in-flight ${orphan}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
115
124
|
await withDb(dbPath, (conn) => deleteIndexedPath(conn, orphan));
|
|
116
125
|
removed++;
|
|
117
126
|
}
|
package/src/context/store.ts
CHANGED
|
@@ -12,13 +12,24 @@ import {
|
|
|
12
12
|
} from "node:fs/promises";
|
|
13
13
|
import { dirname, join, posix, relative, sep } from "node:path";
|
|
14
14
|
import { CONTEXT_DIR, PROTECTED_AREAS } from "../constants.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
atomicWrite,
|
|
17
|
+
atomicWriteIfUnchanged,
|
|
18
|
+
MtimeConflictError,
|
|
19
|
+
readWithMtime,
|
|
20
|
+
} from "../fs/atomic.ts";
|
|
21
|
+
import { applyLinePatches, type LinePatch } from "../fs/patches.ts";
|
|
16
22
|
import {
|
|
17
23
|
getCanonicalRoot,
|
|
18
24
|
PathEscapeError,
|
|
19
25
|
resolveInRoot,
|
|
20
26
|
toRelativePath,
|
|
21
27
|
} from "../fs/sandbox.ts";
|
|
28
|
+
import { withContextLock } from "./locks.ts";
|
|
29
|
+
|
|
30
|
+
function defaultHolderId(): string {
|
|
31
|
+
return `pid:${process.pid}`;
|
|
32
|
+
}
|
|
22
33
|
|
|
23
34
|
/**
|
|
24
35
|
* Disk-backed replacement for the old DuckDB context_items CRUD layer. All
|
|
@@ -59,11 +70,7 @@ export class PathConflictError extends Error {
|
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
|
|
62
|
-
export
|
|
63
|
-
start_line: number;
|
|
64
|
-
end_line: number;
|
|
65
|
-
content: string;
|
|
66
|
-
}
|
|
73
|
+
export type Patch = LinePatch;
|
|
67
74
|
|
|
68
75
|
export interface ContextEntry {
|
|
69
76
|
/** Project-relative path under context/, e.g. "notes/foo.md". Forward-slashes. */
|
|
@@ -313,7 +320,10 @@ export async function writeContextFile(
|
|
|
313
320
|
projectDir: string,
|
|
314
321
|
path: string,
|
|
315
322
|
content: string,
|
|
316
|
-
opts: {
|
|
323
|
+
opts: {
|
|
324
|
+
onConflict?: "error" | "overwrite";
|
|
325
|
+
holderId?: string;
|
|
326
|
+
} = {},
|
|
317
327
|
): Promise<ContextEntry> {
|
|
318
328
|
const abs = await resolveContext(projectDir, path);
|
|
319
329
|
const normalized = normalizeContextPath(path);
|
|
@@ -324,28 +334,35 @@ export async function writeContextFile(
|
|
|
324
334
|
);
|
|
325
335
|
}
|
|
326
336
|
const conflict = opts.onConflict ?? "overwrite";
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
337
|
+
return withContextLock(
|
|
338
|
+
projectDir,
|
|
339
|
+
normalized,
|
|
340
|
+
opts.holderId ?? defaultHolderId(),
|
|
341
|
+
async () => {
|
|
342
|
+
let exists = false;
|
|
343
|
+
try {
|
|
344
|
+
const st = await stat(abs);
|
|
345
|
+
if (st.isDirectory()) throw new IsDirectoryError(normalized);
|
|
346
|
+
exists = true;
|
|
347
|
+
} catch (err) {
|
|
348
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
349
|
+
}
|
|
350
|
+
if (exists && conflict === "error") {
|
|
351
|
+
throw new PathConflictError(normalized);
|
|
352
|
+
}
|
|
353
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
354
|
+
await atomicWrite(abs, content);
|
|
355
|
+
const entry = await getInfo(projectDir, normalized);
|
|
356
|
+
if (!entry) throw new Error(`Wrote ${normalized} but could not stat`);
|
|
357
|
+
return entry;
|
|
358
|
+
},
|
|
359
|
+
);
|
|
343
360
|
}
|
|
344
361
|
|
|
345
362
|
export async function deleteContextPath(
|
|
346
363
|
projectDir: string,
|
|
347
364
|
path: string,
|
|
348
|
-
opts: { recursive?: boolean } = {},
|
|
365
|
+
opts: { recursive?: boolean; holderId?: string } = {},
|
|
349
366
|
): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
|
|
350
367
|
const abs = await resolveContext(projectDir, path, {
|
|
351
368
|
allowSymlinkLeaf: true,
|
|
@@ -354,61 +371,80 @@ export async function deleteContextPath(
|
|
|
354
371
|
if (normalized === "") {
|
|
355
372
|
throw new PathEscapeError("refusing to delete the context root", path);
|
|
356
373
|
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
374
|
+
return withContextLock(
|
|
375
|
+
projectDir,
|
|
376
|
+
normalized,
|
|
377
|
+
opts.holderId ?? defaultHolderId(),
|
|
378
|
+
async () => {
|
|
379
|
+
let lst: Awaited<ReturnType<typeof lstat>>;
|
|
380
|
+
try {
|
|
381
|
+
lst = await lstat(abs);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
384
|
+
throw new NotFoundError(normalized);
|
|
385
|
+
}
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
// A symlink (to a file or a directory, broken or not) is removed with
|
|
389
|
+
// a plain unlink — never follow into the target. This is what enforces
|
|
390
|
+
// "the symlink can be deleted, but not the original content".
|
|
391
|
+
if (lst.isSymbolicLink()) {
|
|
392
|
+
await unlink(abs);
|
|
393
|
+
return { removed: 1, was_directory: false, was_symlink: true };
|
|
394
|
+
}
|
|
395
|
+
if (lst.isDirectory()) {
|
|
396
|
+
if (!opts.recursive) {
|
|
397
|
+
throw new IsDirectoryError(normalized);
|
|
398
|
+
}
|
|
399
|
+
const removedPaths = await collectFiles(abs);
|
|
400
|
+
await rm(abs, { recursive: true, force: false });
|
|
401
|
+
return {
|
|
402
|
+
removed: removedPaths.length,
|
|
403
|
+
was_directory: true,
|
|
404
|
+
was_symlink: false,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
await unlink(abs);
|
|
408
|
+
return { removed: 1, was_directory: false, was_symlink: false };
|
|
409
|
+
},
|
|
410
|
+
);
|
|
387
411
|
}
|
|
388
412
|
|
|
389
413
|
export async function moveContextPath(
|
|
390
414
|
projectDir: string,
|
|
391
415
|
src: string,
|
|
392
416
|
dst: string,
|
|
417
|
+
opts: { holderId?: string } = {},
|
|
393
418
|
): Promise<void> {
|
|
394
419
|
const srcAbs = await resolveContext(projectDir, src);
|
|
395
420
|
const dstAbs = await resolveContext(projectDir, dst);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
421
|
+
const srcNorm = normalizeContextPath(src);
|
|
422
|
+
const dstNorm = normalizeContextPath(dst);
|
|
423
|
+
// Acquire both locks in a stable order to avoid AB/BA deadlocks between
|
|
424
|
+
// concurrent moves that swap two paths. Sorted lexicographically.
|
|
425
|
+
const [firstNorm, secondNorm] =
|
|
426
|
+
srcNorm < dstNorm ? [srcNorm, dstNorm] : [dstNorm, srcNorm];
|
|
427
|
+
const holder = opts.holderId ?? defaultHolderId();
|
|
428
|
+
return withContextLock(projectDir, firstNorm, holder, () =>
|
|
429
|
+
withContextLock(projectDir, secondNorm, holder, async () => {
|
|
430
|
+
try {
|
|
431
|
+
await stat(srcAbs);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
434
|
+
throw new NotFoundError(srcNorm);
|
|
435
|
+
}
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
await stat(dstAbs);
|
|
440
|
+
throw new PathConflictError(dstNorm);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
443
|
+
}
|
|
444
|
+
await mkdir(dirname(dstAbs), { recursive: true });
|
|
445
|
+
await fsRename(srcAbs, dstAbs);
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
412
448
|
}
|
|
413
449
|
|
|
414
450
|
export async function copyContextPath(
|
|
@@ -773,30 +809,26 @@ export async function applyPatches(
|
|
|
773
809
|
projectDir: string,
|
|
774
810
|
path: string,
|
|
775
811
|
patches: Patch[],
|
|
812
|
+
opts: { holderId?: string } = {},
|
|
776
813
|
): Promise<{ applied: number; lines: number }> {
|
|
777
|
-
const
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const newContent = lines.join("\n");
|
|
794
|
-
await writeContextFile(projectDir, path, newContent, {
|
|
795
|
-
onConflict: "overwrite",
|
|
814
|
+
const abs = await resolveContext(projectDir, path);
|
|
815
|
+
const normalized = normalizeContextPath(path);
|
|
816
|
+
const holder = opts.holderId ?? defaultHolderId();
|
|
817
|
+
return withContextLock(projectDir, normalized, holder, async () => {
|
|
818
|
+
const read = await readWithMtime(abs);
|
|
819
|
+
if (!read) throw new NotFoundError(normalized);
|
|
820
|
+
const newContent = applyLinePatches(read.content, patches);
|
|
821
|
+
// The lock keeps other context tools out of this critical section, but
|
|
822
|
+
// an external editor (vim, IDE) can still mutate the file in parallel.
|
|
823
|
+
// The mtime guard catches that — agents and humans don't silently lose
|
|
824
|
+
// edits to each other.
|
|
825
|
+
await atomicWriteIfUnchanged(abs, newContent, read.mtimeMs);
|
|
826
|
+
return { applied: patches.length, lines: newContent.split("\n").length };
|
|
796
827
|
});
|
|
797
|
-
return { applied: patches.length, lines: lines.length };
|
|
798
828
|
}
|
|
799
829
|
|
|
830
|
+
export { MtimeConflictError };
|
|
831
|
+
|
|
800
832
|
/**
|
|
801
833
|
* Convert an absolute filesystem path back to a context-relative path. Used
|
|
802
834
|
* when rendering search hits or worker output that originated in store.ts.
|
package/src/fs/atomic.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { constants as fsConstants } from "node:fs";
|
|
2
3
|
import {
|
|
3
4
|
mkdir,
|
|
@@ -10,6 +11,17 @@ import {
|
|
|
10
11
|
} from "node:fs/promises";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Build a temp suffix that is unique even when two callers in the same
|
|
16
|
+
* process race on the same target in the same millisecond. The 8 random
|
|
17
|
+
* bytes drown out any chance of `pid + Date.now()` collision and let the
|
|
18
|
+
* O_EXCL temp open in atomicWrite act as a real safety net rather than a
|
|
19
|
+
* suggestion.
|
|
20
|
+
*/
|
|
21
|
+
function defaultTempSuffix(): string {
|
|
22
|
+
return `${process.pid}.${Date.now()}.${randomBytes(8).toString("hex")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Write `content` to `targetPath` atomically: write to a sibling temp file,
|
|
15
27
|
* fsync, then rename. The rename is atomic on POSIX same-filesystem; the
|
|
@@ -24,9 +36,17 @@ export async function atomicWrite(
|
|
|
24
36
|
opts: { tempSuffix?: string } = {},
|
|
25
37
|
): Promise<void> {
|
|
26
38
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
27
|
-
const suffix = opts.tempSuffix ??
|
|
39
|
+
const suffix = opts.tempSuffix ?? defaultTempSuffix();
|
|
28
40
|
const tmp = `${targetPath}.tmp.${suffix}`;
|
|
29
|
-
|
|
41
|
+
// O_EXCL surfaces a temp-file collision rather than letting two writers
|
|
42
|
+
// truncate each other's bytes. With the random default suffix this is the
|
|
43
|
+
// belt-and-suspenders guarantee that concurrent writes to the same target
|
|
44
|
+
// never silently lose data on the way to rename().
|
|
45
|
+
const fh = await open(
|
|
46
|
+
tmp,
|
|
47
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
48
|
+
0o644,
|
|
49
|
+
);
|
|
30
50
|
try {
|
|
31
51
|
if (typeof content === "string") {
|
|
32
52
|
await fh.writeFile(content, "utf-8");
|
|
@@ -89,9 +109,13 @@ export async function atomicWriteIfUnchanged(
|
|
|
89
109
|
opts: { tempSuffix?: string } = {},
|
|
90
110
|
): Promise<void> {
|
|
91
111
|
await mkdir(dirname(targetPath), { recursive: true });
|
|
92
|
-
const suffix = opts.tempSuffix ??
|
|
112
|
+
const suffix = opts.tempSuffix ?? defaultTempSuffix();
|
|
93
113
|
const tmp = `${targetPath}.tmp.${suffix}`;
|
|
94
|
-
const fh = await open(
|
|
114
|
+
const fh = await open(
|
|
115
|
+
tmp,
|
|
116
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
117
|
+
0o644,
|
|
118
|
+
);
|
|
95
119
|
try {
|
|
96
120
|
await fh.writeFile(content, "utf-8");
|
|
97
121
|
await fh.sync();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export interface LinePatch {
|
|
4
|
+
start_line: number;
|
|
5
|
+
end_line: number;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const LinePatchSchema = z.object({
|
|
10
|
+
start_line: z.number().describe("1-based inclusive start line"),
|
|
11
|
+
end_line: z
|
|
12
|
+
.number()
|
|
13
|
+
.describe("1-based inclusive end line (0 to insert without replacing)"),
|
|
14
|
+
content: z
|
|
15
|
+
.string()
|
|
16
|
+
.describe("Replacement text (empty string to delete lines)"),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Apply git-style line-range patches to a string. Patches are applied
|
|
21
|
+
* bottom-up so earlier line numbers stay stable. `end_line === 0` is an
|
|
22
|
+
* insert that doesn't replace; an empty `content` deletes.
|
|
23
|
+
*/
|
|
24
|
+
export function applyLinePatches(raw: string, patches: LinePatch[]): string {
|
|
25
|
+
const lines = raw.split("\n");
|
|
26
|
+
const sorted = [...patches].sort((a, b) => b.start_line - a.start_line);
|
|
27
|
+
|
|
28
|
+
for (const patch of sorted) {
|
|
29
|
+
const insertLines = patch.content === "" ? [] : patch.content.split("\n");
|
|
30
|
+
if (patch.end_line === 0) {
|
|
31
|
+
lines.splice(patch.start_line - 1, 0, ...insertLines);
|
|
32
|
+
} else {
|
|
33
|
+
const deleteCount = patch.end_line - patch.start_line + 1;
|
|
34
|
+
lines.splice(patch.start_line - 1, deleteCount, ...insertLines);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
package/src/schedules/store.ts
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
ScheduleFrontmatterSchema,
|
|
20
20
|
} from "./schema.ts";
|
|
21
21
|
|
|
22
|
-
function scheduleFilePath(projectDir: string, id: string): string {
|
|
22
|
+
export function scheduleFilePath(projectDir: string, id: string): string {
|
|
23
23
|
return join(getSchedulesDir(projectDir), `${id}.md`);
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -27,20 +27,26 @@ function scheduleLockPath(projectDir: string, id: string): string {
|
|
|
27
27
|
return join(getSchedulesLockDir(projectDir), `${id}.lock`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function serializeSchedule(
|
|
30
|
+
export function serializeSchedule(
|
|
31
|
+
fm: ScheduleFrontmatter,
|
|
32
|
+
body: string,
|
|
33
|
+
): string {
|
|
31
34
|
return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
interface
|
|
37
|
+
export interface ScheduleParseOk {
|
|
35
38
|
ok: true;
|
|
36
39
|
schedule: Schedule;
|
|
37
40
|
}
|
|
38
|
-
interface
|
|
41
|
+
export interface ScheduleParseFail {
|
|
39
42
|
ok: false;
|
|
40
43
|
reason: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
function parseScheduleFile(
|
|
46
|
+
export function parseScheduleFile(
|
|
47
|
+
raw: string,
|
|
48
|
+
mtimeMs: number,
|
|
49
|
+
): ScheduleParseOk | ScheduleParseFail {
|
|
44
50
|
let parsed: matter.GrayMatterFile<string>;
|
|
45
51
|
try {
|
|
46
52
|
parsed = matter(raw);
|
package/src/tasks/store.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type TaskStatus,
|
|
22
22
|
} from "./schema.ts";
|
|
23
23
|
|
|
24
|
-
function taskFilePath(projectDir: string, id: string): string {
|
|
24
|
+
export function taskFilePath(projectDir: string, id: string): string {
|
|
25
25
|
return join(getTasksDir(projectDir), `${id}.md`);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -33,23 +33,23 @@ function taskLockPath(projectDir: string, id: string): string {
|
|
|
33
33
|
* Render a Task to its on-disk markdown form. Frontmatter contains every
|
|
34
34
|
* field; the body is preserved as-is. Trailing newline keeps line count sane.
|
|
35
35
|
*/
|
|
36
|
-
function serializeTask(fm: TaskFrontmatter, body: string): string {
|
|
36
|
+
export function serializeTask(fm: TaskFrontmatter, body: string): string {
|
|
37
37
|
return matter.stringify(`\n${body.trim()}\n`, fm as Record<string, unknown>);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
interface
|
|
40
|
+
export interface TaskParseOk {
|
|
41
41
|
ok: true;
|
|
42
42
|
task: Task;
|
|
43
43
|
}
|
|
44
|
-
interface
|
|
44
|
+
export interface TaskParseFail {
|
|
45
45
|
ok: false;
|
|
46
46
|
reason: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function parseTaskFile(
|
|
49
|
+
export function parseTaskFile(
|
|
50
50
|
raw: string,
|
|
51
51
|
mtimeMs: number,
|
|
52
|
-
):
|
|
52
|
+
): TaskParseOk | TaskParseFail {
|
|
53
53
|
let parsed: matter.GrayMatterFile<string>;
|
|
54
54
|
try {
|
|
55
55
|
parsed = matter(raw);
|