@amodalai/runtime 0.2.0 → 0.2.1

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 (209) hide show
  1. package/dist/src/__fixtures__/README.md +4 -0
  2. package/dist/src/__fixtures__/e2e.test.d.ts +6 -0
  3. package/dist/src/__fixtures__/e2e.test.js +211 -0
  4. package/dist/src/__fixtures__/e2e.test.js.map +1 -0
  5. package/dist/src/__fixtures__/smoke-agent/automations/delivery-callback-test.json +9 -0
  6. package/dist/src/__fixtures__/smoke-agent/connections/mock-mcp/spec.json +1 -1
  7. package/dist/src/__fixtures__/smoke.test.js +715 -29
  8. package/dist/src/__fixtures__/smoke.test.js.map +1 -1
  9. package/dist/src/__fixtures__/test-env.d.ts +27 -0
  10. package/dist/src/__fixtures__/test-env.js +64 -0
  11. package/dist/src/__fixtures__/test-env.js.map +1 -0
  12. package/dist/src/__fixtures__/test-helpers.d.ts +30 -0
  13. package/dist/src/__fixtures__/test-helpers.js +120 -0
  14. package/dist/src/__fixtures__/test-helpers.js.map +1 -0
  15. package/dist/src/agent/agent-types.d.ts +22 -0
  16. package/dist/src/agent/agent-types.js.map +1 -1
  17. package/dist/src/agent/automation-bridge.d.ts +9 -0
  18. package/dist/src/agent/automation-bridge.js +26 -0
  19. package/dist/src/agent/automation-bridge.js.map +1 -1
  20. package/dist/src/agent/automation-bridge.test.js +63 -0
  21. package/dist/src/agent/automation-bridge.test.js.map +1 -1
  22. package/dist/src/agent/local-server.d.ts +0 -7
  23. package/dist/src/agent/local-server.js +230 -86
  24. package/dist/src/agent/local-server.js.map +1 -1
  25. package/dist/src/agent/local-server.test.js +14 -8
  26. package/dist/src/agent/local-server.test.js.map +1 -1
  27. package/dist/src/agent/loop-types.d.ts +81 -2
  28. package/dist/src/agent/loop-types.js +4 -0
  29. package/dist/src/agent/loop-types.js.map +1 -1
  30. package/dist/src/agent/loop.js +16 -3
  31. package/dist/src/agent/loop.js.map +1 -1
  32. package/dist/src/agent/loop.test.js +572 -8
  33. package/dist/src/agent/loop.test.js.map +1 -1
  34. package/dist/src/agent/proactive/delivery-router.d.ts +68 -0
  35. package/dist/src/agent/proactive/delivery-router.js +337 -0
  36. package/dist/src/agent/proactive/delivery-router.js.map +1 -0
  37. package/dist/src/agent/proactive/delivery-router.test.d.ts +6 -0
  38. package/dist/src/agent/proactive/delivery-router.test.js +455 -0
  39. package/dist/src/agent/proactive/delivery-router.test.js.map +1 -0
  40. package/dist/src/agent/proactive/proactive-runner.d.ts +23 -1
  41. package/dist/src/agent/proactive/proactive-runner.js +42 -10
  42. package/dist/src/agent/proactive/proactive-runner.js.map +1 -1
  43. package/dist/src/agent/proactive/proactive-runner.test.js +0 -2
  44. package/dist/src/agent/proactive/proactive-runner.test.js.map +1 -1
  45. package/dist/src/agent/routes/admin-chat-abort.test.d.ts +6 -0
  46. package/dist/src/agent/routes/admin-chat-abort.test.js +206 -0
  47. package/dist/src/agent/routes/admin-chat-abort.test.js.map +1 -0
  48. package/dist/src/agent/routes/admin-chat.js +0 -2
  49. package/dist/src/agent/routes/admin-chat.js.map +1 -1
  50. package/dist/src/agent/routes/task.test.js +0 -2
  51. package/dist/src/agent/routes/task.test.js.map +1 -1
  52. package/dist/src/agent/snapshot-server.js +0 -2
  53. package/dist/src/agent/snapshot-server.js.map +1 -1
  54. package/dist/src/agent/states/compacting.js +5 -3
  55. package/dist/src/agent/states/compacting.js.map +1 -1
  56. package/dist/src/agent/states/confirming.js +3 -0
  57. package/dist/src/agent/states/confirming.js.map +1 -1
  58. package/dist/src/agent/states/dispatching.js +45 -1
  59. package/dist/src/agent/states/dispatching.js.map +1 -1
  60. package/dist/src/agent/states/executing.js +225 -81
  61. package/dist/src/agent/states/executing.js.map +1 -1
  62. package/dist/src/agent/states/streaming.js +14 -0
  63. package/dist/src/agent/states/streaming.js.map +1 -1
  64. package/dist/src/agent/states/thinking.d.ts +1 -1
  65. package/dist/src/agent/states/thinking.js +246 -29
  66. package/dist/src/agent/states/thinking.js.map +1 -1
  67. package/dist/src/agent/token-estimate.d.ts +20 -6
  68. package/dist/src/agent/token-estimate.js +24 -3
  69. package/dist/src/agent/token-estimate.js.map +1 -1
  70. package/dist/src/agent/token-estimate.test.d.ts +6 -0
  71. package/dist/src/agent/token-estimate.test.js +44 -0
  72. package/dist/src/agent/token-estimate.test.js.map +1 -0
  73. package/dist/src/api/create-agent.js +0 -3
  74. package/dist/src/api/create-agent.js.map +1 -1
  75. package/dist/src/api/types.d.ts +0 -2
  76. package/dist/src/env-ref.d.ts +13 -0
  77. package/dist/src/env-ref.js +31 -0
  78. package/dist/src/env-ref.js.map +1 -0
  79. package/dist/src/env-ref.test.d.ts +6 -0
  80. package/dist/src/env-ref.test.js +34 -0
  81. package/dist/src/env-ref.test.js.map +1 -0
  82. package/dist/src/errors.d.ts +15 -0
  83. package/dist/src/errors.js +22 -0
  84. package/dist/src/errors.js.map +1 -1
  85. package/dist/src/errors.test.js +2 -2
  86. package/dist/src/errors.test.js.map +1 -1
  87. package/dist/src/events/event-bus.d.ts +54 -0
  88. package/dist/src/events/event-bus.js +84 -0
  89. package/dist/src/events/event-bus.js.map +1 -0
  90. package/dist/src/events/event-bus.test.d.ts +6 -0
  91. package/dist/src/events/event-bus.test.js +112 -0
  92. package/dist/src/events/event-bus.test.js.map +1 -0
  93. package/dist/src/events/events-route.d.ts +36 -0
  94. package/dist/src/events/events-route.js +80 -0
  95. package/dist/src/events/events-route.js.map +1 -0
  96. package/dist/src/events/events-route.test.d.ts +6 -0
  97. package/dist/src/events/events-route.test.js +134 -0
  98. package/dist/src/events/events-route.test.js.map +1 -0
  99. package/dist/src/events/store-event-wrapper.d.ts +19 -0
  100. package/dist/src/events/store-event-wrapper.js +57 -0
  101. package/dist/src/events/store-event-wrapper.js.map +1 -0
  102. package/dist/src/events/store-event-wrapper.test.d.ts +6 -0
  103. package/dist/src/events/store-event-wrapper.test.js +91 -0
  104. package/dist/src/events/store-event-wrapper.test.js.map +1 -0
  105. package/dist/src/middleware/auth.d.ts +0 -2
  106. package/dist/src/middleware/auth.js.map +1 -1
  107. package/dist/src/providers/search-provider.d.ts +64 -0
  108. package/dist/src/providers/search-provider.js +174 -0
  109. package/dist/src/providers/search-provider.js.map +1 -0
  110. package/dist/src/providers/types.d.ts +8 -0
  111. package/dist/src/routes/ai-stream.d.ts +15 -0
  112. package/dist/src/routes/ai-stream.js +9 -0
  113. package/dist/src/routes/ai-stream.js.map +1 -1
  114. package/dist/src/routes/chat-stream.d.ts +6 -0
  115. package/dist/src/routes/chat-stream.js +2 -0
  116. package/dist/src/routes/chat-stream.js.map +1 -1
  117. package/dist/src/routes/chat.d.ts +6 -0
  118. package/dist/src/routes/chat.js +2 -0
  119. package/dist/src/routes/chat.js.map +1 -1
  120. package/dist/src/routes/session-resolver.d.ts +5 -0
  121. package/dist/src/routes/session-resolver.js +1 -15
  122. package/dist/src/routes/session-resolver.js.map +1 -1
  123. package/dist/src/routes/session-resolver.test.js +7 -6
  124. package/dist/src/routes/session-resolver.test.js.map +1 -1
  125. package/dist/src/server.d.ts +6 -0
  126. package/dist/src/server.js +2 -0
  127. package/dist/src/server.js.map +1 -1
  128. package/dist/src/session/drizzle-session-store.d.ts +56 -0
  129. package/dist/src/session/drizzle-session-store.js +203 -0
  130. package/dist/src/session/drizzle-session-store.js.map +1 -0
  131. package/dist/src/session/manager.d.ts +6 -3
  132. package/dist/src/session/manager.js +46 -16
  133. package/dist/src/session/manager.js.map +1 -1
  134. package/dist/src/session/manager.test.js +12 -18
  135. package/dist/src/session/manager.test.js.map +1 -1
  136. package/dist/src/session/pglite-session-store.d.ts +23 -0
  137. package/dist/src/session/pglite-session-store.js +70 -0
  138. package/dist/src/session/pglite-session-store.js.map +1 -0
  139. package/dist/src/session/postgres-session-store.d.ts +44 -0
  140. package/dist/src/session/postgres-session-store.js +138 -0
  141. package/dist/src/session/postgres-session-store.js.map +1 -0
  142. package/dist/src/session/session-builder.d.ts +0 -2
  143. package/dist/src/session/session-builder.js +22 -2
  144. package/dist/src/session/session-builder.js.map +1 -1
  145. package/dist/src/session/session-builder.test.js +0 -2
  146. package/dist/src/session/session-builder.test.js.map +1 -1
  147. package/dist/src/session/session-store-selector.d.ts +49 -0
  148. package/dist/src/session/session-store-selector.js +60 -0
  149. package/dist/src/session/session-store-selector.js.map +1 -0
  150. package/dist/src/session/session-store-selector.test.d.ts +6 -0
  151. package/dist/src/session/session-store-selector.test.js +79 -0
  152. package/dist/src/session/session-store-selector.test.js.map +1 -0
  153. package/dist/src/session/store.d.ts +146 -32
  154. package/dist/src/session/store.js +126 -138
  155. package/dist/src/session/store.js.map +1 -1
  156. package/dist/src/session/store.test.js +385 -107
  157. package/dist/src/session/store.test.js.map +1 -1
  158. package/dist/src/session/tool-context-factory.d.ts +3 -2
  159. package/dist/src/session/tool-context-factory.js +1 -2
  160. package/dist/src/session/tool-context-factory.js.map +1 -1
  161. package/dist/src/session/tool-context-factory.test.js +1 -4
  162. package/dist/src/session/tool-context-factory.test.js.map +1 -1
  163. package/dist/src/session/types.d.ts +13 -6
  164. package/dist/src/stores/schema.d.ts +0 -34
  165. package/dist/src/stores/schema.js +6 -4
  166. package/dist/src/stores/schema.js.map +1 -1
  167. package/dist/src/tools/admin-file-tools.d.ts +29 -0
  168. package/dist/src/tools/admin-file-tools.js +525 -11
  169. package/dist/src/tools/admin-file-tools.js.map +1 -1
  170. package/dist/src/tools/admin-file-tools.test.js +373 -4
  171. package/dist/src/tools/admin-file-tools.test.js.map +1 -1
  172. package/dist/src/tools/custom-tool-adapter.test.js +0 -1
  173. package/dist/src/tools/custom-tool-adapter.test.js.map +1 -1
  174. package/dist/src/tools/dispatch-tool.d.ts +4 -4
  175. package/dist/src/tools/fetch-url-tool.d.ts +23 -0
  176. package/dist/src/tools/fetch-url-tool.js +333 -0
  177. package/dist/src/tools/fetch-url-tool.js.map +1 -0
  178. package/dist/src/tools/fetch-url-tool.test.d.ts +6 -0
  179. package/dist/src/tools/fetch-url-tool.test.js +228 -0
  180. package/dist/src/tools/fetch-url-tool.test.js.map +1 -0
  181. package/dist/src/tools/mcp-tool-adapter.test.js +0 -1
  182. package/dist/src/tools/mcp-tool-adapter.test.js.map +1 -1
  183. package/dist/src/tools/registry.test.js +0 -1
  184. package/dist/src/tools/registry.test.js.map +1 -1
  185. package/dist/src/tools/request-tool.test.js +0 -1
  186. package/dist/src/tools/request-tool.test.js.map +1 -1
  187. package/dist/src/tools/store-tools.test.js +0 -1
  188. package/dist/src/tools/store-tools.test.js.map +1 -1
  189. package/dist/src/tools/types.d.ts +20 -2
  190. package/dist/src/tools/web-search-tool.d.ts +31 -0
  191. package/dist/src/tools/web-search-tool.js +170 -0
  192. package/dist/src/tools/web-search-tool.js.map +1 -0
  193. package/dist/src/tools/web-search-tool.test.d.ts +6 -0
  194. package/dist/src/tools/web-search-tool.test.js +153 -0
  195. package/dist/src/tools/web-search-tool.test.js.map +1 -0
  196. package/dist/src/tools/web-tools-shared.d.ts +21 -0
  197. package/dist/src/tools/web-tools-shared.js +32 -0
  198. package/dist/src/tools/web-tools-shared.js.map +1 -0
  199. package/dist/src/types.d.ts +20 -0
  200. package/dist/src/types.js +13 -0
  201. package/dist/src/types.js.map +1 -1
  202. package/dist/tsconfig.tsbuildinfo +1 -1
  203. package/package.json +17 -3
  204. package/dist/src/agent/session-store.d.ts +0 -71
  205. package/dist/src/agent/session-store.js +0 -151
  206. package/dist/src/agent/session-store.js.map +0 -1
  207. package/dist/src/session/admin-file-tools.d.ts +0 -136
  208. package/dist/src/session/admin-file-tools.js +0 -240
  209. package/dist/src/session/admin-file-tools.js.map +0 -1
@@ -4,19 +4,30 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  /**
7
- * Admin file tools rewritten for the new ToolRegistry.
7
+ * Admin file tools for managing agent repo files.
8
8
  *
9
- * Four tools for managing agent repo files:
10
- * - read_repo_file — read a file from the agent repo
11
- * - write_repo_file create or update a file
12
- * - delete_repo_file delete a file
13
- * - internal_api query the runtime's own API
9
+ * Nine tools:
10
+ * Read/write single files:
11
+ * - read_repo_file read a file from the agent repo
12
+ * - write_repo_file create or update a file (full rewrite)
13
+ * - delete_repo_file delete a file
14
+ * - edit_repo_file — in-place find-and-replace edit (preserves
15
+ * the rest of the file)
16
+ * Discovery:
17
+ * - list_repo_files — list files in an allowed directory
18
+ * - glob_repo_files — find files matching a glob pattern
19
+ * - grep_repo_files — regex content search across files
20
+ * - read_many_repo_files — batched read of multiple files
21
+ * Introspection:
22
+ * - internal_api — GET the runtime's own API
14
23
  *
15
- * Path validation enforces allowed directories and blocks sensitive files.
24
+ * All tools enforce the same allowed-directory allowlist and never touch
25
+ * sensitive files (.env, amodal.json, package.json, etc.).
16
26
  */
17
- import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
27
+ import { readFile, writeFile, unlink, mkdir, stat, readdir } from 'node:fs/promises';
18
28
  import * as path from 'node:path';
19
29
  import { z } from 'zod';
30
+ import { glob as globImpl } from 'glob';
20
31
  import { ConfigError } from '../errors.js';
21
32
  // ---------------------------------------------------------------------------
22
33
  // Path validation
@@ -43,6 +54,59 @@ const BLOCKED_FILENAMES = [
43
54
  const READ_ONLY_DIRS = [
44
55
  'amodal_packages/',
45
56
  ];
57
+ // ---------------------------------------------------------------------------
58
+ // Caps + skip patterns (tunable knobs for the discovery tools)
59
+ // ---------------------------------------------------------------------------
60
+ /** Always skipped when walking directories or matching globs. */
61
+ export const SKIP_DIR_NAMES = ['.git', 'node_modules', '.DS_Store'];
62
+ /** Max entries `list_repo_files` returns in a single call. */
63
+ export const LIST_MAX_ENTRIES = 2000;
64
+ /** Max files `glob_repo_files` returns in a single call. */
65
+ export const GLOB_MAX_FILES = 500;
66
+ /** Max matches `grep_repo_files` returns in a single call (matches gemini-cli). */
67
+ export const GREP_MAX_MATCHES = 100;
68
+ /** Max bytes `grep_repo_files` will read from any single file before skipping. */
69
+ export const GREP_FILE_SIZE_LIMIT = 5_000_000; // 5 MB
70
+ /** Max files `read_many_repo_files` returns in a single call. */
71
+ export const READ_MANY_MAX_FILES = 20;
72
+ /** Max bytes per file returned by `read_many_repo_files` before truncation. */
73
+ export const READ_MANY_MAX_BYTES = 50_000;
74
+ /**
75
+ * Default number of lines `read_repo_file` returns when no `limit` is passed.
76
+ * Matches the conventions of Claude Code's Read tool and gemini-cli's
77
+ * read_file — long files are truncated by default and the agent paginates
78
+ * only when it needs to. Prevents single reads from blowing the context
79
+ * window on large config/lockfile/log inputs.
80
+ */
81
+ export const READ_FILE_DEFAULT_LINES = 2000;
82
+ /** Hard upper bound on `limit` — even explicit requests cannot exceed this. */
83
+ export const READ_FILE_MAX_LINES = 10_000;
84
+ /** Top-level directory entry of the allowlist, without the trailing slash. */
85
+ const ALLOWED_TOP_LEVEL_NAMES = ALLOWED_REPO_DIRS.map((d) => d.replace(/\/$/, ''));
86
+ /**
87
+ * Filesystem error codes we treat as "skip this entry, keep going" during
88
+ * best-effort directory walks (list_repo_files, glob_repo_files,
89
+ * grep_repo_files). These correspond to "the path isn't there / isn't
90
+ * accessible / isn't the kind of thing we expected" — all of which are
91
+ * normal during a concurrent walk. Any other error code is unexpected
92
+ * (ENOMEM, EIO, etc.) and will be re-thrown rather than silently dropped.
93
+ */
94
+ const EXPECTED_FS_SKIP_CODES = new Set(['ENOENT', 'EACCES', 'ENOTDIR', 'EISDIR', 'EPERM']);
95
+ function isExpectedFsSkipError(err) {
96
+ if (!(err instanceof Error) || !('code' in err))
97
+ return false;
98
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by instanceof + 'code' in err
99
+ const code = err.code;
100
+ return code !== undefined && EXPECTED_FS_SKIP_CODES.has(code);
101
+ }
102
+ /**
103
+ * Count lines in a string. A trailing empty line from a terminal \n is NOT
104
+ * counted — a file whose content is "a\nb\n" is "2 lines" to humans, not 3.
105
+ */
106
+ function countLines(text) {
107
+ const parts = text.split(/\r?\n/);
108
+ return (parts.length > 0 && parts[parts.length - 1] === '') ? parts.length - 1 : parts.length;
109
+ }
46
110
  /** @internal Exported for testing */
47
111
  export function isAllowedRepoPath(relPath) {
48
112
  const basename = path.basename(relPath);
@@ -61,6 +125,14 @@ function validatePath(repoRoot, rawPath) {
61
125
  return { error: 'Path traversal (..) is not allowed' };
62
126
  }
63
127
  const normalized = path.normalize(rawPath);
128
+ // Bare allowlisted-directory name (e.g. "skills", no trailing slash or
129
+ // child path) — common agent mistake. Give a directed error instead of
130
+ // the generic "not in an allowed directory".
131
+ if (ALLOWED_TOP_LEVEL_NAMES.includes(normalized)) {
132
+ return {
133
+ error: `Path "${normalized}" is a directory — use list_repo_files to enumerate its contents, or provide a file path like "${normalized}/<name>".`,
134
+ };
135
+ }
64
136
  if (!isAllowedRepoPath(normalized)) {
65
137
  return { error: `Path "${normalized}" is not in an allowed directory. Allowed: ${ALLOWED_REPO_DIRS.join(', ')}. Blocked files: ${BLOCKED_FILENAMES.join(', ')}` };
66
138
  }
@@ -70,14 +142,99 @@ function validatePath(repoRoot, rawPath) {
70
142
  }
71
143
  return { resolved, relative: normalized };
72
144
  }
145
+ /**
146
+ * Validate a directory path for the discovery tools (list/glob/grep).
147
+ * Unlike `validatePath`, this accepts bare allowlist names ("skills") as
148
+ * valid inputs — the whole point is to look inside a directory.
149
+ *
150
+ * Returns the absolute path on the filesystem + the relative form, or an
151
+ * error message describing why the directory was rejected.
152
+ */
153
+ function validateDirPath(repoRoot, rawDir) {
154
+ if (!rawDir || rawDir.startsWith('/')) {
155
+ return { error: 'Directory must be relative to the repo root (no leading /)' };
156
+ }
157
+ if (rawDir.includes('..')) {
158
+ return { error: 'Path traversal (..) is not allowed' };
159
+ }
160
+ const normalized = path.normalize(rawDir).replace(/\/$/, '');
161
+ // Bare allowlist dir ("skills"): allowed for directory operations.
162
+ if (ALLOWED_TOP_LEVEL_NAMES.includes(normalized)) {
163
+ const resolved = path.resolve(repoRoot, normalized);
164
+ if (!resolved.startsWith(repoRoot)) {
165
+ return { error: 'Resolved path escapes the repo directory' };
166
+ }
167
+ return { resolved, relative: normalized };
168
+ }
169
+ // Child of an allowlist dir ("skills/triage").
170
+ const startsWithAllowed = ALLOWED_REPO_DIRS.some((dir) => normalized.startsWith(dir));
171
+ if (!startsWithAllowed) {
172
+ return { error: `Directory "${normalized}" is not in an allowed directory. Allowed top-level dirs: ${ALLOWED_TOP_LEVEL_NAMES.join(', ')}` };
173
+ }
174
+ const resolved = path.resolve(repoRoot, normalized);
175
+ if (!resolved.startsWith(repoRoot)) {
176
+ return { error: 'Resolved path escapes the repo directory' };
177
+ }
178
+ return { resolved, relative: normalized };
179
+ }
180
+ /**
181
+ * Walk a directory, yielding repo-relative file paths. Skips SKIP_DIR_NAMES
182
+ * entries and any files whose basename is in BLOCKED_FILENAMES. Stops after
183
+ * `limit` paths have been collected, returning `truncated: true` so the
184
+ * caller can tell the agent to narrow the query.
185
+ */
186
+ async function walkFiles(repoRoot, startAbs, recursive, limit) {
187
+ const files = [];
188
+ let truncated = false;
189
+ const queue = [startAbs];
190
+ while (queue.length > 0 && !truncated) {
191
+ const current = queue.shift();
192
+ if (current === undefined)
193
+ break;
194
+ let entries;
195
+ try {
196
+ entries = await readdir(current, { withFileTypes: true });
197
+ }
198
+ catch (err) {
199
+ // Path gone / no permissions / not a dir — expected during a
200
+ // concurrent walk, skip. Unexpected errors (ENOMEM, EIO) bubble.
201
+ if (isExpectedFsSkipError(err))
202
+ continue;
203
+ throw err;
204
+ }
205
+ // Sort for determinism — agents see the same order across identical calls.
206
+ entries.sort((a, b) => a.name.localeCompare(b.name));
207
+ for (const entry of entries) {
208
+ if (SKIP_DIR_NAMES.includes(entry.name))
209
+ continue;
210
+ if (BLOCKED_FILENAMES.includes(entry.name))
211
+ continue;
212
+ const absChild = path.join(current, entry.name);
213
+ if (entry.isDirectory()) {
214
+ if (recursive)
215
+ queue.push(absChild);
216
+ }
217
+ else if (entry.isFile()) {
218
+ files.push(path.relative(repoRoot, absChild));
219
+ if (files.length >= limit) {
220
+ truncated = true;
221
+ break;
222
+ }
223
+ }
224
+ }
225
+ }
226
+ return { files, truncated };
227
+ }
73
228
  // ---------------------------------------------------------------------------
74
229
  // read_repo_file
75
230
  // ---------------------------------------------------------------------------
76
231
  export function createReadRepoFileTool(repoRoot) {
77
232
  return {
78
- description: 'Read a file from the agent repo. Path is relative to repo root. Allowed directories: skills/, knowledge/, connections/, stores/, pages/, automations/, evals/, agents/, tools/.',
233
+ description: `Read a file from the agent repo. Returns up to ${String(READ_FILE_DEFAULT_LINES)} lines by default; if truncated: true, paginate with offset + limit. Path is relative to repo root (e.g. "knowledge/formatting-rules.md").`,
79
234
  parameters: z.object({
80
235
  path: z.string().min(1).describe('File path relative to repo root (e.g. "knowledge/formatting-rules.md")'),
236
+ offset: z.number().int().min(1).optional().describe(`Line number to start reading from (1-indexed, default 1). To continue after a truncated response, call again with offset: line_end + 1.`),
237
+ limit: z.number().int().min(1).max(READ_FILE_MAX_LINES).optional().describe(`Max lines to return (default ${String(READ_FILE_DEFAULT_LINES)}, max ${String(READ_FILE_MAX_LINES)})`),
81
238
  }),
82
239
  readOnly: true,
83
240
  metadata: { category: 'admin' },
@@ -86,9 +243,9 @@ export function createReadRepoFileTool(repoRoot) {
86
243
  if ('error' in validation) {
87
244
  return { error: validation.error };
88
245
  }
246
+ let buf;
89
247
  try {
90
- const content = await readFile(validation.resolved, 'utf-8');
91
- return { content, path: validation.relative };
248
+ buf = await readFile(validation.resolved);
92
249
  }
93
250
  catch (err) {
94
251
  // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by instanceof + 'code' in err
@@ -96,6 +253,32 @@ export function createReadRepoFileTool(repoRoot) {
96
253
  const msg = isNotFound ? `File not found: ${validation.relative}` : (err instanceof Error ? err.message : String(err));
97
254
  return { error: msg };
98
255
  }
256
+ if (isLikelyBinary(buf)) {
257
+ return { error: `Binary file: ${validation.relative} (read_repo_file returns text only)` };
258
+ }
259
+ const text = buf.toString('utf-8');
260
+ const lines = text.split(/\r?\n/);
261
+ const totalLines = countLines(text);
262
+ const offset = params.offset ?? 1;
263
+ const limit = params.limit ?? READ_FILE_DEFAULT_LINES;
264
+ // offset is 1-indexed; slice is 0-indexed.
265
+ const startIdx = offset - 1;
266
+ const slice = startIdx >= 0 && startIdx < totalLines
267
+ ? lines.slice(startIdx, Math.min(startIdx + limit, totalLines))
268
+ : [];
269
+ const linesReturned = slice.length;
270
+ // line_end = last line returned (1-indexed). Empty range → line_end = line_start - 1
271
+ // so the agent can detect "nothing returned" from line_end < line_start.
272
+ const lineEnd = offset + linesReturned - 1;
273
+ const hasMore = linesReturned > 0 && lineEnd < totalLines;
274
+ return {
275
+ content: slice.join('\n'),
276
+ path: validation.relative,
277
+ line_start: offset,
278
+ line_end: lineEnd,
279
+ total_lines: totalLines,
280
+ ...(hasMore ? { truncated: true } : {}),
281
+ };
99
282
  },
100
283
  };
101
284
  }
@@ -158,6 +341,332 @@ export function createDeleteRepoFileTool(repoRoot) {
158
341
  };
159
342
  }
160
343
  // ---------------------------------------------------------------------------
344
+ // list_repo_files
345
+ // ---------------------------------------------------------------------------
346
+ export function createListRepoFilesTool(repoRoot) {
347
+ return {
348
+ description: `List files in an agent-repo directory. Returns file paths relative to the repo root. Omit "dir" to list every allowed top-level directory at once. Call this BEFORE read_repo_file when you don't know exact filenames — guessing paths wastes turns. Capped at ${String(LIST_MAX_ENTRIES)} entries; narrow with "dir" if truncated.`,
349
+ parameters: z.object({
350
+ dir: z.string().optional().describe(`Directory to list, relative to repo root (e.g. "skills", "knowledge/formatting-rules"). Must be inside one of: ${ALLOWED_TOP_LEVEL_NAMES.join(', ')}. Omit to list all allowed top-level directories at once.`),
351
+ recursive: z.boolean().default(true).describe('Walk subdirectories (default true)'),
352
+ }),
353
+ readOnly: true,
354
+ metadata: { category: 'admin' },
355
+ async execute(params, _ctx) {
356
+ const recursive = params.recursive ?? true;
357
+ // No dir → list every allowlisted top-level directory at once.
358
+ if (params.dir === undefined) {
359
+ const collected = [];
360
+ let truncated = false;
361
+ for (const name of ALLOWED_TOP_LEVEL_NAMES) {
362
+ const absDir = path.resolve(repoRoot, name);
363
+ const remaining = LIST_MAX_ENTRIES - collected.length;
364
+ if (remaining <= 0) {
365
+ truncated = true;
366
+ break;
367
+ }
368
+ const result = await walkFiles(repoRoot, absDir, recursive, remaining);
369
+ collected.push(...result.files);
370
+ if (result.truncated) {
371
+ truncated = true;
372
+ break;
373
+ }
374
+ }
375
+ return { dir: null, files: collected, ...(truncated ? { truncated: true } : {}) };
376
+ }
377
+ const validation = validateDirPath(repoRoot, params.dir);
378
+ if ('error' in validation)
379
+ return { error: validation.error };
380
+ const { files, truncated } = await walkFiles(repoRoot, validation.resolved, recursive, LIST_MAX_ENTRIES);
381
+ return { dir: validation.relative, files, ...(truncated ? { truncated: true } : {}) };
382
+ },
383
+ };
384
+ }
385
+ // ---------------------------------------------------------------------------
386
+ // glob_repo_files
387
+ // ---------------------------------------------------------------------------
388
+ export function createGlobRepoFilesTool(repoRoot) {
389
+ return {
390
+ description: `Find files in the agent repo matching a glob pattern (e.g. "**/SKILL.md", "skills/**/*.md", "knowledge/*.md"). Returns repo-relative file paths, newest first (files modified in the last 24h are surfaced first, then lexical). Capped at ${String(GLOB_MAX_FILES)} results. Use this for structural searches like "find all skills" or "find all spec.json files".`,
391
+ parameters: z.object({
392
+ pattern: z.string().min(1).describe('Glob pattern to match (e.g. "**/*.md", "skills/**/SKILL.md"). Matches are filtered down to the allowed-directory allowlist after globbing.'),
393
+ case_sensitive: z.boolean().default(false).describe('Case-sensitive matching (default false)'),
394
+ }),
395
+ readOnly: true,
396
+ metadata: { category: 'admin' },
397
+ async execute(params, _ctx) {
398
+ if (params.pattern.includes('..')) {
399
+ return { error: 'Glob pattern traversal (..) is not allowed' };
400
+ }
401
+ const absMatches = await globImpl(params.pattern, {
402
+ cwd: repoRoot,
403
+ nodir: true,
404
+ dot: true,
405
+ nocase: !(params.case_sensitive ?? false),
406
+ follow: false,
407
+ ignore: SKIP_DIR_NAMES.map((n) => `**/${n}/**`),
408
+ withFileTypes: true,
409
+ stat: true,
410
+ });
411
+ // Recent-first sort: files touched in the last 24h surface first,
412
+ // ordered newest-to-oldest; everything else falls back to lexical.
413
+ // Matches the agent-ergonomics pattern from gemini-cli's glob tool.
414
+ const now = Date.now();
415
+ const recencyMs = 24 * 60 * 60 * 1000;
416
+ const sorted = [...absMatches].sort((a, b) => {
417
+ const ma = a.mtimeMs ?? 0;
418
+ const mb = b.mtimeMs ?? 0;
419
+ const aRecent = now - ma < recencyMs;
420
+ const bRecent = now - mb < recencyMs;
421
+ if (aRecent && bRecent)
422
+ return mb - ma;
423
+ if (aRecent)
424
+ return -1;
425
+ if (bRecent)
426
+ return 1;
427
+ return a.fullpath().localeCompare(b.fullpath());
428
+ });
429
+ const relative = [];
430
+ let truncated = false;
431
+ for (const entry of sorted) {
432
+ const rel = path.relative(repoRoot, entry.fullpath());
433
+ if (!isAllowedRepoPath(rel))
434
+ continue;
435
+ relative.push(rel);
436
+ if (relative.length >= GLOB_MAX_FILES) {
437
+ truncated = true;
438
+ break;
439
+ }
440
+ }
441
+ return { pattern: params.pattern, files: relative, ...(truncated ? { truncated: true } : {}) };
442
+ },
443
+ };
444
+ }
445
+ // ---------------------------------------------------------------------------
446
+ // grep_repo_files
447
+ // ---------------------------------------------------------------------------
448
+ function isLikelyBinary(buf) {
449
+ // Quick heuristic: look for a NUL byte in the first 8KB.
450
+ const sample = buf.subarray(0, Math.min(buf.length, 8192));
451
+ for (const byte of sample) {
452
+ if (byte === 0)
453
+ return true;
454
+ }
455
+ return false;
456
+ }
457
+ export function createGrepRepoFilesTool(repoRoot) {
458
+ return {
459
+ description: `Search file contents in the agent repo for a regex pattern. Returns matching {file, line_number, text} entries, capped at ${String(GREP_MAX_MATCHES)} matches total. Use for "where is X defined?" or "which skills mention Y?" queries. Searches files under the allowed-directory allowlist only.`,
460
+ parameters: z.object({
461
+ pattern: z.string().min(1).describe('Regular expression to search for (JavaScript regex syntax).'),
462
+ dir: z.string().optional().describe(`Directory to search under, relative to repo root (e.g. "skills", "knowledge"). Must be inside one of: ${ALLOWED_TOP_LEVEL_NAMES.join(', ')}. Omit to search all allowed top-level directories.`),
463
+ case_insensitive: z.boolean().default(true).describe('Case-insensitive search (default true)'),
464
+ include: z.string().optional().describe('Glob filter for filenames (e.g. "*.md", "**/*.json"). Evaluated against repo-relative paths.'),
465
+ }),
466
+ readOnly: true,
467
+ metadata: { category: 'admin' },
468
+ async execute(params, _ctx) {
469
+ // Compile the regex once — surface a clean error on invalid syntax.
470
+ let re;
471
+ try {
472
+ re = new RegExp(params.pattern, (params.case_insensitive ?? true) ? 'i' : '');
473
+ }
474
+ catch (err) {
475
+ return { error: `Invalid regex pattern: ${err instanceof Error ? err.message : String(err)}` };
476
+ }
477
+ // Resolve candidate files.
478
+ const includePattern = params.include ?? '**/*';
479
+ const searchDirs = [];
480
+ if (params.dir !== undefined) {
481
+ const validation = validateDirPath(repoRoot, params.dir);
482
+ if ('error' in validation)
483
+ return { error: validation.error };
484
+ searchDirs.push(validation.relative);
485
+ }
486
+ else {
487
+ searchDirs.push(...ALLOWED_TOP_LEVEL_NAMES);
488
+ }
489
+ const matches = [];
490
+ let truncated = false;
491
+ outer: for (const dir of searchDirs) {
492
+ const absMatches = await globImpl(includePattern, {
493
+ cwd: path.join(repoRoot, dir),
494
+ nodir: true,
495
+ dot: true,
496
+ follow: false,
497
+ ignore: SKIP_DIR_NAMES.map((n) => `**/${n}/**`),
498
+ withFileTypes: true,
499
+ });
500
+ for (const entry of absMatches) {
501
+ const absFile = entry.fullpath();
502
+ const relFile = path.relative(repoRoot, absFile);
503
+ if (!isAllowedRepoPath(relFile))
504
+ continue;
505
+ let stats;
506
+ try {
507
+ stats = await stat(absFile);
508
+ }
509
+ catch (err) {
510
+ // File vanished between glob and stat, or not readable — skip.
511
+ if (isExpectedFsSkipError(err))
512
+ continue;
513
+ throw err;
514
+ }
515
+ if (stats.size > GREP_FILE_SIZE_LIMIT)
516
+ continue;
517
+ let buf;
518
+ try {
519
+ buf = await readFile(absFile);
520
+ }
521
+ catch (err) {
522
+ // File vanished between stat and read, or not readable — skip.
523
+ if (isExpectedFsSkipError(err))
524
+ continue;
525
+ throw err;
526
+ }
527
+ if (isLikelyBinary(buf))
528
+ continue;
529
+ const lines = buf.toString('utf-8').split(/\r?\n/);
530
+ for (let i = 0; i < lines.length; i++) {
531
+ if (re.test(lines[i])) {
532
+ matches.push({ file: relFile, line_number: i + 1, text: lines[i] });
533
+ if (matches.length >= GREP_MAX_MATCHES) {
534
+ truncated = true;
535
+ break outer;
536
+ }
537
+ }
538
+ }
539
+ }
540
+ }
541
+ return { pattern: params.pattern, matches, ...(truncated ? { truncated: true } : {}) };
542
+ },
543
+ };
544
+ }
545
+ // ---------------------------------------------------------------------------
546
+ // edit_repo_file
547
+ // ---------------------------------------------------------------------------
548
+ export function createEditRepoFileTool(repoRoot) {
549
+ return {
550
+ description: 'Replace a specific substring in a repo file in place (preserves everything else). Use this instead of write_repo_file for small edits to large files — it saves context tokens and avoids accidentally dropping content. By default old_string must match EXACTLY ONE occurrence; set allow_multiple=true to replace every occurrence.',
551
+ parameters: z.object({
552
+ path: z.string().min(1).describe('File path relative to repo root.'),
553
+ old_string: z.string().min(1).describe('Exact text to find. Include enough surrounding context to uniquely identify the target.'),
554
+ new_string: z.string().describe('Replacement text.'),
555
+ allow_multiple: z.boolean().default(false).describe('Replace every occurrence (default false → exactly one occurrence required)'),
556
+ }),
557
+ readOnly: false,
558
+ metadata: { category: 'admin' },
559
+ async execute(params, _ctx) {
560
+ const validation = validatePath(repoRoot, params.path);
561
+ if ('error' in validation)
562
+ return { error: validation.error };
563
+ if (isReadOnlyPath(validation.relative)) {
564
+ return { error: `${validation.relative} is read-only (installed package)` };
565
+ }
566
+ let original;
567
+ try {
568
+ original = await readFile(validation.resolved, 'utf-8');
569
+ }
570
+ catch (err) {
571
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by instanceof + 'code' in err
572
+ const isNotFound = err instanceof Error && 'code' in err && err.code === 'ENOENT';
573
+ return { error: isNotFound ? `File not found: ${validation.relative}` : (err instanceof Error ? err.message : String(err)) };
574
+ }
575
+ const allowMultiple = params.allow_multiple ?? false;
576
+ // Count occurrences with a non-regex scan so the agent doesn't have
577
+ // to escape special characters in old_string.
578
+ const occurrences = countOccurrences(original, params.old_string);
579
+ if (occurrences === 0) {
580
+ return { error: `No occurrences of old_string found in ${validation.relative}. Verify whitespace, indentation, and surrounding context match exactly.` };
581
+ }
582
+ if (!allowMultiple && occurrences > 1) {
583
+ return { error: `Found ${String(occurrences)} occurrences of old_string in ${validation.relative}, expected exactly 1. Either add more surrounding context to uniquely identify the target, or set allow_multiple=true to replace all.` };
584
+ }
585
+ const updated = allowMultiple
586
+ ? original.split(params.old_string).join(params.new_string)
587
+ : original.replace(params.old_string, params.new_string);
588
+ await writeFile(validation.resolved, updated, 'utf-8');
589
+ return {
590
+ edited: validation.relative,
591
+ occurrences,
592
+ bytes_before: original.length,
593
+ bytes_after: updated.length,
594
+ };
595
+ },
596
+ };
597
+ }
598
+ function countOccurrences(haystack, needle) {
599
+ if (needle.length === 0)
600
+ return 0;
601
+ let count = 0;
602
+ let idx = 0;
603
+ while ((idx = haystack.indexOf(needle, idx)) !== -1) {
604
+ count++;
605
+ idx += needle.length;
606
+ }
607
+ return count;
608
+ }
609
+ // ---------------------------------------------------------------------------
610
+ // read_many_repo_files
611
+ // ---------------------------------------------------------------------------
612
+ export function createReadManyRepoFilesTool(repoRoot) {
613
+ return {
614
+ description: `Read multiple files from the agent repo in one call. Returns a structured array of {path, content} entries. Capped at ${String(READ_MANY_MAX_FILES)} files per call; each file truncated to ${String(READ_MANY_MAX_BYTES)} bytes (use read_repo_file for the full content of a truncated file). Use this when you need to compare/review several files together (e.g. "read every SKILL.md").`,
615
+ parameters: z.object({
616
+ paths: z.array(z.string().min(1)).min(1).describe('Array of file paths relative to repo root. Each must be in an allowed directory.'),
617
+ }),
618
+ readOnly: true,
619
+ metadata: { category: 'admin' },
620
+ async execute(params, _ctx) {
621
+ const requested = params.paths.slice(0, READ_MANY_MAX_FILES);
622
+ const excess = params.paths.length - requested.length;
623
+ const files = [];
624
+ for (const p of requested) {
625
+ const validation = validatePath(repoRoot, p);
626
+ if ('error' in validation) {
627
+ files.push({ path: p, error: validation.error });
628
+ continue;
629
+ }
630
+ let buf;
631
+ try {
632
+ buf = await readFile(validation.resolved);
633
+ }
634
+ catch (err) {
635
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by instanceof + 'code' in err
636
+ const isNotFound = err instanceof Error && 'code' in err && err.code === 'ENOENT';
637
+ files.push({
638
+ path: validation.relative,
639
+ error: isNotFound ? `File not found: ${validation.relative}` : (err instanceof Error ? err.message : String(err)),
640
+ });
641
+ continue;
642
+ }
643
+ if (isLikelyBinary(buf)) {
644
+ files.push({ path: validation.relative, error: 'Binary file — not returned' });
645
+ continue;
646
+ }
647
+ // Count lines in the ORIGINAL file (not the truncated content) so
648
+ // the agent knows how much more there is when content is truncated.
649
+ // READ_MANY_MAX_BYTES is a byte budget, so slice bytes then decode
650
+ // (may emit U+FFFD on a mid-multibyte cut — acceptable, matches
651
+ // previous behavior).
652
+ const fullText = buf.toString('utf-8');
653
+ const totalLines = countLines(fullText);
654
+ const truncated = buf.length > READ_MANY_MAX_BYTES;
655
+ const content = truncated
656
+ ? buf.subarray(0, READ_MANY_MAX_BYTES).toString('utf-8')
657
+ : fullText;
658
+ files.push({
659
+ path: validation.relative,
660
+ content,
661
+ total_lines: totalLines,
662
+ ...(truncated ? { truncated: true } : {}),
663
+ });
664
+ }
665
+ return { files, ...(excess > 0 ? { truncated: true, dropped: excess } : {}) };
666
+ },
667
+ };
668
+ }
669
+ // ---------------------------------------------------------------------------
161
670
  // internal_api
162
671
  // ---------------------------------------------------------------------------
163
672
  export function createInternalApiTool(getPort) {
@@ -194,7 +703,12 @@ export function createInternalApiTool(getPort) {
194
703
  export function registerAdminFileTools(registry, repoRoot, getPort) {
195
704
  registry.register('read_repo_file', createReadRepoFileTool(repoRoot));
196
705
  registry.register('write_repo_file', createWriteRepoFileTool(repoRoot));
706
+ registry.register('edit_repo_file', createEditRepoFileTool(repoRoot));
197
707
  registry.register('delete_repo_file', createDeleteRepoFileTool(repoRoot));
708
+ registry.register('list_repo_files', createListRepoFilesTool(repoRoot));
709
+ registry.register('glob_repo_files', createGlobRepoFilesTool(repoRoot));
710
+ registry.register('grep_repo_files', createGrepRepoFilesTool(repoRoot));
711
+ registry.register('read_many_repo_files', createReadManyRepoFilesTool(repoRoot));
198
712
  registry.register('internal_api', createInternalApiTool(getPort));
199
713
  }
200
714
  //# sourceMappingURL=admin-file-tools.js.map