@illuminis/comprism 0.1.5 → 0.1.7

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/out/agent/command.js +4 -4
  2. package/out/agent/render.d.ts +24 -0
  3. package/out/agent/render.js +35 -3
  4. package/out/agent/session.d.ts +6 -1
  5. package/out/agent/session.js +17 -8
  6. package/out/commands/agents.js +3 -3
  7. package/out/commands/ask.js +8 -8
  8. package/out/commands/codemap.d.ts +1 -1
  9. package/out/commands/codemap.js +3 -3
  10. package/out/commands/commands-thin.js +4 -4
  11. package/out/commands/config.js +1 -1
  12. package/out/commands/cost.js +4 -4
  13. package/out/commands/decisionEngine.d.ts +1 -0
  14. package/out/commands/decisionEngine.js +66 -0
  15. package/out/commands/hooks.js +1 -1
  16. package/out/commands/install.d.ts +1 -1
  17. package/out/commands/install.js +3 -2
  18. package/out/commands/instructions.js +1 -1
  19. package/out/commands/integrations.js +4 -4
  20. package/out/commands/keys.js +6 -6
  21. package/out/commands/login.js +37 -21
  22. package/out/commands/permissions.js +2 -2
  23. package/out/commands/plugins.js +3 -3
  24. package/out/commands/privacy.js +2 -2
  25. package/out/commands/repl.js +107 -83
  26. package/out/commands/review.js +6 -6
  27. package/out/commands/settings.js +30 -123
  28. package/out/commands/skills.js +2 -2
  29. package/out/commands/unattended.js +6 -6
  30. package/out/commands/update.js +1 -1
  31. package/out/commands/welcome.js +20 -29
  32. package/out/commands/worktrees.d.ts +1 -1
  33. package/out/graph/sync.d.ts +1 -1
  34. package/out/graph/sync.js +6 -6
  35. package/out/lib/attach.js +4 -3
  36. package/out/lib/commandlist.d.ts +1 -1
  37. package/out/lib/commandlist.js +2 -2
  38. package/out/lib/config.d.ts +3 -2
  39. package/out/lib/config.js +7 -8
  40. package/out/lib/connection.js +1 -1
  41. package/out/lib/gateway.d.ts +5 -5
  42. package/out/lib/machine.js +1 -1
  43. package/out/lib/project-ops.d.ts +1 -1
  44. package/out/lib/project-ops.js +4 -4
  45. package/out/lib/queue.js +1 -1
  46. package/out/lib/readiness.d.ts +3 -30
  47. package/out/lib/readiness.js +13 -82
  48. package/out/lib/servicecommand.d.ts +14 -0
  49. package/out/lib/servicecommand.js +74 -0
  50. package/out/lib/sessions.js +3 -3
  51. package/out/lib/ui.d.ts +9 -6
  52. package/out/lib/ui.js +20 -26
  53. package/out/lib/voice.d.ts +2 -39
  54. package/out/lib/voice.js +7 -148
  55. package/out/lib/words.d.ts +20 -0
  56. package/out/lib/words.js +27 -0
  57. package/out/machine/codemap/build.d.ts +45 -0
  58. package/out/machine/codemap/build.js +91 -0
  59. package/out/machine/codemap/facts.d.ts +47 -0
  60. package/out/machine/codemap/facts.js +12 -0
  61. package/out/machine/codemap/files.d.ts +45 -0
  62. package/out/machine/codemap/files.js +207 -0
  63. package/out/machine/codemap/read-locales.d.ts +29 -0
  64. package/out/machine/codemap/read-locales.js +246 -0
  65. package/out/machine/codemap/read-python.d.ts +11 -0
  66. package/out/machine/codemap/read-python.js +116 -0
  67. package/out/machine/codemap/read-typescript.d.ts +16 -0
  68. package/out/machine/codemap/read-typescript.js +292 -0
  69. package/out/machine/executor/browser.d.ts +14 -0
  70. package/out/machine/executor/browser.js +270 -0
  71. package/out/machine/executor/diagnostics.d.ts +2 -0
  72. package/out/machine/executor/diagnostics.js +181 -0
  73. package/out/machine/executor/documents.d.ts +40 -0
  74. package/out/machine/executor/documents.js +170 -0
  75. package/out/machine/executor/files.d.ts +2 -0
  76. package/out/machine/executor/files.js +590 -0
  77. package/out/machine/executor/git.d.ts +48 -0
  78. package/out/machine/executor/git.js +145 -0
  79. package/out/machine/executor/hooks.d.ts +51 -0
  80. package/out/machine/executor/hooks.js +154 -0
  81. package/out/machine/executor/index.d.ts +45 -0
  82. package/out/machine/executor/index.js +367 -0
  83. package/out/machine/executor/notebook.d.ts +2 -0
  84. package/out/machine/executor/notebook.js +147 -0
  85. package/out/machine/executor/paths.d.ts +20 -0
  86. package/out/machine/executor/paths.js +154 -0
  87. package/out/machine/executor/sandbox.d.ts +40 -0
  88. package/out/machine/executor/sandbox.js +299 -0
  89. package/out/machine/executor/shell.d.ts +86 -0
  90. package/out/machine/executor/shell.js +582 -0
  91. package/out/machine/executor/toolservers.d.ts +20 -0
  92. package/out/machine/executor/toolservers.js +189 -0
  93. package/out/machine/executor/worktree.d.ts +9 -0
  94. package/out/machine/executor/worktree.js +119 -0
  95. package/out/machine/folder/project.d.ts +28 -0
  96. package/out/machine/folder/project.js +114 -0
  97. package/out/machine/runtime/home.d.ts +2 -0
  98. package/out/machine/runtime/home.js +48 -0
  99. package/out/machine/runtime/needs.d.ts +30 -0
  100. package/out/machine/runtime/needs.js +89 -0
  101. package/out/machine/runtime/self.d.ts +23 -0
  102. package/out/machine/runtime/self.js +124 -0
  103. package/out/machine/voice/record.d.ts +38 -0
  104. package/out/machine/voice/record.js +208 -0
  105. package/out/providers/index.js +2 -1
  106. package/out/thin.js +18 -10
  107. package/package.json +4 -4
@@ -0,0 +1,2 @@
1
+ import { RunResult } from "./shell";
2
+ export declare function performFileAction(root: string, name: string, a: Record<string, unknown>): RunResult;
@@ -0,0 +1,590 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.performFileAction = performFileAction;
37
+ /**
38
+ * The file actions, on a real filesystem.
39
+ *
40
+ * Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
41
+ * items D1 to D16 and E6 to E8.
42
+ *
43
+ * The same twelve actions the browser performs, with the same refusals, against
44
+ * a real disk. **The refusals are the part worth reading**: an ambiguous edit,
45
+ * a path outside the project, a credentials file. An executor that reads and
46
+ * writes is easy; one that refuses correctly is the one somebody can leave
47
+ * running.
48
+ */
49
+ const child_process_1 = require("child_process");
50
+ const crypto_1 = require("crypto");
51
+ const fs = __importStar(require("fs"));
52
+ const path = __importStar(require("path"));
53
+ const paths_1 = require("./paths");
54
+ const MAX_FILE_CHARS = 60_000;
55
+ const MAX_RESULTS = 200;
56
+ const MAX_MAP_PATHS = 2_000;
57
+ /** Beyond this a file asked for whole is read as its first lines (manual 5.4). */
58
+ const MAX_WHOLE_LINES = 2_000;
59
+ const SKIP_DIRS = new Set([
60
+ "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".venv",
61
+ "venv", "env", "__pycache__", ".pytest_cache", ".mypy_cache", "coverage",
62
+ ".turbo", ".cache", "target", "vendor", ".idea", "Pods", ".gradle",
63
+ ".terraform", ".serverless", "site-packages",
64
+ ]);
65
+ const TEXT_EXT = new Set([
66
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "kt",
67
+ "swift", "c", "h", "cc", "cpp", "hpp", "cs", "php", "sh", "bash", "zsh",
68
+ "sql", "html", "css", "scss", "less", "json", "jsonc", "yaml", "yml", "toml",
69
+ "ini", "cfg", "conf", "md", "mdx", "txt", "rst", "csv", "tsv", "xml", "svg",
70
+ "vue", "svelte", "astro", "graphql", "gql", "proto", "lock", "prisma", "tf",
71
+ "r", "jl", "lua", "pl", "ex", "exs",
72
+ // Logs are the commonest large text file a person asks about (manual 6.13).
73
+ "log",
74
+ ]);
75
+ function isText(name) {
76
+ const lower = name.toLowerCase();
77
+ if (!lower.includes("."))
78
+ return ["makefile", "dockerfile", "gitignore"].includes(lower);
79
+ return TEXT_EXT.has(lower.split(".").pop());
80
+ }
81
+ function ok(content, summary) {
82
+ return { content, summary: summary ?? content.split("\n")[0].slice(0, 200) };
83
+ }
84
+ function fail(content) {
85
+ return { content, isError: true, summary: content.slice(0, 200) };
86
+ }
87
+ /** A project path as the agent sees it: forward slashes on every system.
88
+ * Windows would otherwise hand back `src\math.py`, which no pattern the model
89
+ * writes will ever match (manual 5.2). */
90
+ function slashed(rel) {
91
+ return rel.split(path.sep).join("/");
92
+ }
93
+ function walk(dir, root, out, limit, all = false) {
94
+ if (out.length >= limit)
95
+ return;
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(dir, { withFileTypes: true });
99
+ }
100
+ catch {
101
+ return;
102
+ }
103
+ for (const e of entries) {
104
+ if (out.length >= limit)
105
+ return;
106
+ const full = path.join(dir, e.name);
107
+ if (e.isDirectory()) {
108
+ // Git's own folder is never project content, even when asked for all.
109
+ if (e.name === ".git" || (!all && SKIP_DIRS.has(e.name)))
110
+ continue;
111
+ // Not followed. A link pointing outside the project would otherwise pull
112
+ // the whole machine into a file map, and `resolveInside` would never see
113
+ // it because nobody asked for that path by name.
114
+ if (e.isSymbolicLink())
115
+ continue;
116
+ walk(full, root, out, limit, all);
117
+ }
118
+ else if (e.isFile()) {
119
+ out.push(slashed(path.relative(root, full)));
120
+ }
121
+ }
122
+ }
123
+ /** Paths the service said to leave out of a search or listing, because a
124
+ * `deny` rule covers them (manual 4.11). The service decides; this only skips
125
+ * them, so nothing from a denied folder is read. The service still removes
126
+ * anything that slips through from what comes back. */
127
+ function denied(rel, exclude) {
128
+ if (!Array.isArray(exclude) || exclude.length === 0)
129
+ return false;
130
+ return exclude.some((raw) => {
131
+ const pattern = String(raw);
132
+ const base = pattern.endsWith("/**") ? pattern.slice(0, -3) : null;
133
+ if (base !== null && (rel === base || rel.startsWith(`${base}/`)))
134
+ return true;
135
+ return globToRegExp(pattern).test(rel);
136
+ });
137
+ }
138
+ /** What git itself would call this project's files: tracked, plus new files
139
+ * that `.gitignore` does not exclude. Git reads every ignore file, nested and
140
+ * global, so nothing here reimplements its rules. Null outside a repository,
141
+ * or when git is not installed, and the plain walk is used instead. */
142
+ function gitFiles(dir, root) {
143
+ let listed;
144
+ try {
145
+ // An argument list, never a shell string (COMMON.md, Windows quoting).
146
+ listed = (0, child_process_1.execFileSync)("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
147
+ cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
148
+ maxBuffer: 64 * 1024 * 1024, windowsHide: true,
149
+ });
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ const out = [];
155
+ for (const rel of listed.split("\0")) {
156
+ if (!rel)
157
+ continue;
158
+ // Dependency and build folders stay out even if someone committed them.
159
+ if (rel.split("/").some((part) => SKIP_DIRS.has(part)))
160
+ continue;
161
+ const full = path.join(dir, rel);
162
+ // A tracked file that was deleted, or a link, is not a file to read.
163
+ try {
164
+ if (!fs.lstatSync(full).isFile())
165
+ continue;
166
+ }
167
+ catch {
168
+ continue;
169
+ }
170
+ out.push(slashed(path.relative(root, full)));
171
+ }
172
+ return out;
173
+ }
174
+ /** The files under `dir` a search looks at. Ignored files, dependency folders
175
+ * and build output are left out unless `all` is asked for (manual 5.2). */
176
+ function projectFiles(dir, root, limit, all) {
177
+ // A search limited to one file searches that file (manual 5.3).
178
+ try {
179
+ if (fs.statSync(dir).isFile())
180
+ return [slashed(path.relative(root, dir))];
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ const listed = all ? null : gitFiles(dir, root);
186
+ if (listed)
187
+ return listed.sort().slice(0, limit);
188
+ const out = [];
189
+ walk(dir, root, out, limit, all);
190
+ return out.sort();
191
+ }
192
+ function globToRegExp(pattern) {
193
+ let out = "";
194
+ for (let i = 0; i < pattern.length; i += 1) {
195
+ const ch = pattern[i];
196
+ if (ch === "*") {
197
+ if (pattern[i + 1] === "*") {
198
+ out += ".*";
199
+ i += 1;
200
+ if (pattern[i + 1] === "/")
201
+ i += 1;
202
+ }
203
+ else
204
+ out += "[^/]*";
205
+ }
206
+ else if (ch === "?")
207
+ out += "[^/]";
208
+ else
209
+ out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
210
+ }
211
+ return new RegExp(`^${out}$`);
212
+ }
213
+ /** What each file held when the agent last read or wrote it, by real path, so
214
+ * an edit whose target has gone can say the file changed since it was read
215
+ * (manual 5.6). Only a fingerprint is kept, never the text. */
216
+ const seen = new Map();
217
+ function fingerprint(text) {
218
+ return (0, crypto_1.createHash)("sha256").update(text).digest("hex");
219
+ }
220
+ /** Lines taken out and put in by replacing `from` with `to`. */
221
+ function linesIn(text) {
222
+ if (!text)
223
+ return 0;
224
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
225
+ }
226
+ /** Lines really taken out and put in: lines the edit repeated unchanged at
227
+ * either end, for context, are not counted as changed (manual 5.6). */
228
+ function trimmedCounts(from, to) {
229
+ const a = from.replace(/\n$/, "").split("\n");
230
+ const b = to.replace(/\n$/, "").split("\n");
231
+ if (!from)
232
+ return [0, linesIn(to)];
233
+ if (!to)
234
+ return [linesIn(from), 0];
235
+ let head = 0;
236
+ while (head < a.length && head < b.length && a[head] === b[head])
237
+ head += 1;
238
+ let tail = 0;
239
+ while (tail < a.length - head && tail < b.length - head
240
+ && a[a.length - 1 - tail] === b[b.length - 1 - tail])
241
+ tail += 1;
242
+ return [a.length - head - tail, b.length - head - tail];
243
+ }
244
+ function lineCounts(from, to) {
245
+ const [removed, added] = trimmedCounts(from, to);
246
+ return { changed: 1, lines_removed: removed, lines_added: added };
247
+ }
248
+ const STALE = (rel) => `Refused, and nothing was written: the file changed since it was read. ${rel} no longer holds that text, `
249
+ + "so somebody or something else edited it. Read it again and edit what is there now.";
250
+ function countOf(haystack, needle) {
251
+ if (!needle)
252
+ return 0;
253
+ let n = 0, at = haystack.indexOf(needle);
254
+ while (at !== -1) {
255
+ n += 1;
256
+ at = haystack.indexOf(needle, at + needle.length);
257
+ }
258
+ return n;
259
+ }
260
+ function performFileAction(root, name, a) {
261
+ const realRoot = fs.realpathSync(root);
262
+ try {
263
+ switch (name) {
264
+ case "read_file": {
265
+ const rel = String(a.path ?? "");
266
+ if ((0, paths_1.isSecret)(rel)) {
267
+ return fail(`Refused: ${rel} holds credentials, and credentials are never read into a prompt.`);
268
+ }
269
+ const p = (0, paths_1.resolveInside)(realRoot, rel, true);
270
+ if (!isText(path.basename(p))) {
271
+ const size = fs.statSync(p).size;
272
+ return fail(`${rel} is not a text file (${size.toLocaleString()} bytes). Reading it would be noise rather than context.`);
273
+ }
274
+ const whole = fs.readFileSync(p, "utf8");
275
+ seen.set(p, fingerprint(whole));
276
+ const lines = whole.split("\n");
277
+ // A file ending in a newline has no line after it.
278
+ if (lines.length > 1 && lines[lines.length - 1] === "")
279
+ lines.pop();
280
+ const total = lines.length;
281
+ const offset = Number(a.offset ?? 0), limit = Number(a.limit ?? 0);
282
+ const ranged = offset > 0 || limit > 0;
283
+ // Small files are read whole. A large one asked for whole gets its
284
+ // first lines and is told how to reach the rest, so a 5,000 line file
285
+ // never lands in the conversation entire (manual 5.4).
286
+ const large = total > MAX_WHOLE_LINES || whole.length > MAX_FILE_CHARS;
287
+ const from = Math.min(Math.max(0, offset > 0 ? offset - 1 : 0), total);
288
+ let to = ranged ? (limit > 0 ? Math.min(total, from + limit) : total)
289
+ : large ? Math.min(total, MAX_WHOLE_LINES) : total;
290
+ let body = lines.slice(from, to).join("\n");
291
+ if (body.length > MAX_FILE_CHARS) {
292
+ // Cut on a line, so the range reported is the range given.
293
+ let size = 0, kept = from;
294
+ while (kept < to && size + lines[kept].length + 1 <= MAX_FILE_CHARS) {
295
+ size += lines[kept].length + 1;
296
+ kept += 1;
297
+ }
298
+ to = Math.max(kept, from + 1);
299
+ body = lines.slice(from, to).join("\n");
300
+ }
301
+ const n = (x) => x.toLocaleString("en-US");
302
+ if (to < total || from > 0) {
303
+ body += `\n\n[Lines ${n(from + 1)} to ${n(to)} of ${n(total)}. `
304
+ + "Use grep to find what you need, then offset and limit to read just those lines.]";
305
+ }
306
+ return {
307
+ content: body,
308
+ summary: `read ${rel} lines ${n(from + 1)} to ${n(to)} of ${n(total)}`,
309
+ facts: { lines_from: total ? from + 1 : 0, lines_to: to, lines_total: total },
310
+ };
311
+ }
312
+ case "list_dir": {
313
+ const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
314
+ const here = slashed(path.relative(realRoot, p));
315
+ const rows = fs.readdirSync(p, { withFileTypes: true })
316
+ .filter((e) => !denied(here ? `${here}/${e.name}` : e.name, a.exclude))
317
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort();
318
+ return ok(rows.join("\n") || "(empty)", `${rows.length} entries`);
319
+ }
320
+ case "file_map": {
321
+ const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
322
+ const found = projectFiles(p, realRoot, Number.MAX_SAFE_INTEGER, Boolean(a.include_ignored))
323
+ .filter((f) => !denied(f, a.exclude));
324
+ const shown = found.slice(0, MAX_MAP_PATHS);
325
+ const note = found.length > shown.length
326
+ ? `\n\n[... ${found.length - shown.length} more paths left out. Map a subfolder for the rest.]` : "";
327
+ return {
328
+ content: shown.join("\n") + note, summary: `${found.length} files`,
329
+ facts: { files: shown.length, left_out: found.length - shown.length },
330
+ };
331
+ }
332
+ case "glob": {
333
+ const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
334
+ const found = projectFiles(p, realRoot, Number.MAX_SAFE_INTEGER, Boolean(a.include_ignored))
335
+ .filter((f) => !denied(f, a.exclude));
336
+ const pattern = String(a.pattern ?? "");
337
+ const rx = globToRegExp(pattern);
338
+ // A bare name pattern ("calc.py", "*.py") means anywhere in the
339
+ // project, the way a person reads it, not only the top folder.
340
+ const anywhere = pattern.includes("/") ? null : globToRegExp(`**/${pattern}`);
341
+ const all = found.filter((f) => rx.test(f) || Boolean(anywhere?.test(f)));
342
+ // Newest first, as the tool description promises.
343
+ const mtime = (f) => { try {
344
+ return fs.statSync(path.join(realRoot, f)).mtimeMs;
345
+ }
346
+ catch {
347
+ return 0;
348
+ } };
349
+ const hits = all.map((f) => [f, mtime(f)])
350
+ .sort((x, y) => y[1] - x[1]).slice(0, MAX_RESULTS).map(([f]) => f);
351
+ const note = all.length > hits.length
352
+ ? `\n\n[... ${all.length - hits.length} more files left out. Narrow the pattern.]` : "";
353
+ return {
354
+ content: (hits.join("\n") || "(no matches)") + note, summary: `${all.length} files`,
355
+ facts: { files: hits.length, left_out: all.length - hits.length },
356
+ };
357
+ }
358
+ case "grep": {
359
+ const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
360
+ let rx;
361
+ try {
362
+ rx = new RegExp(String(a.pattern ?? ""), a.case_sensitive ? "" : "i");
363
+ }
364
+ catch (e) {
365
+ return fail(`That is not a valid regular expression: ${e.message}`);
366
+ }
367
+ const globText = a.glob ? String(a.glob) : "";
368
+ const globRx = globText ? globToRegExp(globText) : null;
369
+ const globAnywhere = globText && !globText.includes("/") ? globToRegExp(`**/${globText}`) : null;
370
+ const files = projectFiles(p, realRoot, Number.MAX_SAFE_INTEGER, Boolean(a.include_ignored))
371
+ .filter((f) => !denied(f, a.exclude));
372
+ const context = Math.max(0, Math.min(Number(a.context_lines ?? 0), 5));
373
+ const hits = [];
374
+ // Every match is counted, even past the cap, so the agent is told how
375
+ // many it did not see and can narrow the search (manual 5.3).
376
+ let matches = 0;
377
+ const matchedFiles = new Set();
378
+ for (const rel of files) {
379
+ const leaf = path.basename(rel);
380
+ if (!isText(leaf) || (0, paths_1.isSecret)(leaf))
381
+ continue;
382
+ if (globRx && !globRx.test(rel) && !globAnywhere?.test(rel))
383
+ continue;
384
+ let text;
385
+ try {
386
+ text = fs.readFileSync(path.join(realRoot, rel), "utf8");
387
+ }
388
+ catch {
389
+ continue;
390
+ }
391
+ const lines = text.split("\n");
392
+ for (let i = 0; i < lines.length; i += 1) {
393
+ if (!rx.test(lines[i]))
394
+ continue;
395
+ matches += 1;
396
+ matchedFiles.add(rel);
397
+ if (hits.length >= MAX_RESULTS)
398
+ continue;
399
+ if (context === 0)
400
+ hits.push(`${rel}:${i + 1}:${lines[i]}`);
401
+ else {
402
+ const from = Math.max(0, i - context), to = Math.min(lines.length, i + context + 1);
403
+ hits.push(`${rel}:${i + 1}:\n${lines.slice(from, to).join("\n")}`);
404
+ }
405
+ }
406
+ }
407
+ const leftOut = matches - hits.length;
408
+ const note = leftOut > 0
409
+ ? `\n\n[Showing ${hits.length} of ${matches.toLocaleString("en-US")} matches in ${matchedFiles.size} files; ${leftOut.toLocaleString("en-US")} left out. Narrow the pattern, the folder or the glob.]`
410
+ : "";
411
+ return {
412
+ content: (hits.join("\n") || "(no matches)") + note,
413
+ summary: `${matches} matches`,
414
+ facts: { matches: hits.length, left_out: leftOut, files: matchedFiles.size },
415
+ };
416
+ }
417
+ // Bytes rather than text, and the ONLY caller is the server writing a
418
+ // document it just built. A Word file, a deck or a PDF is not text, so it
419
+ // cannot come down the ordinary write, and a person who asked for a deck
420
+ // wants it in the folder they are standing in rather than behind a link.
421
+ //
422
+ // Deliberately NOT offered to the model. It is issued by the loop after
423
+ // `make_document`, in the same way the project's instruction file is read
424
+ // without the model asking. A model that could write arbitrary bytes into
425
+ // a repository would be a way to put something in a file that no diff
426
+ // shows and nobody reviews.
427
+ case "write_bytes": {
428
+ const rel = String(a.path ?? "");
429
+ if (a.base64 === undefined || a.base64 === null) {
430
+ return fail(`Refused, and nothing was written: write_bytes was called for ${rel} with no content.`);
431
+ }
432
+ const p = (0, paths_1.resolveInside)(realRoot, rel, false);
433
+ fs.mkdirSync(path.dirname(p), { recursive: true });
434
+ const replaced = fs.existsSync(p);
435
+ const bytes = Buffer.from(String(a.base64), "base64");
436
+ fs.writeFileSync(p, bytes);
437
+ return ok(`${replaced ? "Replaced" : "Saved"} ${rel} (${bytes.length.toLocaleString()} bytes).`, `${replaced ? "replaced" : "saved"} ${rel}`);
438
+ }
439
+ case "write_file": {
440
+ const rel = String(a.path ?? "");
441
+ // A call that arrives WITHOUT content is a malformed call, not a request
442
+ // for an empty file. Treating the two as the same is how a whole study
443
+ // was lost on 11 September 2026: three of four builds saved a blank page,
444
+ // this returned "ok", and nobody found out until the files were measured.
445
+ // The model was not at fault - it was told the write succeeded, and on one
446
+ // run it tried three times and was told so three times.
447
+ //
448
+ // An empty file is still reachable, deliberately, with content: "".
449
+ if (a.content === undefined || a.content === null) {
450
+ return fail(`Refused, and nothing was written: write_file was called for ${rel} with no content at all. Send the file's full text in "content". To create a genuinely empty file, pass an empty string.`);
451
+ }
452
+ const p = (0, paths_1.resolveInside)(realRoot, rel, false);
453
+ fs.mkdirSync(path.dirname(p), { recursive: true });
454
+ const content = String(a.content);
455
+ // Whether this REPLACED something is checked before the write, because
456
+ // afterwards there is nothing left to tell from. Writing no longer stops
457
+ // to ask, so saying which of the two happened is the only way a person
458
+ // watching knows that a file they already had was just replaced. "wrote"
459
+ // reads identically in both cases and hides exactly the one that matters.
460
+ const replaced = fs.existsSync(p);
461
+ const lineCount = content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
462
+ if (replaced && fs.readFileSync(p, "utf8") === content) {
463
+ seen.set(p, fingerprint(content));
464
+ return {
465
+ content: `No change: ${rel} already holds exactly this. Nothing was written.`,
466
+ summary: `no change ${rel}`, facts: { changed: 0, created: 0, lines: lineCount },
467
+ };
468
+ }
469
+ fs.writeFileSync(p, content, "utf8");
470
+ seen.set(p, fingerprint(content));
471
+ const size = `${content.length.toLocaleString()} characters`;
472
+ const facts = { changed: 1, created: replaced ? 0 : 1, lines: lineCount };
473
+ return replaced
474
+ ? { ...ok(`Rewrote ${rel}, replacing what was there (${size}).`, `rewrote ${rel}`), facts }
475
+ : { ...ok(`Created ${rel} (${size}).`, `created ${rel}`), facts };
476
+ }
477
+ case "edit_file": {
478
+ const rel = String(a.path ?? "");
479
+ const p = (0, paths_1.resolveInside)(realRoot, rel, true);
480
+ const text = fs.readFileSync(p, "utf8");
481
+ const oldText = String(a.old_text ?? "");
482
+ const n = countOf(text, oldText);
483
+ if (n === 0) {
484
+ // Read before, and different now: somebody else changed it.
485
+ const before = seen.get(p);
486
+ if (before && before !== fingerprint(text))
487
+ return fail(STALE(rel));
488
+ return fail(`Refused: that exact text is not in ${rel}. Read the file again — whitespace and indentation have to match exactly.`);
489
+ }
490
+ if (n > 1) {
491
+ return fail(`Refused: that text appears ${n} times in ${rel}, so it is not clear which one you mean. Include more surrounding lines to make the target unique.`);
492
+ }
493
+ // Missing replacement text used to DELETE the matched section and report
494
+ // "edited". Worse than the write case, because it destroys work that was
495
+ // already there. Deleting a section on purpose still works, with
496
+ // new_text set to an empty string.
497
+ if (a.new_text === undefined || a.new_text === null) {
498
+ return fail(`Refused, and nothing was changed: edit_file was called for ${rel} with no replacement text. Send "new_text". To delete the matched section, pass an empty string.`);
499
+ }
500
+ const newText = String(a.new_text);
501
+ if (newText === oldText) {
502
+ return {
503
+ content: `No change: the replacement is the same as the text already in ${rel}. Nothing was written.`,
504
+ summary: `no change ${rel}`, facts: { changed: 0 },
505
+ };
506
+ }
507
+ // A function as the replacement, so a `$` in the new text is written
508
+ // as a `$` and never read as a pattern.
509
+ const next = text.replace(oldText, () => newText);
510
+ fs.writeFileSync(p, next, "utf8");
511
+ seen.set(p, fingerprint(next));
512
+ return { ...ok(`Edited ${rel}.`, `edited ${rel}`), facts: lineCounts(oldText, newText) };
513
+ }
514
+ case "multi_edit": {
515
+ const rel = String(a.path ?? "");
516
+ const p = (0, paths_1.resolveInside)(realRoot, rel, true);
517
+ const edits = a.edits ?? [];
518
+ let next = fs.readFileSync(p, "utf8");
519
+ for (let i = 0; i < edits.length; i += 1) {
520
+ const from = String(edits[i].old_text ?? "");
521
+ const n = countOf(next, from);
522
+ if (n === 0 && i === 0) {
523
+ const before = seen.get(p);
524
+ if (before && before !== fingerprint(next))
525
+ return fail(STALE(rel));
526
+ }
527
+ if (n !== 1) {
528
+ return fail(`Refused, and nothing was changed: edit ${i + 1} of ${edits.length} matches ${n} times in ${rel}. Every edit has to match exactly once.`);
529
+ }
530
+ // Same rule as edit_file, checked before anything is written, so a bad
531
+ // edit halfway down a list cannot leave the file half changed.
532
+ const to = edits[i].new_text;
533
+ if (to === undefined || to === null) {
534
+ return fail(`Refused, and nothing was changed: edit ${i + 1} of ${edits.length} for ${rel} has no replacement text. Send "new_text". To delete the matched section, pass an empty string.`);
535
+ }
536
+ next = next.replace(from, () => String(to));
537
+ }
538
+ const original = fs.readFileSync(p, "utf8");
539
+ if (next === original) {
540
+ return {
541
+ content: `No change: those edits leave ${rel} exactly as it was. Nothing was written.`,
542
+ summary: `no change ${rel}`, facts: { changed: 0 },
543
+ };
544
+ }
545
+ fs.writeFileSync(p, next, "utf8");
546
+ seen.set(p, fingerprint(next));
547
+ const counted = edits.map((e) => trimmedCounts(String(e.old_text ?? ""), String(e.new_text ?? "")));
548
+ const removed = counted.reduce((t, c) => t + c[0], 0);
549
+ const added = counted.reduce((t, c) => t + c[1], 0);
550
+ return {
551
+ ...ok(`Applied ${edits.length} edits to ${rel}.`, `${edits.length} edits`),
552
+ facts: { changed: 1, lines_removed: removed, lines_added: added },
553
+ };
554
+ }
555
+ case "create_dir": {
556
+ const rel = String(a.path ?? "");
557
+ fs.mkdirSync((0, paths_1.resolveInside)(realRoot, rel, false), { recursive: true });
558
+ return ok(`Created ${rel}.`, `created ${rel}`);
559
+ }
560
+ case "delete_file": {
561
+ const rel = String(a.path ?? "");
562
+ fs.unlinkSync((0, paths_1.resolveInside)(realRoot, rel, true));
563
+ return ok(`Deleted ${rel}.`, `deleted ${rel}`);
564
+ }
565
+ case "move_file": {
566
+ const rel = String(a.path ?? ""), to = String(a.to ?? "");
567
+ const from = (0, paths_1.resolveInside)(realRoot, rel, true);
568
+ const dest = (0, paths_1.resolveInside)(realRoot, to, false);
569
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
570
+ fs.renameSync(from, dest);
571
+ return ok(`Moved ${rel} to ${to}.`, `moved ${rel}`);
572
+ }
573
+ default:
574
+ return fail(`This client cannot do that: ${name}`);
575
+ }
576
+ }
577
+ catch (err) {
578
+ // The whole refusal, path and real location included, however long the
579
+ // path (manual 2.4). An ordinary failure summary stops at 200 characters.
580
+ if (err instanceof paths_1.OutsideWorkspace) {
581
+ return { content: err.message, isError: true, summary: err.message.slice(0, 2000) };
582
+ }
583
+ const e = err;
584
+ if (e.code === "ENOENT")
585
+ return fail(`No such file: ${a.path ?? ""}`);
586
+ if (e.code === "EACCES")
587
+ return fail(`Permission denied: ${a.path ?? ""}`);
588
+ return fail(`This action could not be completed: ${e.message}`);
589
+ }
590
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Reading the repository, and recording work in it.
3
+ *
4
+ * Specification: docs/modules/CODING_AGENT_BUILD_SPECIFICATION.md, register
5
+ * items D23 to D27.
6
+ *
7
+ * **Pushing is the only thing here that sends anything anywhere**, and it is
8
+ * the only one the server has to have allowed first. Pull requests, reviews,
9
+ * checks and releases are not git operations at all: they go through GitHub's
10
+ * own API, on the server, because they need the tenant's credential and that
11
+ * must never be sent to a client.
12
+ *
13
+ * So the split is by what each thing needs. A push needs the repository, which
14
+ * is here. A pull request needs the credential, which is there.
15
+ *
16
+ * Every command goes through `runCommand`, so the folder boundary, the trimmed
17
+ * environment, the timeout and the output cap all apply here too rather than
18
+ * being reimplemented for git.
19
+ */
20
+ import { RunResult, ShellOptions } from "./shell";
21
+ export declare function gitStatus(opts: ShellOptions): Promise<RunResult>;
22
+ export declare function gitDiff(opts: ShellOptions & {
23
+ path?: string;
24
+ staged?: boolean;
25
+ }): Promise<RunResult>;
26
+ export declare function gitLog(opts: ShellOptions & {
27
+ path?: string;
28
+ limit?: number;
29
+ }): Promise<RunResult>;
30
+ export declare function gitBranch(name: string, opts: ShellOptions): Promise<RunResult>;
31
+ export declare function gitCommit(message: string, opts: ShellOptions & {
32
+ paths?: string[];
33
+ }): Promise<RunResult>;
34
+ /** Send commits to the remote.
35
+ *
36
+ * Reached only after the server has allowed it: the branch is one the person
37
+ * named for this repository, it is not a branch the project is built from, and
38
+ * they have just confirmed it. This function does not re-decide any of that,
39
+ * and it must not: a client that could decide whether it may publish is a
40
+ * client that could grant itself permission.
41
+ *
42
+ * **There is no force push and there is no upstream rewrite.** Not as an
43
+ * option, not behind a flag. Everything on the remote stays recoverable by
44
+ * whoever is working from it.
45
+ */
46
+ export declare function gitPush(branch: string, opts: ShellOptions & {
47
+ createRemote?: boolean;
48
+ }): Promise<RunResult>;