@amodalai/runtime 0.2.10 → 0.3.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 (139) hide show
  1. package/dist/src/__fixtures__/e2e.test.js +2 -2
  2. package/dist/src/__fixtures__/e2e.test.js.map +1 -1
  3. package/dist/src/__fixtures__/smoke.test.js +0 -88
  4. package/dist/src/__fixtures__/smoke.test.js.map +1 -1
  5. package/dist/src/__tests__/studio-integration.test.js +298 -0
  6. package/dist/src/__tests__/studio-integration.test.js.map +1 -0
  7. package/dist/src/agent/agent-types.d.ts +4 -0
  8. package/dist/src/agent/feedback-store.d.ts +11 -10
  9. package/dist/src/agent/feedback-store.js +147 -75
  10. package/dist/src/agent/feedback-store.js.map +1 -1
  11. package/dist/src/agent/local-server.js +30 -111
  12. package/dist/src/agent/local-server.js.map +1 -1
  13. package/dist/src/agent/local-server.test.js +17 -1
  14. package/dist/src/agent/local-server.test.js.map +1 -1
  15. package/dist/src/agent/routes/context.d.ts +24 -0
  16. package/dist/src/agent/routes/context.js +30 -0
  17. package/dist/src/agent/routes/context.js.map +1 -0
  18. package/dist/src/agent/routes/feedback.js +28 -56
  19. package/dist/src/agent/routes/feedback.js.map +1 -1
  20. package/dist/src/api/create-agent.js +8 -4
  21. package/dist/src/api/create-agent.js.map +1 -1
  22. package/dist/src/api/types.d.ts +1 -1
  23. package/dist/src/channels/channel-session-mapper.js +1 -1
  24. package/dist/src/channels/channel-session-mapper.js.map +1 -1
  25. package/dist/src/config.d.ts +2 -2
  26. package/dist/src/config.js +2 -1
  27. package/dist/src/config.js.map +1 -1
  28. package/dist/src/config.test.js +1 -1
  29. package/dist/src/config.test.js.map +1 -1
  30. package/dist/src/errors.d.ts +2 -2
  31. package/dist/src/errors.js +2 -2
  32. package/dist/src/index.d.ts +0 -3
  33. package/dist/src/index.js +0 -3
  34. package/dist/src/index.js.map +1 -1
  35. package/dist/src/session/drizzle-session-store.d.ts +4 -6
  36. package/dist/src/session/drizzle-session-store.js +15 -5
  37. package/dist/src/session/drizzle-session-store.js.map +1 -1
  38. package/dist/src/session/manager.js +1 -1
  39. package/dist/src/session/manager.test.js +7 -5
  40. package/dist/src/session/manager.test.js.map +1 -1
  41. package/dist/src/session/postgres-session-store.d.ts +3 -24
  42. package/dist/src/session/postgres-session-store.js +9 -128
  43. package/dist/src/session/postgres-session-store.js.map +1 -1
  44. package/dist/src/session/session-builder.d.ts +0 -4
  45. package/dist/src/session/session-builder.js +2 -9
  46. package/dist/src/session/session-builder.js.map +1 -1
  47. package/dist/src/session/session-builder.test.js +0 -25
  48. package/dist/src/session/session-builder.test.js.map +1 -1
  49. package/dist/src/session/session-store-selector.d.ts +11 -26
  50. package/dist/src/session/session-store-selector.js +3 -48
  51. package/dist/src/session/session-store-selector.js.map +1 -1
  52. package/dist/src/session/session-store-selector.test.js +5 -57
  53. package/dist/src/session/session-store-selector.test.js.map +1 -1
  54. package/dist/src/session/store.d.ts +8 -14
  55. package/dist/src/session/store.js +8 -10
  56. package/dist/src/session/store.js.map +1 -1
  57. package/dist/src/session/store.test.js +6 -126
  58. package/dist/src/session/store.test.js.map +1 -1
  59. package/dist/src/session/tool-context-factory.js +1 -1
  60. package/dist/src/session/tool-context-factory.js.map +1 -1
  61. package/dist/src/stores/drizzle-store-backend.d.ts +5 -0
  62. package/dist/src/stores/drizzle-store-backend.js +23 -3
  63. package/dist/src/stores/drizzle-store-backend.js.map +1 -1
  64. package/dist/src/stores/drizzle-store-backend.test.js +10 -58
  65. package/dist/src/stores/drizzle-store-backend.test.js.map +1 -1
  66. package/dist/src/stores/index.d.ts +0 -2
  67. package/dist/src/stores/index.js +0 -1
  68. package/dist/src/stores/index.js.map +1 -1
  69. package/dist/src/stores/postgres-store-backend.d.ts +5 -15
  70. package/dist/src/stores/postgres-store-backend.js +14 -72
  71. package/dist/src/stores/postgres-store-backend.js.map +1 -1
  72. package/dist/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +4 -6
  74. package/dist/src/agent/automation-bridge.d.ts +0 -33
  75. package/dist/src/agent/automation-bridge.js +0 -50
  76. package/dist/src/agent/automation-bridge.js.map +0 -1
  77. package/dist/src/agent/automation-bridge.test.d.ts +0 -6
  78. package/dist/src/agent/automation-bridge.test.js +0 -130
  79. package/dist/src/agent/automation-bridge.test.js.map +0 -1
  80. package/dist/src/agent/eval-store.d.ts +0 -50
  81. package/dist/src/agent/eval-store.js +0 -137
  82. package/dist/src/agent/eval-store.js.map +0 -1
  83. package/dist/src/agent/proactive/delivery-router.d.ts +0 -68
  84. package/dist/src/agent/proactive/delivery-router.js +0 -337
  85. package/dist/src/agent/proactive/delivery-router.js.map +0 -1
  86. package/dist/src/agent/proactive/delivery-router.test.js +0 -455
  87. package/dist/src/agent/proactive/delivery-router.test.js.map +0 -1
  88. package/dist/src/agent/proactive/delivery.d.ts +0 -21
  89. package/dist/src/agent/proactive/delivery.js +0 -68
  90. package/dist/src/agent/proactive/delivery.js.map +0 -1
  91. package/dist/src/agent/proactive/delivery.test.d.ts +0 -6
  92. package/dist/src/agent/proactive/delivery.test.js +0 -65
  93. package/dist/src/agent/proactive/delivery.test.js.map +0 -1
  94. package/dist/src/agent/proactive/proactive-runner.d.ts +0 -129
  95. package/dist/src/agent/proactive/proactive-runner.js +0 -301
  96. package/dist/src/agent/proactive/proactive-runner.js.map +0 -1
  97. package/dist/src/agent/proactive/proactive-runner.test.d.ts +0 -6
  98. package/dist/src/agent/proactive/proactive-runner.test.js +0 -250
  99. package/dist/src/agent/proactive/proactive-runner.test.js.map +0 -1
  100. package/dist/src/agent/routes/admin-chat-abort.test.d.ts +0 -6
  101. package/dist/src/agent/routes/admin-chat-abort.test.js +0 -207
  102. package/dist/src/agent/routes/admin-chat-abort.test.js.map +0 -1
  103. package/dist/src/agent/routes/admin-chat.d.ts +0 -28
  104. package/dist/src/agent/routes/admin-chat.js +0 -110
  105. package/dist/src/agent/routes/admin-chat.js.map +0 -1
  106. package/dist/src/agent/routes/automations.d.ts +0 -19
  107. package/dist/src/agent/routes/automations.js +0 -86
  108. package/dist/src/agent/routes/automations.js.map +0 -1
  109. package/dist/src/agent/routes/automations.test.d.ts +0 -6
  110. package/dist/src/agent/routes/automations.test.js +0 -117
  111. package/dist/src/agent/routes/automations.test.js.map +0 -1
  112. package/dist/src/agent/routes/evals.d.ts +0 -17
  113. package/dist/src/agent/routes/evals.js +0 -389
  114. package/dist/src/agent/routes/evals.js.map +0 -1
  115. package/dist/src/agent/routes/webhooks.d.ts +0 -17
  116. package/dist/src/agent/routes/webhooks.js +0 -63
  117. package/dist/src/agent/routes/webhooks.js.map +0 -1
  118. package/dist/src/agent/routes/webhooks.test.d.ts +0 -6
  119. package/dist/src/agent/routes/webhooks.test.js +0 -100
  120. package/dist/src/agent/routes/webhooks.test.js.map +0 -1
  121. package/dist/src/session/pglite-session-store.d.ts +0 -23
  122. package/dist/src/session/pglite-session-store.js +0 -92
  123. package/dist/src/session/pglite-session-store.js.map +0 -1
  124. package/dist/src/stores/pglite-store-backend.d.ts +0 -39
  125. package/dist/src/stores/pglite-store-backend.js +0 -128
  126. package/dist/src/stores/pglite-store-backend.js.map +0 -1
  127. package/dist/src/stores/pglite-store-backend.test.d.ts +0 -6
  128. package/dist/src/stores/pglite-store-backend.test.js +0 -150
  129. package/dist/src/stores/pglite-store-backend.test.js.map +0 -1
  130. package/dist/src/stores/schema.d.ts +0 -593
  131. package/dist/src/stores/schema.js +0 -75
  132. package/dist/src/stores/schema.js.map +0 -1
  133. package/dist/src/tools/admin-file-tools.d.ts +0 -42
  134. package/dist/src/tools/admin-file-tools.js +0 -714
  135. package/dist/src/tools/admin-file-tools.js.map +0 -1
  136. package/dist/src/tools/admin-file-tools.test.d.ts +0 -6
  137. package/dist/src/tools/admin-file-tools.test.js +0 -523
  138. package/dist/src/tools/admin-file-tools.test.js.map +0 -1
  139. /package/dist/src/{agent/proactive/delivery-router.test.d.ts → __tests__/studio-integration.test.d.ts} +0 -0
@@ -1,714 +0,0 @@
1
- /**
2
- * @license
3
- * Copyright 2026 Amodal Labs, Inc.
4
- * SPDX-License-Identifier: MIT
5
- */
6
- /**
7
- * Admin file tools for managing agent repo files.
8
- *
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
23
- *
24
- * All tools enforce the same allowed-directory allowlist and never touch
25
- * sensitive files (.env, amodal.json, package.json, etc.).
26
- */
27
- import { readFile, writeFile, unlink, mkdir, stat, readdir } from 'node:fs/promises';
28
- import * as path from 'node:path';
29
- import { z } from 'zod';
30
- import { glob as globImpl } from 'glob';
31
- import { ConfigError } from '../errors.js';
32
- // ---------------------------------------------------------------------------
33
- // Path validation
34
- // ---------------------------------------------------------------------------
35
- const ALLOWED_REPO_DIRS = [
36
- 'skills/',
37
- 'knowledge/',
38
- 'connections/',
39
- 'stores/',
40
- 'pages/',
41
- 'automations/',
42
- 'evals/',
43
- 'agents/',
44
- 'tools/',
45
- 'node_modules/',
46
- ];
47
- const BLOCKED_FILENAMES = [
48
- '.env',
49
- 'amodal.json',
50
- 'package.json',
51
- 'pnpm-lock.yaml',
52
- 'tsconfig.json',
53
- ];
54
- const READ_ONLY_DIRS = [
55
- 'node_modules/',
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
- }
110
- /** @internal Exported for testing */
111
- export function isAllowedRepoPath(relPath) {
112
- const basename = path.basename(relPath);
113
- if (BLOCKED_FILENAMES.includes(basename))
114
- return false;
115
- return ALLOWED_REPO_DIRS.some((dir) => relPath.startsWith(dir));
116
- }
117
- function isReadOnlyPath(relPath) {
118
- return READ_ONLY_DIRS.some((dir) => relPath.startsWith(dir));
119
- }
120
- function validatePath(repoRoot, rawPath) {
121
- if (!rawPath || rawPath.startsWith('/')) {
122
- return { error: 'Path must be relative to the repo root (no leading /)' };
123
- }
124
- if (rawPath.includes('..')) {
125
- return { error: 'Path traversal (..) is not allowed' };
126
- }
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
- }
136
- if (!isAllowedRepoPath(normalized)) {
137
- return { error: `Path "${normalized}" is not in an allowed directory. Allowed: ${ALLOWED_REPO_DIRS.join(', ')}. Blocked files: ${BLOCKED_FILENAMES.join(', ')}` };
138
- }
139
- const resolved = path.resolve(repoRoot, normalized);
140
- if (!resolved.startsWith(repoRoot)) {
141
- return { error: 'Resolved path escapes the repo directory' };
142
- }
143
- return { resolved, relative: normalized };
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
- }
228
- // ---------------------------------------------------------------------------
229
- // read_repo_file
230
- // ---------------------------------------------------------------------------
231
- export function createReadRepoFileTool(repoRoot) {
232
- return {
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").`,
234
- parameters: z.object({
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)})`),
238
- }),
239
- readOnly: true,
240
- metadata: { category: 'admin' },
241
- async execute(params, _ctx) {
242
- const validation = validatePath(repoRoot, params.path);
243
- if ('error' in validation) {
244
- return { error: validation.error };
245
- }
246
- let buf;
247
- try {
248
- buf = await readFile(validation.resolved);
249
- }
250
- catch (err) {
251
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by instanceof + 'code' in err
252
- const isNotFound = err instanceof Error && 'code' in err && err.code === 'ENOENT';
253
- const msg = isNotFound ? `File not found: ${validation.relative}` : (err instanceof Error ? err.message : String(err));
254
- return { error: msg };
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
- };
282
- },
283
- };
284
- }
285
- // ---------------------------------------------------------------------------
286
- // write_repo_file
287
- // ---------------------------------------------------------------------------
288
- export function createWriteRepoFileTool(repoRoot) {
289
- return {
290
- description: 'Create or update a file in the agent repo. Allowed directories: skills/, knowledge/, connections/, stores/, pages/, automations/, evals/, agents/, tools/.',
291
- parameters: z.object({
292
- path: z.string().min(1).describe('File path relative to repo root'),
293
- content: z.string().min(1).describe('Full file content to write'),
294
- }),
295
- readOnly: false,
296
- metadata: { category: 'admin' },
297
- async execute(params, _ctx) {
298
- const validation = validatePath(repoRoot, params.path);
299
- if ('error' in validation) {
300
- return { error: validation.error };
301
- }
302
- if (isReadOnlyPath(validation.relative)) {
303
- return { error: `${validation.relative} is read-only (installed package)` };
304
- }
305
- await mkdir(path.dirname(validation.resolved), { recursive: true });
306
- await writeFile(validation.resolved, params.content, 'utf-8');
307
- return { written: validation.relative, bytes: params.content.length };
308
- },
309
- };
310
- }
311
- // ---------------------------------------------------------------------------
312
- // delete_repo_file
313
- // ---------------------------------------------------------------------------
314
- export function createDeleteRepoFileTool(repoRoot) {
315
- return {
316
- description: 'Delete a file from the agent repo. Always confirm with the user before deleting. Same directory restrictions as write_repo_file.',
317
- parameters: z.object({
318
- path: z.string().min(1).describe('File path relative to repo root'),
319
- }),
320
- readOnly: false,
321
- metadata: { category: 'admin' },
322
- async execute(params, _ctx) {
323
- const validation = validatePath(repoRoot, params.path);
324
- if ('error' in validation) {
325
- return { error: validation.error };
326
- }
327
- if (isReadOnlyPath(validation.relative)) {
328
- return { error: `${validation.relative} is read-only (installed package)` };
329
- }
330
- try {
331
- await unlink(validation.resolved);
332
- return { deleted: validation.relative };
333
- }
334
- catch (err) {
335
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- guarded by instanceof + 'code' in err
336
- const isNotFound = err instanceof Error && 'code' in err && err.code === 'ENOENT';
337
- const msg = isNotFound ? `File not found: ${validation.relative}` : (err instanceof Error ? err.message : String(err));
338
- return { error: msg };
339
- }
340
- },
341
- };
342
- }
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
- // ---------------------------------------------------------------------------
670
- // internal_api
671
- // ---------------------------------------------------------------------------
672
- export function createInternalApiTool(getPort) {
673
- return {
674
- description: "Query the amodal runtime's internal API. Use this to check eval results, connection health, agent context, store data, and automation status. Only GET requests.",
675
- parameters: z.object({
676
- endpoint: z.string().min(1).describe('API path (e.g. "/api/evals/runs", "/inspect/health")'),
677
- }),
678
- readOnly: true,
679
- metadata: { category: 'admin' },
680
- async execute(params, ctx) {
681
- const port = getPort();
682
- if (!port) {
683
- throw new ConfigError('Server not ready — cannot query internal API', {
684
- key: 'server.port',
685
- });
686
- }
687
- const resp = await fetch(`http://127.0.0.1:${String(port)}${params.endpoint}`, {
688
- signal: ctx.signal ?? AbortSignal.timeout(10_000),
689
- });
690
- const text = await resp.text();
691
- try {
692
- return { status: resp.status, data: JSON.parse(text) };
693
- }
694
- catch {
695
- return { status: resp.status, data: text };
696
- }
697
- },
698
- };
699
- }
700
- // ---------------------------------------------------------------------------
701
- // Register all admin tools
702
- // ---------------------------------------------------------------------------
703
- export function registerAdminFileTools(registry, repoRoot, getPort) {
704
- registry.register('read_repo_file', createReadRepoFileTool(repoRoot));
705
- registry.register('write_repo_file', createWriteRepoFileTool(repoRoot));
706
- registry.register('edit_repo_file', createEditRepoFileTool(repoRoot));
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));
712
- registry.register('internal_api', createInternalApiTool(getPort));
713
- }
714
- //# sourceMappingURL=admin-file-tools.js.map