botholomew 0.15.5 → 0.16.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 +6 -6
- package/package.json +1 -1
- package/src/chat/agent.ts +1 -1
- package/src/chat/usage.ts +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/prompts.ts +333 -0
- package/src/constants.ts +1 -1
- package/src/context/capabilities.ts +4 -0
- package/src/context/locks.ts +146 -0
- package/src/context/reindex.ts +10 -1
- package/src/context/store.ts +120 -70
- package/src/fs/atomic.ts +28 -4
- package/src/init/index.ts +4 -4
- package/src/init/templates.ts +10 -16
- package/src/tools/file/copy.ts +3 -1
- package/src/tools/file/delete.ts +1 -0
- package/src/tools/file/edit.ts +14 -0
- package/src/tools/file/move.ts +7 -2
- package/src/tools/file/write.ts +1 -1
- package/src/tools/prompt/create.ts +136 -0
- package/src/tools/prompt/delete.ts +103 -0
- package/src/tools/prompt/edit.ts +34 -13
- package/src/tools/prompt/list.ts +109 -0
- package/src/tools/prompt/read.ts +46 -14
- package/src/tools/registry.ts +6 -0
- package/src/tools/tool.ts +9 -0
- package/src/tui/App.tsx +48 -8
- package/src/utils/frontmatter.ts +93 -4
- package/src/worker/heartbeat.ts +20 -0
- package/src/worker/llm.ts +4 -0
- package/src/worker/prompt.ts +29 -23
- package/src/worker/tick.ts +22 -8
package/src/context/store.ts
CHANGED
|
@@ -12,7 +12,12 @@ 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";
|
|
16
21
|
import { applyLinePatches, type LinePatch } from "../fs/patches.ts";
|
|
17
22
|
import {
|
|
18
23
|
getCanonicalRoot,
|
|
@@ -20,6 +25,11 @@ import {
|
|
|
20
25
|
resolveInRoot,
|
|
21
26
|
toRelativePath,
|
|
22
27
|
} from "../fs/sandbox.ts";
|
|
28
|
+
import { withContextLock } from "./locks.ts";
|
|
29
|
+
|
|
30
|
+
function defaultHolderId(): string {
|
|
31
|
+
return `pid:${process.pid}`;
|
|
32
|
+
}
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* Disk-backed replacement for the old DuckDB context_items CRUD layer. All
|
|
@@ -310,7 +320,10 @@ export async function writeContextFile(
|
|
|
310
320
|
projectDir: string,
|
|
311
321
|
path: string,
|
|
312
322
|
content: string,
|
|
313
|
-
opts: {
|
|
323
|
+
opts: {
|
|
324
|
+
onConflict?: "error" | "overwrite";
|
|
325
|
+
holderId?: string;
|
|
326
|
+
} = {},
|
|
314
327
|
): Promise<ContextEntry> {
|
|
315
328
|
const abs = await resolveContext(projectDir, path);
|
|
316
329
|
const normalized = normalizeContextPath(path);
|
|
@@ -321,28 +334,35 @@ export async function writeContextFile(
|
|
|
321
334
|
);
|
|
322
335
|
}
|
|
323
336
|
const conflict = opts.onConflict ?? "overwrite";
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
+
);
|
|
340
360
|
}
|
|
341
361
|
|
|
342
362
|
export async function deleteContextPath(
|
|
343
363
|
projectDir: string,
|
|
344
364
|
path: string,
|
|
345
|
-
opts: { recursive?: boolean } = {},
|
|
365
|
+
opts: { recursive?: boolean; holderId?: string } = {},
|
|
346
366
|
): Promise<{ removed: number; was_directory: boolean; was_symlink: boolean }> {
|
|
347
367
|
const abs = await resolveContext(projectDir, path, {
|
|
348
368
|
allowSymlinkLeaf: true,
|
|
@@ -351,61 +371,80 @@ export async function deleteContextPath(
|
|
|
351
371
|
if (normalized === "") {
|
|
352
372
|
throw new PathEscapeError("refusing to delete the context root", path);
|
|
353
373
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
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
|
+
);
|
|
384
411
|
}
|
|
385
412
|
|
|
386
413
|
export async function moveContextPath(
|
|
387
414
|
projectDir: string,
|
|
388
415
|
src: string,
|
|
389
416
|
dst: string,
|
|
417
|
+
opts: { holderId?: string } = {},
|
|
390
418
|
): Promise<void> {
|
|
391
419
|
const srcAbs = await resolveContext(projectDir, src);
|
|
392
420
|
const dstAbs = await resolveContext(projectDir, dst);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
);
|
|
409
448
|
}
|
|
410
449
|
|
|
411
450
|
export async function copyContextPath(
|
|
@@ -770,15 +809,26 @@ export async function applyPatches(
|
|
|
770
809
|
projectDir: string,
|
|
771
810
|
path: string,
|
|
772
811
|
patches: Patch[],
|
|
812
|
+
opts: { holderId?: string } = {},
|
|
773
813
|
): Promise<{ applied: number; lines: number }> {
|
|
774
|
-
const
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
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 };
|
|
778
827
|
});
|
|
779
|
-
return { applied: patches.length, lines: newContent.split("\n").length };
|
|
780
828
|
}
|
|
781
829
|
|
|
830
|
+
export { MtimeConflictError };
|
|
831
|
+
|
|
782
832
|
/**
|
|
783
833
|
* Convert an absolute filesystem path back to a context-relative path. Used
|
|
784
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();
|
package/src/init/index.ts
CHANGED
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
DEFAULT_CONFIG,
|
|
37
37
|
DEFAULT_MCPX_SERVERS,
|
|
38
38
|
GOALS_MD,
|
|
39
|
-
SOUL_MD,
|
|
40
39
|
STANDUP_SKILL,
|
|
41
40
|
SUMMARIZE_SKILL,
|
|
42
41
|
} from "./templates.ts";
|
|
@@ -74,9 +73,8 @@ export async function initProject(
|
|
|
74
73
|
|
|
75
74
|
// Persistent-context template files
|
|
76
75
|
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
76
|
await Bun.write(join(pcDir, "goals.md"), GOALS_MD);
|
|
77
|
+
await Bun.write(join(pcDir, "beliefs.md"), BELIEFS_MD);
|
|
80
78
|
await Bun.write(join(pcDir, "capabilities.md"), CAPABILITIES_MD);
|
|
81
79
|
|
|
82
80
|
// Default skills
|
|
@@ -117,7 +115,9 @@ export async function initProject(
|
|
|
117
115
|
logger.dim("");
|
|
118
116
|
logger.dim("Layout:");
|
|
119
117
|
logger.dim(` ${CONFIG_DIR}/ settings`);
|
|
120
|
-
logger.dim(
|
|
118
|
+
logger.dim(
|
|
119
|
+
` prompts/ goals, beliefs, capabilities (and any you add)`,
|
|
120
|
+
);
|
|
121
121
|
logger.dim(` ${CONTEXT_DIR}/ agent-writable knowledge tree`);
|
|
122
122
|
logger.dim(` ${TASKS_DIR}/ one markdown file per task`);
|
|
123
123
|
logger.dim(` ${LOCKS_SUBDIR}/ worker claim lockfiles`);
|
package/src/init/templates.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { DEFAULT_CONFIG as SCHEMA_DEFAULT_CONFIG } from "../config/schemas.ts";
|
|
2
2
|
|
|
3
|
-
export const
|
|
3
|
+
export const GOALS_MD = `---
|
|
4
|
+
title: Goals
|
|
4
5
|
loading: always
|
|
5
|
-
agent-modification:
|
|
6
|
+
agent-modification: true
|
|
6
7
|
---
|
|
7
8
|
|
|
8
|
-
#
|
|
9
|
+
# Goals
|
|
9
10
|
|
|
10
11
|
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.
|
|
11
12
|
|
|
12
13
|
You are thoughtful, thorough, and proactive. You work through your task queue methodically, prioritizing appropriately and asking for clarification when needed.
|
|
13
14
|
|
|
14
15
|
You are direct: lead with the answer, skip preambles, disagree when you have reason to, and never flatter.
|
|
16
|
+
|
|
17
|
+
*The list below is the current set of goals for this project. Update it as goals are completed or new ones are added.*
|
|
18
|
+
|
|
19
|
+
- Get set up and ready to help.
|
|
15
20
|
`;
|
|
16
21
|
|
|
17
22
|
export const BELIEFS_MD = `---
|
|
23
|
+
title: Beliefs
|
|
18
24
|
loading: always
|
|
19
25
|
agent-modification: true
|
|
20
26
|
---
|
|
@@ -28,20 +34,8 @@ agent-modification: true
|
|
|
28
34
|
- I should ask for help when I'm stuck rather than guessing.
|
|
29
35
|
`;
|
|
30
36
|
|
|
31
|
-
export const GOALS_MD = `---
|
|
32
|
-
loading: always
|
|
33
|
-
agent-modification: true
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
# Goals
|
|
37
|
-
|
|
38
|
-
*These are the current goals for this project.*
|
|
39
|
-
*Botholomew updates this file as goals are completed or new ones are added.*
|
|
40
|
-
|
|
41
|
-
- Get set up and ready to help.
|
|
42
|
-
`;
|
|
43
|
-
|
|
44
37
|
export const CAPABILITIES_MD = `---
|
|
38
|
+
title: Capabilities
|
|
45
39
|
loading: always
|
|
46
40
|
agent-modification: true
|
|
47
41
|
---
|
package/src/tools/file/copy.ts
CHANGED
|
@@ -33,7 +33,9 @@ export const contextCopyTool = {
|
|
|
33
33
|
execute: async (input, ctx) => {
|
|
34
34
|
try {
|
|
35
35
|
if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
|
|
36
|
-
await deleteContextPath(ctx.projectDir, input.dst
|
|
36
|
+
await deleteContextPath(ctx.projectDir, input.dst, {
|
|
37
|
+
holderId: ctx.workerId,
|
|
38
|
+
});
|
|
37
39
|
}
|
|
38
40
|
await copyContextPath(ctx.projectDir, input.src, input.dst);
|
|
39
41
|
return { src: input.src, dst: input.dst, is_error: false };
|
package/src/tools/file/delete.ts
CHANGED
package/src/tools/file/edit.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import {
|
|
3
3
|
applyPatches,
|
|
4
4
|
IsDirectoryError,
|
|
5
|
+
MtimeConflictError,
|
|
5
6
|
NotFoundError,
|
|
6
7
|
readContextFile,
|
|
7
8
|
} from "../../context/store.ts";
|
|
@@ -19,6 +20,7 @@ const outputSchema = z.object({
|
|
|
19
20
|
is_error: z.boolean(),
|
|
20
21
|
error_type: z.string().optional(),
|
|
21
22
|
message: z.string().optional(),
|
|
23
|
+
next_action_hint: z.string().optional(),
|
|
22
24
|
});
|
|
23
25
|
|
|
24
26
|
export const contextEditTool = {
|
|
@@ -34,6 +36,7 @@ export const contextEditTool = {
|
|
|
34
36
|
ctx.projectDir,
|
|
35
37
|
input.path,
|
|
36
38
|
input.patches,
|
|
39
|
+
{ holderId: ctx.workerId },
|
|
37
40
|
);
|
|
38
41
|
const content = await readContextFile(ctx.projectDir, input.path);
|
|
39
42
|
return { applied, content, is_error: false };
|
|
@@ -56,6 +59,17 @@ export const contextEditTool = {
|
|
|
56
59
|
message: `context/${err.path} is a directory`,
|
|
57
60
|
};
|
|
58
61
|
}
|
|
62
|
+
if (err instanceof MtimeConflictError) {
|
|
63
|
+
return {
|
|
64
|
+
applied: 0,
|
|
65
|
+
content: "",
|
|
66
|
+
is_error: true,
|
|
67
|
+
error_type: "mtime_conflict",
|
|
68
|
+
message: `context/${input.path} was modified concurrently — another writer (or an external editor) changed it between read and write.`,
|
|
69
|
+
next_action_hint:
|
|
70
|
+
"Call context_read to fetch the current content, recompute your patches against the new line numbers, and retry.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
59
73
|
throw err;
|
|
60
74
|
}
|
|
61
75
|
},
|
package/src/tools/file/move.ts
CHANGED
|
@@ -32,9 +32,14 @@ export const contextMoveTool = {
|
|
|
32
32
|
execute: async (input, ctx) => {
|
|
33
33
|
try {
|
|
34
34
|
if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
|
|
35
|
-
await deleteContextPath(ctx.projectDir, input.dst, {
|
|
35
|
+
await deleteContextPath(ctx.projectDir, input.dst, {
|
|
36
|
+
recursive: true,
|
|
37
|
+
holderId: ctx.workerId,
|
|
38
|
+
});
|
|
36
39
|
}
|
|
37
|
-
await moveContextPath(ctx.projectDir, input.src, input.dst
|
|
40
|
+
await moveContextPath(ctx.projectDir, input.src, input.dst, {
|
|
41
|
+
holderId: ctx.workerId,
|
|
42
|
+
});
|
|
38
43
|
return { src: input.src, dst: input.dst, is_error: false };
|
|
39
44
|
} catch (err) {
|
|
40
45
|
if (err instanceof NotFoundError) {
|
package/src/tools/file/write.ts
CHANGED
|
@@ -38,7 +38,7 @@ export const contextWriteTool = {
|
|
|
38
38
|
ctx.projectDir,
|
|
39
39
|
input.path,
|
|
40
40
|
input.content,
|
|
41
|
-
{ onConflict: input.on_conflict ?? "error" },
|
|
41
|
+
{ onConflict: input.on_conflict ?? "error", holderId: ctx.workerId },
|
|
42
42
|
);
|
|
43
43
|
return { path: entry.path, is_error: false };
|
|
44
44
|
} catch (err) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getPromptsDir } from "../../constants.ts";
|
|
5
|
+
import { atomicWrite } from "../../fs/atomic.ts";
|
|
6
|
+
import {
|
|
7
|
+
PromptValidationError,
|
|
8
|
+
parsePromptFile,
|
|
9
|
+
serializePromptFile,
|
|
10
|
+
} from "../../utils/frontmatter.ts";
|
|
11
|
+
import type { ToolDefinition } from "../tool.ts";
|
|
12
|
+
|
|
13
|
+
const inputSchema = z.object({
|
|
14
|
+
name: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.describe(
|
|
18
|
+
"Prompt name without extension (e.g. 'style-notes'). Resolves to prompts/<name>.md.",
|
|
19
|
+
),
|
|
20
|
+
title: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.describe("Human-readable title shown in prompt_list output."),
|
|
24
|
+
loading: z
|
|
25
|
+
.enum(["always", "contextual"])
|
|
26
|
+
.describe(
|
|
27
|
+
"'always' includes the prompt in every system prompt. 'contextual' includes it only when the latest user/task text shares keywords with the body.",
|
|
28
|
+
),
|
|
29
|
+
agent_modification: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.describe(
|
|
32
|
+
"If true, prompt_edit and prompt_delete may modify or remove this file. If false, the file is read-only to the agent.",
|
|
33
|
+
),
|
|
34
|
+
body: z
|
|
35
|
+
.string()
|
|
36
|
+
.describe("Markdown body (everything after the frontmatter)."),
|
|
37
|
+
on_conflict: z
|
|
38
|
+
.enum(["error", "overwrite"])
|
|
39
|
+
.optional()
|
|
40
|
+
.default("error")
|
|
41
|
+
.describe(
|
|
42
|
+
"What to do if a prompt with this name already exists. Defaults to 'error'.",
|
|
43
|
+
),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const outputSchema = z.object({
|
|
47
|
+
name: z.string().nullable(),
|
|
48
|
+
path: z.string().nullable(),
|
|
49
|
+
created: z.boolean(),
|
|
50
|
+
content: z.string(),
|
|
51
|
+
is_error: z.boolean(),
|
|
52
|
+
error_type: z.string().optional(),
|
|
53
|
+
message: z.string().optional(),
|
|
54
|
+
next_action_hint: z.string().optional(),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const VALID_NAME = /^[a-zA-Z0-9._-]+$/;
|
|
58
|
+
|
|
59
|
+
export const promptCreateTool = {
|
|
60
|
+
name: "prompt_create",
|
|
61
|
+
description:
|
|
62
|
+
"[[ bash equivalent command: touch ]] Create a new prompt file under prompts/. Frontmatter (title, loading, agent-modification) is set from the arguments and re-validated before the file is committed. Fails with path_conflict if a prompt with this name exists unless on_conflict='overwrite'.",
|
|
63
|
+
group: "context",
|
|
64
|
+
inputSchema,
|
|
65
|
+
outputSchema,
|
|
66
|
+
execute: async (input, ctx) => {
|
|
67
|
+
if (!VALID_NAME.test(input.name) || input.name.includes("..")) {
|
|
68
|
+
return {
|
|
69
|
+
name: null,
|
|
70
|
+
path: null,
|
|
71
|
+
created: false,
|
|
72
|
+
content: "",
|
|
73
|
+
is_error: true,
|
|
74
|
+
error_type: "invalid_name",
|
|
75
|
+
message: `Invalid prompt name: ${input.name}`,
|
|
76
|
+
next_action_hint:
|
|
77
|
+
"Use [a-zA-Z0-9._-] only — no slashes, no '..', no extension.",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const dir = getPromptsDir(ctx.projectDir);
|
|
82
|
+
const filePath = join(dir, `${input.name}.md`);
|
|
83
|
+
const exists = await Bun.file(filePath).exists();
|
|
84
|
+
if (exists && input.on_conflict !== "overwrite") {
|
|
85
|
+
return {
|
|
86
|
+
name: input.name,
|
|
87
|
+
path: filePath,
|
|
88
|
+
created: false,
|
|
89
|
+
content: "",
|
|
90
|
+
is_error: true,
|
|
91
|
+
error_type: "path_conflict",
|
|
92
|
+
message: `Prompt already exists: prompts/${input.name}.md`,
|
|
93
|
+
next_action_hint:
|
|
94
|
+
"Pass on_conflict='overwrite' to replace, or use prompt_edit for a partial change.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const meta = {
|
|
99
|
+
title: input.title,
|
|
100
|
+
loading: input.loading,
|
|
101
|
+
"agent-modification": input.agent_modification,
|
|
102
|
+
};
|
|
103
|
+
const serialized = serializePromptFile(meta, input.body);
|
|
104
|
+
|
|
105
|
+
// Round-trip validation: refuse to write content that wouldn't load back.
|
|
106
|
+
try {
|
|
107
|
+
parsePromptFile(filePath, serialized);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
name: input.name,
|
|
111
|
+
path: filePath,
|
|
112
|
+
created: false,
|
|
113
|
+
content: serialized,
|
|
114
|
+
is_error: true,
|
|
115
|
+
error_type: "invalid_frontmatter",
|
|
116
|
+
message:
|
|
117
|
+
err instanceof PromptValidationError
|
|
118
|
+
? err.message
|
|
119
|
+
: `Generated content failed validation: ${err instanceof Error ? err.message : String(err)}`,
|
|
120
|
+
next_action_hint:
|
|
121
|
+
"Pick a title without unusual characters that break YAML.",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await mkdir(dir, { recursive: true });
|
|
126
|
+
await atomicWrite(filePath, serialized);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
name: input.name,
|
|
130
|
+
path: filePath,
|
|
131
|
+
created: !exists,
|
|
132
|
+
content: serialized,
|
|
133
|
+
is_error: false,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
} satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
|