@illuminis/comprism 0.1.3 → 0.1.5

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 (142) hide show
  1. package/README.md +61 -2
  2. package/out/agent/command.d.ts +75 -4
  3. package/out/agent/command.js +221 -26
  4. package/out/agent/render.d.ts +56 -8
  5. package/out/agent/render.js +441 -39
  6. package/out/agent/session.d.ts +156 -2
  7. package/out/agent/session.js +795 -154
  8. package/out/commands/agents.d.ts +2 -0
  9. package/out/commands/agents.js +79 -0
  10. package/out/commands/ask.d.ts +1 -1
  11. package/out/commands/ask.js +88 -12
  12. package/out/commands/commands-thin.js +79 -17
  13. package/out/commands/config.d.ts +1 -0
  14. package/out/commands/config.js +138 -0
  15. package/out/commands/cost.d.ts +1 -0
  16. package/out/commands/cost.js +167 -0
  17. package/out/commands/hooks.d.ts +1 -0
  18. package/out/commands/hooks.js +83 -0
  19. package/out/commands/install.d.ts +44 -1
  20. package/out/commands/install.js +198 -4
  21. package/out/commands/instructions.d.ts +1 -0
  22. package/out/commands/instructions.js +113 -0
  23. package/out/commands/integrations.d.ts +3 -0
  24. package/out/commands/integrations.js +215 -0
  25. package/out/commands/jobs.d.ts +5 -0
  26. package/out/commands/jobs.js +157 -0
  27. package/out/commands/login.js +249 -43
  28. package/out/commands/memory.d.ts +3 -0
  29. package/out/commands/memory.js +113 -0
  30. package/out/commands/permissions.d.ts +1 -0
  31. package/out/commands/permissions.js +94 -0
  32. package/out/commands/plugins.d.ts +4 -0
  33. package/out/commands/plugins.js +192 -0
  34. package/out/commands/privacy.d.ts +1 -0
  35. package/out/commands/privacy.js +57 -0
  36. package/out/commands/providerKey.d.ts +32 -0
  37. package/out/commands/providerKey.js +108 -0
  38. package/out/commands/repl.d.ts +12 -1
  39. package/out/commands/repl.js +1649 -122
  40. package/out/commands/report.d.ts +39 -0
  41. package/out/commands/report.js +115 -0
  42. package/out/commands/review.d.ts +5 -0
  43. package/out/commands/review.js +223 -0
  44. package/out/commands/sessions.d.ts +23 -0
  45. package/out/commands/sessions.js +115 -0
  46. package/out/commands/settings.d.ts +3 -1
  47. package/out/commands/settings.js +18 -16
  48. package/out/commands/skills.d.ts +21 -0
  49. package/out/commands/skills.js +207 -0
  50. package/out/commands/unattended.d.ts +7 -0
  51. package/out/commands/unattended.js +351 -0
  52. package/out/commands/update.d.ts +1 -0
  53. package/out/commands/update.js +123 -0
  54. package/out/commands/welcome.d.ts +25 -1
  55. package/out/commands/welcome.js +71 -35
  56. package/out/commands/worktrees.d.ts +5 -0
  57. package/out/commands/worktrees.js +186 -0
  58. package/out/executor/browser.d.ts +14 -0
  59. package/out/executor/browser.js +270 -0
  60. package/out/executor/diagnostics.d.ts +2 -0
  61. package/out/executor/diagnostics.js +181 -0
  62. package/out/executor/files.js +270 -40
  63. package/out/executor/git.js +42 -29
  64. package/out/executor/hooks.d.ts +42 -58
  65. package/out/executor/hooks.js +89 -182
  66. package/out/executor/index.d.ts +21 -5
  67. package/out/executor/index.js +160 -14
  68. package/out/executor/paths.d.ts +6 -1
  69. package/out/executor/paths.js +34 -6
  70. package/out/executor/sandbox.d.ts +40 -0
  71. package/out/executor/sandbox.js +299 -0
  72. package/out/executor/shell.d.ts +49 -4
  73. package/out/executor/shell.js +302 -56
  74. package/out/executor/toolservers.d.ts +20 -0
  75. package/out/executor/toolservers.js +189 -0
  76. package/out/executor/worktree.d.ts +9 -0
  77. package/out/executor/worktree.js +119 -0
  78. package/out/graph/read-python.js +2 -1
  79. package/out/lib/attach.d.ts +56 -12
  80. package/out/lib/attach.js +230 -63
  81. package/out/lib/clipboard.d.ts +23 -0
  82. package/out/lib/clipboard.js +182 -0
  83. package/out/lib/commandlist.d.ts +20 -0
  84. package/out/lib/commandlist.js +58 -0
  85. package/out/lib/decision.d.ts +22 -0
  86. package/out/lib/decision.js +50 -0
  87. package/out/lib/fingerprint.d.ts +25 -0
  88. package/out/lib/fingerprint.js +58 -0
  89. package/out/lib/gateway.d.ts +190 -2
  90. package/out/lib/gateway.js +59 -4
  91. package/out/lib/history.d.ts +24 -0
  92. package/out/lib/history.js +137 -0
  93. package/out/lib/ide.d.ts +19 -0
  94. package/out/lib/ide.js +131 -0
  95. package/out/lib/keyboard.d.ts +98 -0
  96. package/out/lib/keyboard.js +390 -0
  97. package/out/lib/lastlogin.d.ts +14 -0
  98. package/out/lib/lastlogin.js +105 -0
  99. package/out/lib/machine.d.ts +21 -0
  100. package/out/lib/machine.js +91 -0
  101. package/out/lib/notify.d.ts +4 -0
  102. package/out/lib/notify.js +52 -0
  103. package/out/lib/output.d.ts +48 -0
  104. package/out/lib/output.js +108 -0
  105. package/out/lib/picker.d.ts +1 -0
  106. package/out/lib/picker.js +158 -0
  107. package/out/lib/project-ops.d.ts +19 -0
  108. package/out/lib/project-ops.js +146 -0
  109. package/out/lib/project.d.ts +28 -0
  110. package/out/lib/project.js +114 -0
  111. package/out/lib/prompt.d.ts +3 -0
  112. package/out/lib/prompt.js +36 -2
  113. package/out/lib/queue.d.ts +13 -0
  114. package/out/lib/queue.js +116 -0
  115. package/out/lib/readiness.d.ts +19 -0
  116. package/out/lib/readiness.js +170 -1
  117. package/out/lib/self.d.ts +23 -0
  118. package/out/lib/self.js +124 -0
  119. package/out/lib/sessions.d.ts +19 -0
  120. package/out/lib/sessions.js +221 -0
  121. package/out/lib/startcache.d.ts +13 -0
  122. package/out/lib/startcache.js +78 -0
  123. package/out/lib/stdin.d.ts +32 -0
  124. package/out/lib/stdin.js +117 -0
  125. package/out/lib/store.d.ts +40 -0
  126. package/out/lib/store.js +138 -0
  127. package/out/lib/sync.d.ts +18 -0
  128. package/out/lib/sync.js +81 -0
  129. package/out/lib/ui.d.ts +10 -4
  130. package/out/lib/ui.js +65 -25
  131. package/out/lib/voice.d.ts +36 -0
  132. package/out/lib/voice.js +160 -1
  133. package/out/native/dictate +0 -0
  134. package/out/postinstall.js +42 -17
  135. package/out/providers/anthropic.d.ts +22 -0
  136. package/out/providers/anthropic.js +80 -0
  137. package/out/providers/index.d.ts +8 -0
  138. package/out/providers/index.js +83 -0
  139. package/out/providers/openai.d.ts +11 -0
  140. package/out/providers/openai.js +57 -0
  141. package/out/thin.js +595 -33
  142. package/package.json +11 -50
@@ -46,12 +46,16 @@ exports.performFileAction = performFileAction;
46
46
  * writes is easy; one that refuses correctly is the one somebody can leave
47
47
  * running.
48
48
  */
49
+ const child_process_1 = require("child_process");
50
+ const crypto_1 = require("crypto");
49
51
  const fs = __importStar(require("fs"));
50
52
  const path = __importStar(require("path"));
51
53
  const paths_1 = require("./paths");
52
54
  const MAX_FILE_CHARS = 60_000;
53
55
  const MAX_RESULTS = 200;
54
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;
55
59
  const SKIP_DIRS = new Set([
56
60
  "node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".venv",
57
61
  "venv", "env", "__pycache__", ".pytest_cache", ".mypy_cache", "coverage",
@@ -65,6 +69,8 @@ const TEXT_EXT = new Set([
65
69
  "ini", "cfg", "conf", "md", "mdx", "txt", "rst", "csv", "tsv", "xml", "svg",
66
70
  "vue", "svelte", "astro", "graphql", "gql", "proto", "lock", "prisma", "tf",
67
71
  "r", "jl", "lua", "pl", "ex", "exs",
72
+ // Logs are the commonest large text file a person asks about (manual 6.13).
73
+ "log",
68
74
  ]);
69
75
  function isText(name) {
70
76
  const lower = name.toLowerCase();
@@ -78,7 +84,13 @@ function ok(content, summary) {
78
84
  function fail(content) {
79
85
  return { content, isError: true, summary: content.slice(0, 200) };
80
86
  }
81
- function walk(dir, root, out, limit) {
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) {
82
94
  if (out.length >= limit)
83
95
  return;
84
96
  let entries;
@@ -93,19 +105,89 @@ function walk(dir, root, out, limit) {
93
105
  return;
94
106
  const full = path.join(dir, e.name);
95
107
  if (e.isDirectory()) {
96
- if (SKIP_DIRS.has(e.name))
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)))
97
110
  continue;
98
111
  // Not followed. A link pointing outside the project would otherwise pull
99
112
  // the whole machine into a file map, and `resolveInside` would never see
100
113
  // it because nobody asked for that path by name.
101
114
  if (e.isSymbolicLink())
102
115
  continue;
103
- walk(full, root, out, limit);
116
+ walk(full, root, out, limit, all);
104
117
  }
105
118
  else if (e.isFile()) {
106
- out.push(path.relative(root, full));
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;
107
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 [];
108
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();
109
191
  }
110
192
  function globToRegExp(pattern) {
111
193
  let out = "";
@@ -128,6 +210,43 @@ function globToRegExp(pattern) {
128
210
  }
129
211
  return new RegExp(`^${out}$`);
130
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.";
131
250
  function countOf(haystack, needle) {
132
251
  if (!needle)
133
252
  return 0;
@@ -152,40 +271,89 @@ function performFileAction(root, name, a) {
152
271
  const size = fs.statSync(p).size;
153
272
  return fail(`${rel} is not a text file (${size.toLocaleString()} bytes). Reading it would be noise rather than context.`);
154
273
  }
155
- let body = fs.readFileSync(p, "utf8");
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;
156
281
  const offset = Number(a.offset ?? 0), limit = Number(a.limit ?? 0);
157
- if (offset > 0 || limit > 0) {
158
- const lines = body.split("\n");
159
- const from = Math.max(0, offset > 0 ? offset - 1 : 0);
160
- body = lines.slice(from, limit > 0 ? from + limit : undefined).join("\n");
161
- }
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");
162
291
  if (body.length > MAX_FILE_CHARS) {
163
- body = `${body.slice(0, MAX_FILE_CHARS)}\n\n[... truncated. Use offset and limit to read further.]`;
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.]";
164
305
  }
165
- return ok(body, `read ${rel}`);
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
+ };
166
311
  }
167
312
  case "list_dir": {
168
313
  const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
314
+ const here = slashed(path.relative(realRoot, p));
169
315
  const rows = fs.readdirSync(p, { withFileTypes: true })
316
+ .filter((e) => !denied(here ? `${here}/${e.name}` : e.name, a.exclude))
170
317
  .map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort();
171
318
  return ok(rows.join("\n") || "(empty)", `${rows.length} entries`);
172
319
  }
173
320
  case "file_map": {
174
321
  const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
175
- const found = [];
176
- walk(p, realRoot, found, MAX_MAP_PATHS);
177
- found.sort();
178
- const note = found.length >= MAX_MAP_PATHS
179
- ? `\n\n[... stopped at ${MAX_MAP_PATHS} paths. Map a subfolder for the rest.]` : "";
180
- return ok(found.join("\n") + note, `${found.length} files`);
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
+ };
181
331
  }
182
332
  case "glob": {
183
333
  const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
184
- const found = [];
185
- walk(p, realRoot, found, MAX_MAP_PATHS);
186
- const rx = globToRegExp(String(a.pattern ?? ""));
187
- const hits = found.filter((f) => rx.test(f)).slice(0, MAX_RESULTS);
188
- return ok(hits.join("\n") || "(no matches)", `${hits.length} files`);
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
+ };
189
357
  }
190
358
  case "grep": {
191
359
  const p = (0, paths_1.resolveInside)(realRoot, String(a.path ?? ""), true);
@@ -196,18 +364,22 @@ function performFileAction(root, name, a) {
196
364
  catch (e) {
197
365
  return fail(`That is not a valid regular expression: ${e.message}`);
198
366
  }
199
- const globRx = a.glob ? globToRegExp(String(a.glob)) : null;
200
- const files = [];
201
- walk(p, realRoot, files, MAX_MAP_PATHS);
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));
202
372
  const context = Math.max(0, Math.min(Number(a.context_lines ?? 0), 5));
203
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();
204
378
  for (const rel of files) {
205
- if (hits.length >= MAX_RESULTS)
206
- break;
207
379
  const leaf = path.basename(rel);
208
380
  if (!isText(leaf) || (0, paths_1.isSecret)(leaf))
209
381
  continue;
210
- if (globRx && !globRx.test(rel))
382
+ if (globRx && !globRx.test(rel) && !globAnywhere?.test(rel))
211
383
  continue;
212
384
  let text;
213
385
  try {
@@ -217,9 +389,13 @@ function performFileAction(root, name, a) {
217
389
  continue;
218
390
  }
219
391
  const lines = text.split("\n");
220
- for (let i = 0; i < lines.length && hits.length < MAX_RESULTS; i += 1) {
392
+ for (let i = 0; i < lines.length; i += 1) {
221
393
  if (!rx.test(lines[i]))
222
394
  continue;
395
+ matches += 1;
396
+ matchedFiles.add(rel);
397
+ if (hits.length >= MAX_RESULTS)
398
+ continue;
223
399
  if (context === 0)
224
400
  hits.push(`${rel}:${i + 1}:${lines[i]}`);
225
401
  else {
@@ -228,8 +404,15 @@ function performFileAction(root, name, a) {
228
404
  }
229
405
  }
230
406
  }
231
- const note = hits.length >= MAX_RESULTS ? `\n\n[... stopped at ${MAX_RESULTS} matches. Narrow the search.]` : "";
232
- return ok(hits.join("\n") + note || "(no matches)", `${hits.length} matches`);
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
+ };
233
416
  }
234
417
  // Bytes rather than text, and the ONLY caller is the server writing a
235
418
  // document it just built. A Word file, a deck or a PDF is not text, so it
@@ -275,11 +458,21 @@ function performFileAction(root, name, a) {
275
458
  // watching knows that a file they already had was just replaced. "wrote"
276
459
  // reads identically in both cases and hides exactly the one that matters.
277
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
+ }
278
469
  fs.writeFileSync(p, content, "utf8");
470
+ seen.set(p, fingerprint(content));
279
471
  const size = `${content.length.toLocaleString()} characters`;
472
+ const facts = { changed: 1, created: replaced ? 0 : 1, lines: lineCount };
280
473
  return replaced
281
- ? ok(`Rewrote ${rel}, replacing what was there (${size}).`, `rewrote ${rel}`)
282
- : ok(`Created ${rel} (${size}).`, `created ${rel}`);
474
+ ? { ...ok(`Rewrote ${rel}, replacing what was there (${size}).`, `rewrote ${rel}`), facts }
475
+ : { ...ok(`Created ${rel} (${size}).`, `created ${rel}`), facts };
283
476
  }
284
477
  case "edit_file": {
285
478
  const rel = String(a.path ?? "");
@@ -288,6 +481,10 @@ function performFileAction(root, name, a) {
288
481
  const oldText = String(a.old_text ?? "");
289
482
  const n = countOf(text, oldText);
290
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));
291
488
  return fail(`Refused: that exact text is not in ${rel}. Read the file again — whitespace and indentation have to match exactly.`);
292
489
  }
293
490
  if (n > 1) {
@@ -300,8 +497,19 @@ function performFileAction(root, name, a) {
300
497
  if (a.new_text === undefined || a.new_text === null) {
301
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.`);
302
499
  }
303
- fs.writeFileSync(p, text.replace(oldText, String(a.new_text)), "utf8");
304
- return ok(`Edited ${rel}.`, `edited ${rel}`);
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) };
305
513
  }
306
514
  case "multi_edit": {
307
515
  const rel = String(a.path ?? "");
@@ -311,6 +519,11 @@ function performFileAction(root, name, a) {
311
519
  for (let i = 0; i < edits.length; i += 1) {
312
520
  const from = String(edits[i].old_text ?? "");
313
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
+ }
314
527
  if (n !== 1) {
315
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.`);
316
529
  }
@@ -320,10 +533,24 @@ function performFileAction(root, name, a) {
320
533
  if (to === undefined || to === null) {
321
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.`);
322
535
  }
323
- next = next.replace(from, String(to));
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
+ };
324
544
  }
325
545
  fs.writeFileSync(p, next, "utf8");
326
- return ok(`Applied ${edits.length} edits to ${rel}.`, `${edits.length} edits`);
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
+ };
327
554
  }
328
555
  case "create_dir": {
329
556
  const rel = String(a.path ?? "");
@@ -348,8 +575,11 @@ function performFileAction(root, name, a) {
348
575
  }
349
576
  }
350
577
  catch (err) {
351
- if (err instanceof paths_1.OutsideWorkspace)
352
- return fail(err.message);
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
+ }
353
583
  const e = err;
354
584
  if (e.code === "ENOENT")
355
585
  return fail(`No such file: ${a.path ?? ""}`);
@@ -26,47 +26,45 @@ exports.gitPush = gitPush;
26
26
  * being reimplemented for git.
27
27
  */
28
28
  const shell_1 = require("./shell");
29
- /** Arguments passed as a list and quoted, never interpolated into a string.
29
+ /** Every git call is a program and a list of arguments, with no shell.
30
30
  *
31
31
  * A commit message is written by a model and can contain anything: quotes,
32
32
  * backticks, a newline, a semicolon. Pasting one into a shell string is how a
33
- * message becomes a second command. */
34
- function quote(value) {
35
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
36
- }
33
+ * message becomes a second command, and quoting it for one shell broke it on
34
+ * another: Windows kept the POSIX quotes as part of the message. */
35
+ const git = (args, opts) => (0, shell_1.runProgram)("git", args, opts);
37
36
  async function gitStatus(opts) {
38
37
  // Porcelain, because the human-readable format changes between versions and
39
38
  // the model has to be able to rely on the shape.
40
- const r = await (0, shell_1.runCommand)("git status --porcelain=v1 --branch", opts);
39
+ const r = await git(["status", "--porcelain=v1", "--branch"], opts);
41
40
  if (!r.isError && /^exit 0/.test(r.content) && r.content.trim().split("\n").length <= 3) {
42
41
  return { ...r, content: `${r.content}\n\n[Nothing has changed in the working tree.]` };
43
42
  }
44
43
  return r;
45
44
  }
46
45
  async function gitDiff(opts) {
47
- const parts = ["git", "--no-pager", "diff"];
46
+ const parts = ["--no-pager", "diff"];
48
47
  if (opts.staged)
49
48
  parts.push("--staged");
50
49
  // Color off explicitly: a diff full of escape codes costs tokens and reads
51
50
  // as noise to a model.
52
51
  parts.push("--no-color");
53
52
  if (opts.path)
54
- parts.push("--", quote(opts.path));
55
- return (0, shell_1.runCommand)(parts.join(" "), opts);
53
+ parts.push("--", opts.path);
54
+ return git(parts, opts);
56
55
  }
57
56
  async function gitLog(opts) {
58
57
  const limit = Math.max(1, Math.min(opts.limit ?? 20, 100));
59
58
  const parts = [
60
- "git", "--no-pager", "log", `-n ${limit}`,
61
- // Quoted. Everything here goes through a shell, so an unquoted format string
62
- // is split on its spaces and git reads "%an" as a revision. It fails with
63
- // "ambiguous argument", which reads like a broken repository rather than a
64
- // quoting mistake, and cost a test run to diagnose.
65
- `--pretty=${quote("format:%h %an %ar %s")}`,
59
+ "--no-pager", "log", "-n", String(limit),
60
+ // One argument, spaces and all. When this went through a shell it had to be
61
+ // quoted, or git read "%an" as a revision and failed with "ambiguous
62
+ // argument", which reads like a broken repository.
63
+ "--pretty=format:%h %an %ar %s",
66
64
  ];
67
65
  if (opts.path)
68
- parts.push("--", quote(opts.path));
69
- return (0, shell_1.runCommand)(parts.join(" "), opts);
66
+ parts.push("--", opts.path);
67
+ return git(parts, opts);
70
68
  }
71
69
  async function gitBranch(name, opts) {
72
70
  if (!/^[\w./-]{1,120}$/.test(name)) {
@@ -75,21 +73,27 @@ async function gitBranch(name, opts) {
75
73
  content: `Refused: ${name} is not a usable branch name. Letters, numbers, dots, dashes, underscores and slashes only.`,
76
74
  };
77
75
  }
78
- return (0, shell_1.runCommand)(`git checkout -b ${quote(name)}`, opts);
76
+ return git(["checkout", "-b", name], opts);
79
77
  }
80
78
  async function gitCommit(message, opts) {
81
79
  if (!message.trim()) {
82
80
  return { isError: true, content: "Refused: a commit needs a message." };
83
81
  }
84
- const stage = opts.paths?.length
85
- ? `git add -- ${opts.paths.map(quote).join(" ")}`
86
- : "git add -A";
87
- const staged = await (0, shell_1.runCommand)(stage, opts);
88
- if (staged.isError)
89
- return staged;
82
+ // Only the files named: what the agent changed, or what the person named.
83
+ // Never "add everything", which would sweep in the person's own unrelated
84
+ // work (manual 5.17).
85
+ if (!opts.paths?.length) {
86
+ return {
87
+ isError: true,
88
+ content: "Refused: nothing to commit that this session changed. Name the files to commit.",
89
+ };
90
+ }
91
+ const staged = await git(["add", "--", ...opts.paths], opts);
92
+ if (staged.isError || !/^exit 0/.test(staged.content))
93
+ return { ...staged, isError: true };
90
94
  // Nothing to commit is a normal outcome, not a failure. Reported plainly so
91
95
  // the model does not conclude that committing is broken and try again.
92
- const check = await (0, shell_1.runCommand)("git diff --staged --quiet", opts);
96
+ const check = await git(["diff", "--staged", "--quiet"], opts);
93
97
  if (/^exit 0\b/.test(check.content)) {
94
98
  return { content: "Nothing to commit: no changes are staged.", summary: "nothing to commit" };
95
99
  }
@@ -97,7 +101,13 @@ async function gitCommit(message, opts) {
97
101
  // a commit somebody later reads as a colleague's careful work, and the whole
98
102
  // record this product keeps depends on that not happening.
99
103
  const trailer = "\n\nCo-authored-by: illuminis coding agent <agent@illuminis.ai>";
100
- return (0, shell_1.runCommand)(`git commit -m ${quote(message + trailer)}`, opts);
104
+ const made = await git(["commit", "-m", message + trailer], opts);
105
+ // A commit hook that refused it is a failed commit, said so (manual 5.17).
106
+ // Its output is the reason; what was staged is left staged for the person.
107
+ if (!/^exit 0/.test(made.content)) {
108
+ return { ...made, isError: true, summary: "commit failed", content: `Commit failed. ${made.content}` };
109
+ }
110
+ return { ...made, summary: `committed ${opts.paths.join(", ")}` };
101
111
  }
102
112
  /** Send commits to the remote.
103
113
  *
@@ -117,11 +127,14 @@ async function gitPush(branch, opts) {
117
127
  }
118
128
  // What is about to go, named before it goes. A push that reports success
119
129
  // without saying what it sent is a push nobody can check afterwards.
120
- const ahead = await (0, shell_1.runCommand)(`git --no-pager log --oneline origin/${quote(branch)}..HEAD 2>/dev/null || git --no-pager log --oneline -5`, opts);
130
+ // A branch the remote has not seen yet has no range to compare with, so the
131
+ // last five commits stand in for it.
132
+ let ahead = await git(["--no-pager", "log", "--oneline", `origin/${branch}..HEAD`], opts);
133
+ if (!/^exit 0/.test(ahead.content))
134
+ ahead = await git(["--no-pager", "log", "--oneline", "-5"], opts);
121
135
  // `-u` on a new branch so later pushes need no arguments, and nothing else.
122
136
  // No `--force`, no `--force-with-lease`, no `+refs` refspec.
123
- const flags = opts.createRemote ? "-u" : "";
124
- const pushed = await (0, shell_1.runCommand)(`git push ${flags} origin ${quote(branch)}`.replace(/\s+/g, " "), opts);
137
+ const pushed = await git(["push", ...(opts.createRemote ? ["-u"] : []), "origin", branch], opts);
125
138
  if (pushed.isError || !/^exit 0/.test(pushed.content)) {
126
139
  return { ...pushed, isError: true };
127
140
  }