botholomew 0.12.5 → 0.14.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.
Files changed (107) hide show
  1. package/README.md +91 -68
  2. package/package.json +2 -2
  3. package/src/chat/agent.ts +59 -86
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +178 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +803 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +293 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +74 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +53 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +27 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +8 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/tool.ts +5 -0
  71. package/src/tools/util/sleep.ts +77 -0
  72. package/src/tools/worker/spawn.ts +28 -14
  73. package/src/tui/App.tsx +12 -19
  74. package/src/tui/components/ContextPanel.tsx +83 -316
  75. package/src/tui/components/SchedulePanel.tsx +34 -48
  76. package/src/tui/components/SleepProgress.tsx +70 -0
  77. package/src/tui/components/StatusBar.tsx +15 -15
  78. package/src/tui/components/TaskPanel.tsx +34 -38
  79. package/src/tui/components/ThreadPanel.tsx +29 -38
  80. package/src/tui/components/ToolCall.tsx +10 -0
  81. package/src/tui/components/WorkerPanel.tsx +21 -19
  82. package/src/tui/markdown.ts +2 -8
  83. package/src/utils/title.ts +5 -7
  84. package/src/utils/v7-date.ts +47 -0
  85. package/src/worker/heartbeat.ts +46 -24
  86. package/src/worker/index.ts +13 -15
  87. package/src/worker/llm.ts +30 -37
  88. package/src/worker/prompt.ts +19 -41
  89. package/src/worker/schedules.ts +48 -69
  90. package/src/worker/spawn.ts +11 -11
  91. package/src/worker/tick.ts +39 -43
  92. package/src/workers/store.ts +247 -0
  93. package/src/commands/tools.ts +0 -367
  94. package/src/context/describer.ts +0 -140
  95. package/src/context/drives.ts +0 -110
  96. package/src/context/ingest.ts +0 -162
  97. package/src/context/refresh.ts +0 -183
  98. package/src/db/context.ts +0 -637
  99. package/src/db/daemon-state.ts +0 -6
  100. package/src/db/reembed.ts +0 -113
  101. package/src/db/schedules.ts +0 -213
  102. package/src/db/tasks.ts +0 -347
  103. package/src/db/threads.ts +0 -276
  104. package/src/db/workers.ts +0 -212
  105. package/src/tools/context/list-drives.ts +0 -36
  106. package/src/tools/context/refresh.ts +0 -165
  107. package/src/tools/context/search.ts +0 -54
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
- import { ingestByPath } from "../../context/ingest.ts";
3
- import { applyPatchesToContextItem } from "../../db/context.ts";
2
+ import {
3
+ applyPatches,
4
+ IsDirectoryError,
5
+ NotFoundError,
6
+ readContextFile,
7
+ } from "../../context/store.ts";
4
8
  import type { ToolDefinition } from "../tool.ts";
5
9
 
6
10
  const PatchSchema = z.object({
@@ -14,8 +18,7 @@ const PatchSchema = z.object({
14
18
  });
15
19
 
16
20
  const inputSchema = z.object({
17
- drive: z.string().describe("Drive name (e.g. 'agent', 'disk')"),
18
- path: z.string().describe("Path within the drive (starts with /)"),
21
+ path: z.string().describe("Project-relative path under context/"),
19
22
  patches: z.array(PatchSchema).describe("Patches to apply"),
20
23
  });
21
24
 
@@ -23,24 +26,46 @@ const outputSchema = z.object({
23
26
  applied: z.number(),
24
27
  content: z.string(),
25
28
  is_error: z.boolean(),
29
+ error_type: z.string().optional(),
30
+ message: z.string().optional(),
26
31
  });
27
32
 
28
33
  export const contextEditTool = {
29
34
  name: "context_edit",
30
35
  description:
31
- "[[ bash equivalent command: patch ]] Apply git-style patches to a context item. Each patch specifies a line range to replace.",
36
+ "[[ bash equivalent command: patch ]] Apply line-range patches to a file under context/. Each patch specifies start_line/end_line/content. Edits that traverse a user symlink fail with PathEscapeError — delete the symlink first or copy the content to a real path.",
32
37
  group: "context",
33
38
  inputSchema,
34
39
  outputSchema,
35
40
  execute: async (input, ctx) => {
36
- const target = { drive: input.drive, path: input.path };
37
- const { item, applied } = await applyPatchesToContextItem(
38
- ctx.conn,
39
- target,
40
- input.patches,
41
- );
42
-
43
- await ingestByPath(ctx.conn, target, ctx.config);
44
- return { applied, content: item.content ?? "", is_error: false };
41
+ try {
42
+ const { applied } = await applyPatches(
43
+ ctx.projectDir,
44
+ input.path,
45
+ input.patches,
46
+ );
47
+ const content = await readContextFile(ctx.projectDir, input.path);
48
+ return { applied, content, is_error: false };
49
+ } catch (err) {
50
+ if (err instanceof NotFoundError) {
51
+ return {
52
+ applied: 0,
53
+ content: "",
54
+ is_error: true,
55
+ error_type: "not_found",
56
+ message: `No file at context/${err.path}`,
57
+ };
58
+ }
59
+ if (err instanceof IsDirectoryError) {
60
+ return {
61
+ applied: 0,
62
+ content: "",
63
+ is_error: true,
64
+ error_type: "is_directory",
65
+ message: `context/${err.path} is a directory`,
66
+ };
67
+ }
68
+ throw err;
69
+ }
45
70
  },
46
71
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,17 +1,10 @@
1
1
  import { z } from "zod";
2
- import { parseDriveRef } from "../../context/drives.ts";
3
- import { getContextItem, getContextItemById } from "../../db/context.ts";
4
- import { isUuid } from "../../db/uuid.ts";
2
+ import { fileExists } from "../../context/store.ts";
3
+ import { PathEscapeError } from "../../fs/sandbox.ts";
5
4
  import type { ToolDefinition } from "../tool.ts";
6
5
 
7
6
  const inputSchema = z.object({
8
- drive: z
9
- .string()
10
- .optional()
11
- .describe("Drive name. Optional when `path` is a UUID or 'drive:/path'."),
12
- path: z
13
- .string()
14
- .describe("Path within the drive (or UUID / drive:/path ref)"),
7
+ path: z.string().describe("Path under context/"),
15
8
  });
16
9
 
17
10
  const outputSchema = z.object({
@@ -22,26 +15,19 @@ const outputSchema = z.object({
22
15
  export const contextExistsTool = {
23
16
  name: "context_exists",
24
17
  description:
25
- "[[ bash equivalent command: test -e ]] Check if a context item exists.",
18
+ "[[ bash equivalent command: test -e ]] Check whether a path exists under context/.",
26
19
  group: "context",
27
20
  inputSchema,
28
21
  outputSchema,
29
22
  execute: async (input, ctx) => {
30
- if (isUuid(input.path)) {
31
- const item = await getContextItemById(ctx.conn, input.path);
32
- return { exists: item !== null, is_error: false };
23
+ try {
24
+ const exists = await fileExists(ctx.projectDir, input.path);
25
+ return { exists, is_error: false };
26
+ } catch (err) {
27
+ if (err instanceof PathEscapeError) {
28
+ return { exists: false, is_error: false };
29
+ }
30
+ throw err;
33
31
  }
34
- const parsed = parseDriveRef(input.path);
35
- if (parsed) {
36
- const item = await getContextItem(ctx.conn, parsed);
37
- return { exists: item !== null, is_error: false };
38
- }
39
- if (!input.drive) return { exists: false, is_error: false };
40
- const path = input.path.startsWith("/") ? input.path : `/${input.path}`;
41
- const item = await getContextItem(ctx.conn, {
42
- drive: input.drive,
43
- path,
44
- });
45
- return { exists: item !== null, is_error: false };
46
32
  },
47
33
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,41 +1,21 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef, parseDriveRef } from "../../context/drives.ts";
3
- import {
4
- findNearbyContextPaths,
5
- getContextItem,
6
- getContextItemById,
7
- } from "../../db/context.ts";
8
- import { isUuid } from "../../db/uuid.ts";
2
+ import { getInfo, readContextFile } from "../../context/store.ts";
9
3
  import type { ToolDefinition } from "../tool.ts";
10
4
 
11
5
  const inputSchema = z.object({
12
- drive: z
13
- .string()
14
- .optional()
15
- .describe(
16
- "Drive name (e.g. 'disk', 'url', 'agent'). Optional when `path` is a UUID or already in `drive:/...` form.",
17
- ),
18
- path: z
19
- .string()
20
- .describe(
21
- "Path within the drive (starts with /), or a bare UUID / 'drive:/path' ref.",
22
- ),
6
+ path: z.string().describe("Path under context/"),
23
7
  });
24
8
 
25
9
  const fileSchema = z.object({
26
- id: z.string(),
27
- title: z.string(),
28
- description: z.string(),
29
- mime_type: z.string(),
10
+ path: z.string(),
11
+ is_directory: z.boolean(),
30
12
  is_textual: z.boolean(),
13
+ is_symlink: z.boolean(),
14
+ mime_type: z.string(),
31
15
  size: z.number(),
32
16
  lines: z.number(),
33
- drive: z.string(),
34
- path: z.string(),
35
- ref: z.string(),
36
- indexed_at: z.string().nullable(),
37
- created_at: z.string(),
38
- updated_at: z.string(),
17
+ mtime: z.string(),
18
+ content_hash: z.string().nullable(),
39
19
  });
40
20
 
41
21
  const outputSchema = z.object({
@@ -49,74 +29,36 @@ const outputSchema = z.object({
49
29
  export const contextInfoTool = {
50
30
  name: "context_info",
51
31
  description:
52
- "[[ bash equivalent command: stat ]] Show context item metadata: size, MIME type, line count, etc.",
32
+ "[[ bash equivalent command: stat ]] Show metadata for a path under context/: size, MIME type, line count, mtime, content hash. `is_symlink` is true when the path is a user-placed symlink.",
53
33
  group: "context",
54
34
  inputSchema,
55
35
  outputSchema,
56
36
  execute: async (input, ctx) => {
57
- let drive = input.drive;
58
- let path = input.path;
59
- if (isUuid(input.path)) {
60
- const byId = await getContextItemById(ctx.conn, input.path);
61
- if (byId) {
62
- drive = byId.drive;
63
- path = byId.path;
64
- }
65
- } else {
66
- const parsed = parseDriveRef(input.path);
67
- if (parsed) {
68
- drive = parsed.drive;
69
- path = parsed.path;
70
- }
71
- }
72
-
73
- if (!drive) {
74
- return {
75
- is_error: true,
76
- error_type: "missing_drive",
77
- message: `Cannot resolve context item: no drive provided and \`${input.path}\` is not a UUID or \`drive:/path\` ref.`,
78
- next_action_hint:
79
- "Pass `drive` explicitly, or use a `drive:/path` ref. Call context_list_drives to see which drives exist.",
80
- };
81
- }
82
-
83
- if (!path.startsWith("/")) path = `/${path}`;
84
-
85
- const item = await getContextItem(ctx.conn, { drive, path });
86
- if (!item) {
87
- const { parent, siblings, walkedUp } = await findNearbyContextPaths(
88
- ctx.conn,
89
- drive,
90
- path,
91
- );
92
- const hint =
93
- siblings.length > 0
94
- ? `${walkedUp ? `Parent ${parent} has no direct entries; ` : ""}Nearby items under ${parent}: ${siblings.join(", ")}. Call context_tree({drive:"${drive}",path:"${parent.replace(/^[^:]*:/, "")}"}) to see more.`
95
- : `No items found under ${parent}. Call context_list_drives to see which drives exist.`;
37
+ const info = await getInfo(ctx.projectDir, input.path);
38
+ if (!info) {
96
39
  return {
97
40
  is_error: true,
98
41
  error_type: "not_found",
99
- message: `No context item at ${formatDriveRef({ drive, path })}`,
100
- next_action_hint: hint,
42
+ message: `No path at context/${input.path}`,
43
+ next_action_hint: "Call context_tree to browse.",
101
44
  };
102
45
  }
103
-
104
- const content = item.content ?? "";
46
+ let lines = 0;
47
+ if (info.is_textual && !info.is_directory) {
48
+ const content = await readContextFile(ctx.projectDir, input.path);
49
+ lines = content === "" ? 0 : content.split("\n").length;
50
+ }
105
51
  return {
106
52
  file: {
107
- id: item.id,
108
- title: item.title,
109
- description: item.description,
110
- mime_type: item.mime_type,
111
- is_textual: item.is_textual,
112
- size: content.length,
113
- lines: content ? content.split("\n").length : 0,
114
- drive: item.drive,
115
- path: item.path,
116
- ref: formatDriveRef(item),
117
- indexed_at: item.indexed_at?.toISOString() ?? null,
118
- created_at: item.created_at.toISOString(),
119
- updated_at: item.updated_at.toISOString(),
53
+ path: info.path,
54
+ is_directory: info.is_directory,
55
+ is_textual: info.is_textual,
56
+ is_symlink: info.is_symlink,
57
+ mime_type: info.mime_type,
58
+ size: info.size,
59
+ lines,
60
+ mtime: info.mtime.toISOString(),
61
+ content_hash: info.content_hash,
120
62
  },
121
63
  is_error: false,
122
64
  };
@@ -1,46 +1,61 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef } from "../../context/drives.ts";
3
2
  import {
4
- contextPathExists,
5
- deleteContextItemByPath,
6
- moveContextItem,
7
- } from "../../db/context.ts";
3
+ deleteContextPath,
4
+ fileExists,
5
+ moveContextPath,
6
+ NotFoundError,
7
+ PathConflictError,
8
+ } from "../../context/store.ts";
8
9
  import type { ToolDefinition } from "../tool.ts";
9
10
 
10
11
  const inputSchema = z.object({
11
- src_drive: z.string().describe("Source drive"),
12
- src_path: z.string().describe("Source path within the drive"),
13
- dst_drive: z.string().describe("Destination drive"),
14
- dst_path: z.string().describe("Destination path within the drive"),
12
+ src: z.string().describe("Source path under context/"),
13
+ dst: z.string().describe("Destination path under context/"),
15
14
  overwrite: z.boolean().optional().describe("Overwrite if destination exists"),
16
15
  });
17
16
 
18
17
  const outputSchema = z.object({
19
- ref: z.string(),
18
+ src: z.string(),
19
+ dst: z.string(),
20
20
  is_error: z.boolean(),
21
+ error_type: z.string().optional(),
22
+ message: z.string().optional(),
21
23
  });
22
24
 
23
25
  export const contextMoveTool = {
24
26
  name: "context_move",
25
27
  description:
26
- "[[ bash equivalent command: mv ]] Move or rename a context item (can also relocate between drives).",
28
+ "[[ bash equivalent command: mv ]] Move or rename a file/directory under context/. Source/destination paths that traverse a user symlink fail with PathEscapeError.",
27
29
  group: "context",
28
30
  inputSchema,
29
31
  outputSchema,
30
32
  execute: async (input, ctx) => {
31
- const src = { drive: input.src_drive, path: input.src_path };
32
- const dst = { drive: input.dst_drive, path: input.dst_path };
33
-
34
- const dstExists = await contextPathExists(ctx.conn, dst);
35
- if (dstExists && !input.overwrite) {
36
- throw new Error(`Destination already exists: ${formatDriveRef(dst)}`);
37
- }
38
- if (dstExists) {
39
- await deleteContextItemByPath(ctx.conn, dst);
33
+ try {
34
+ if (input.overwrite && (await fileExists(ctx.projectDir, input.dst))) {
35
+ await deleteContextPath(ctx.projectDir, input.dst, { recursive: true });
36
+ }
37
+ await moveContextPath(ctx.projectDir, input.src, input.dst);
38
+ return { src: input.src, dst: input.dst, is_error: false };
39
+ } catch (err) {
40
+ if (err instanceof NotFoundError) {
41
+ return {
42
+ src: input.src,
43
+ dst: input.dst,
44
+ is_error: true,
45
+ error_type: "not_found",
46
+ message: `No file at context/${err.path}`,
47
+ };
48
+ }
49
+ if (err instanceof PathConflictError) {
50
+ return {
51
+ src: input.src,
52
+ dst: input.dst,
53
+ is_error: true,
54
+ error_type: "path_conflict",
55
+ message: `Destination already exists at context/${err.path}; pass overwrite=true to replace.`,
56
+ };
57
+ }
58
+ throw err;
40
59
  }
41
-
42
- await moveContextItem(ctx.conn, src, dst);
43
-
44
- return { ref: formatDriveRef(dst), is_error: false };
45
60
  },
46
61
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,24 +1,16 @@
1
1
  import { z } from "zod";
2
- import { formatDriveRef, parseDriveRef } from "../../context/drives.ts";
3
2
  import {
4
- findNearbyContextPaths,
5
- getContextItem,
6
- getContextItemById,
7
- } from "../../db/context.ts";
8
- import { isUuid } from "../../db/uuid.ts";
3
+ IsDirectoryError,
4
+ NotFoundError,
5
+ readContextFile,
6
+ } from "../../context/store.ts";
9
7
  import type { ToolDefinition } from "../tool.ts";
10
8
 
11
9
  const inputSchema = z.object({
12
- drive: z
13
- .string()
14
- .optional()
15
- .describe(
16
- "Drive name (e.g. 'disk', 'url', 'agent', 'google-docs', 'github'). Use context_list_drives to see what's available. Optional when `path` is a UUID or already in `drive:/...` form.",
17
- ),
18
10
  path: z
19
11
  .string()
20
12
  .describe(
21
- "Path within the drive (starts with /), or a bare UUID / 'drive:/path' ref (in which case `drive` is ignored).",
13
+ "Project-relative path under context/ (e.g. 'notes/foo.md'). Forward-slashes; never absolute.",
22
14
  ),
23
15
  offset: z
24
16
  .number()
@@ -37,79 +29,39 @@ const outputSchema = z.object({
37
29
 
38
30
  export const contextReadTool = {
39
31
  name: "context_read",
40
- description:
41
- "[[ bash equivalent command: cat ]] Read a context item's contents.",
32
+ description: "[[ bash equivalent command: cat ]] Read a file under context/.",
42
33
  group: "context",
43
34
  inputSchema,
44
35
  outputSchema,
45
36
  execute: async (input, ctx) => {
46
- // Accept either (drive, path) or a bare UUID / drive:/path ref in `path`.
47
- let drive = input.drive;
48
- let path = input.path;
49
- if (isUuid(input.path)) {
50
- const byId = await getContextItemById(ctx.conn, input.path);
51
- if (byId) {
52
- drive = byId.drive;
53
- path = byId.path;
37
+ try {
38
+ let content = await readContextFile(ctx.projectDir, input.path);
39
+ if (input.offset || input.limit) {
40
+ const lines = content.split("\n");
41
+ const start = (input.offset ?? 1) - 1;
42
+ const end = input.limit ? start + input.limit : lines.length;
43
+ content = lines.slice(start, end).join("\n");
54
44
  }
55
- } else {
56
- const parsed = parseDriveRef(input.path);
57
- if (parsed) {
58
- drive = parsed.drive;
59
- path = parsed.path;
45
+ return { content, is_error: false };
46
+ } catch (err) {
47
+ if (err instanceof NotFoundError) {
48
+ return {
49
+ is_error: true,
50
+ error_type: "not_found",
51
+ message: `No file at context/${err.path}`,
52
+ next_action_hint:
53
+ "Call context_tree to browse, or context_exists to check first.",
54
+ };
60
55
  }
56
+ if (err instanceof IsDirectoryError) {
57
+ return {
58
+ is_error: true,
59
+ error_type: "is_directory",
60
+ message: `context/${err.path} is a directory`,
61
+ next_action_hint: "Use context_tree to list its contents.",
62
+ };
63
+ }
64
+ throw err;
61
65
  }
62
-
63
- if (!drive) {
64
- return {
65
- is_error: true,
66
- error_type: "missing_drive",
67
- message: `Cannot resolve context item: no drive provided and \`${input.path}\` is not a UUID or \`drive:/path\` ref.`,
68
- next_action_hint:
69
- "Pass `drive` explicitly, or use a `drive:/path` ref. Call context_list_drives to see which drives exist.",
70
- };
71
- }
72
-
73
- if (!path.startsWith("/")) path = `/${path}`;
74
-
75
- const item = await getContextItem(ctx.conn, { drive, path });
76
- if (!item) {
77
- const { parent, siblings, walkedUp } = await findNearbyContextPaths(
78
- ctx.conn,
79
- drive,
80
- path,
81
- );
82
- const hint =
83
- siblings.length > 0
84
- ? `${walkedUp ? `Parent ${parent} has no direct entries; ` : ""}Nearby items under ${parent}: ${siblings.join(", ")}. Call context_tree({drive:"${drive}",path:"${parent.replace(/^[^:]*:/, "")}"}) to see more.`
85
- : `No items found under ${parent}. Call context_list_drives to see which drives exist.`;
86
- return {
87
- is_error: true,
88
- error_type: "not_found",
89
- message: `No context item at ${formatDriveRef({ drive, path })}`,
90
- next_action_hint: hint,
91
- };
92
- }
93
-
94
- if (item.content == null) {
95
- return {
96
- is_error: true,
97
- error_type: "no_text_content",
98
- message: `Context item ${formatDriveRef(item)} has no text content (mime: ${item.mime_type})`,
99
- next_action_hint:
100
- "Binary items can't be read as text. Call context_info to inspect metadata, or pick a textual sibling.",
101
- };
102
- }
103
-
104
- let content = item.content;
105
-
106
- if (input.offset || input.limit) {
107
- const lines = content.split("\n");
108
- const start = (input.offset ?? 1) - 1;
109
- const end = input.limit ? start + input.limit : lines.length;
110
- content = lines.slice(start, end).join("\n");
111
- }
112
-
113
- return { content, is_error: false };
114
66
  },
115
67
  } satisfies ToolDefinition<typeof inputSchema, typeof outputSchema>;
@@ -1,132 +1,55 @@
1
- import { isText } from "istextorbinary";
2
1
  import { z } from "zod";
3
- import { formatDriveRef } from "../../context/drives.ts";
4
- import { ingestByPath } from "../../context/ingest.ts";
5
- import {
6
- createContextItemStrict,
7
- PathConflictError,
8
- upsertContextItem,
9
- } from "../../db/context.ts";
10
- import { buildContextTree } from "../dir/tree.ts";
2
+ import { PathConflictError, writeContextFile } from "../../context/store.ts";
11
3
  import type { ToolDefinition } from "../tool.ts";
12
4
 
13
- function mimeFromPath(path: string): string {
14
- const type = Bun.file(path).type.split(";")[0];
15
- return type ?? "application/octet-stream";
16
- }
17
-
18
- function isTextualPath(path: string): boolean {
19
- const filename = path.split("/").pop() ?? path;
20
- const result = isText(filename);
21
- return result !== false;
22
- }
23
-
24
5
  const inputSchema = z.object({
25
- drive: z
6
+ path: z
26
7
  .string()
27
- .default("agent")
28
8
  .describe(
29
- "Drive to write to (defaults to 'agent', which is the agent's scratch drive). Only 'agent' and drives mirroring an external system you can write back to make sense here.",
9
+ "Project-relative path under context/ (e.g. 'notes/foo.md'). Created if its parent directory does not exist.",
30
10
  ),
31
- path: z.string().describe("Path within the drive (starts with /)"),
32
11
  content: z.string().describe("Text content to write"),
33
- content_base64: z
34
- .string()
35
- .optional()
36
- .describe(
37
- "Base64-encoded binary content (used instead of content for binary files)",
38
- ),
39
- title: z
40
- .string()
41
- .optional()
42
- .describe("Title for the file (defaults to filename)"),
43
- description: z.string().optional().describe("Description of the file"),
44
12
  on_conflict: z
45
13
  .enum(["error", "overwrite"])
46
14
  .optional()
47
15
  .describe(
48
- "What to do if a file already exists at this (drive, path). Defaults to 'error'. Pass 'overwrite' to replace.",
16
+ "What to do if the file already exists. Defaults to 'error'. Pass 'overwrite' to replace.",
49
17
  ),
50
18
  });
51
19
 
52
20
  const outputSchema = z.object({
53
- id: z.string().nullable(),
54
- drive: z.string(),
55
21
  path: z.string(),
56
- ref: z.string(),
57
22
  is_error: z.boolean(),
58
23
  error_type: z.string().optional(),
59
24
  message: z.string().optional(),
60
25
  next_action_hint: z.string().optional(),
61
- tree: z
62
- .string()
63
- .optional()
64
- .describe(
65
- "Snapshot of the drive's tree after the write so you can see the surrounding files.",
66
- ),
67
26
  });
68
27
 
69
28
  export const contextWriteTool = {
70
29
  name: "context_write",
71
30
  description:
72
- "[[ bash equivalent command: tee ]] Write content to a context item. By default writes to drive='agent' (the agent's scratch drive). Fails if the (drive, path) already exists pass on_conflict='overwrite' to replace.",
31
+ "[[ bash equivalent command: tee ]] Write text content to a file under context/. Fails if the path already exists unless on_conflict='overwrite'. Writes that traverse a user symlink fail with PathEscapeError delete the symlink first or write to a real path.",
73
32
  group: "context",
74
33
  inputSchema,
75
34
  outputSchema,
76
35
  execute: async (input, ctx) => {
77
- const mimeType = mimeFromPath(input.path);
78
- const isTextual = isTextualPath(input.path);
79
- const title =
80
- input.title ?? input.path.split("/").filter(Boolean).pop() ?? input.path;
81
- const onConflict = input.on_conflict ?? "error";
82
- const target = { drive: input.drive, path: input.path };
83
-
84
36
  try {
85
- const item =
86
- onConflict === "overwrite"
87
- ? await upsertContextItem(ctx.conn, {
88
- title,
89
- description: input.description,
90
- content: input.content_base64 ?? input.content,
91
- drive: target.drive,
92
- path: target.path,
93
- mimeType,
94
- isTextual,
95
- })
96
- : await createContextItemStrict(ctx.conn, {
97
- title,
98
- description: input.description,
99
- content: input.content_base64 ?? input.content,
100
- drive: target.drive,
101
- path: target.path,
102
- mimeType,
103
- isTextual,
104
- });
105
-
106
- await ingestByPath(ctx.conn, target, ctx.config);
107
- const { tree } = await buildContextTree(ctx.conn, {
108
- drive: target.drive,
109
- });
110
- return {
111
- id: item.id,
112
- drive: item.drive,
113
- path: item.path,
114
- ref: formatDriveRef(item),
115
- is_error: false,
116
- tree,
117
- };
37
+ const entry = await writeContextFile(
38
+ ctx.projectDir,
39
+ input.path,
40
+ input.content,
41
+ { onConflict: input.on_conflict ?? "error" },
42
+ );
43
+ return { path: entry.path, is_error: false };
118
44
  } catch (err) {
119
45
  if (err instanceof PathConflictError) {
120
46
  return {
121
- id: null,
122
- drive: err.drive,
123
47
  path: err.path,
124
- ref: formatDriveRef({ drive: err.drive, path: err.path }),
125
48
  is_error: true,
126
49
  error_type: "path_conflict",
127
- message: `A file already exists at ${formatDriveRef({ drive: err.drive, path: err.path })} (id: ${err.existingId}).`,
50
+ message: `A file already exists at context/${err.path}.`,
128
51
  next_action_hint:
129
- "Call context_read to inspect the existing file, or retry with on_conflict='overwrite' to replace it.",
52
+ "Call context_read to inspect, or retry with on_conflict='overwrite'.",
130
53
  };
131
54
  }
132
55
  throw err;