@illuminis/comprism 0.1.2 → 0.1.4

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 (131) hide show
  1. package/README.md +98 -36
  2. package/out/agent/command.d.ts +75 -4
  3. package/out/agent/command.js +220 -25
  4. package/out/agent/render.d.ts +17 -2
  5. package/out/agent/render.js +157 -14
  6. package/out/agent/session.d.ts +141 -2
  7. package/out/agent/session.js +735 -149
  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 +78 -11
  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 +188 -36
  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 +8 -1
  39. package/out/commands/repl.js +1207 -118
  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/worktrees.d.ts +5 -0
  55. package/out/commands/worktrees.js +186 -0
  56. package/out/executor/browser.d.ts +14 -0
  57. package/out/executor/browser.js +270 -0
  58. package/out/executor/diagnostics.d.ts +2 -0
  59. package/out/executor/diagnostics.js +181 -0
  60. package/out/executor/files.js +270 -40
  61. package/out/executor/git.js +42 -29
  62. package/out/executor/hooks.d.ts +42 -58
  63. package/out/executor/hooks.js +89 -182
  64. package/out/executor/index.d.ts +21 -5
  65. package/out/executor/index.js +160 -14
  66. package/out/executor/paths.d.ts +6 -1
  67. package/out/executor/paths.js +34 -6
  68. package/out/executor/sandbox.d.ts +40 -0
  69. package/out/executor/sandbox.js +299 -0
  70. package/out/executor/shell.d.ts +49 -4
  71. package/out/executor/shell.js +302 -56
  72. package/out/executor/toolservers.d.ts +20 -0
  73. package/out/executor/toolservers.js +189 -0
  74. package/out/executor/worktree.d.ts +9 -0
  75. package/out/executor/worktree.js +119 -0
  76. package/out/graph/read-python.js +2 -1
  77. package/out/lib/attach.d.ts +56 -12
  78. package/out/lib/attach.js +230 -63
  79. package/out/lib/clipboard.d.ts +23 -0
  80. package/out/lib/clipboard.js +182 -0
  81. package/out/lib/commandlist.d.ts +20 -0
  82. package/out/lib/commandlist.js +58 -0
  83. package/out/lib/decision.d.ts +22 -0
  84. package/out/lib/decision.js +50 -0
  85. package/out/lib/fingerprint.d.ts +25 -0
  86. package/out/lib/fingerprint.js +58 -0
  87. package/out/lib/gateway.d.ts +186 -2
  88. package/out/lib/gateway.js +59 -4
  89. package/out/lib/history.d.ts +24 -0
  90. package/out/lib/history.js +137 -0
  91. package/out/lib/ide.d.ts +19 -0
  92. package/out/lib/ide.js +131 -0
  93. package/out/lib/keyboard.d.ts +95 -0
  94. package/out/lib/keyboard.js +383 -0
  95. package/out/lib/machine.d.ts +21 -0
  96. package/out/lib/machine.js +91 -0
  97. package/out/lib/notify.d.ts +4 -0
  98. package/out/lib/notify.js +52 -0
  99. package/out/lib/output.d.ts +48 -0
  100. package/out/lib/output.js +108 -0
  101. package/out/lib/project-ops.d.ts +19 -0
  102. package/out/lib/project-ops.js +146 -0
  103. package/out/lib/project.d.ts +28 -0
  104. package/out/lib/project.js +114 -0
  105. package/out/lib/prompt.js +15 -2
  106. package/out/lib/queue.d.ts +13 -0
  107. package/out/lib/queue.js +116 -0
  108. package/out/lib/readiness.d.ts +19 -0
  109. package/out/lib/readiness.js +170 -1
  110. package/out/lib/self.d.ts +23 -0
  111. package/out/lib/self.js +124 -0
  112. package/out/lib/sessions.d.ts +19 -0
  113. package/out/lib/sessions.js +221 -0
  114. package/out/lib/stdin.d.ts +32 -0
  115. package/out/lib/stdin.js +117 -0
  116. package/out/lib/store.d.ts +40 -0
  117. package/out/lib/store.js +138 -0
  118. package/out/lib/sync.d.ts +18 -0
  119. package/out/lib/sync.js +81 -0
  120. package/out/lib/ui.d.ts +2 -4
  121. package/out/lib/ui.js +31 -25
  122. package/out/lib/voice.js +24 -0
  123. package/out/postinstall.js +42 -14
  124. package/out/providers/anthropic.d.ts +22 -0
  125. package/out/providers/anthropic.js +80 -0
  126. package/out/providers/index.d.ts +8 -0
  127. package/out/providers/index.js +83 -0
  128. package/out/providers/openai.d.ts +11 -0
  129. package/out/providers/openai.js +57 -0
  130. package/out/thin.js +609 -21
  131. package/package.json +9 -49
@@ -0,0 +1,181 @@
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.readDiagnostics = readDiagnostics;
37
+ /**
38
+ * `read_diagnostics` in a terminal (manual 5.11): the project's own checker
39
+ * for the language of the changed file, run without installing anything.
40
+ *
41
+ * TypeScript's `tsc` for `.ts` and `.tsx`, Pyright for `.py`, each only when it
42
+ * is installed in the project or on this machine. When neither is, the result
43
+ * says the file could not be checked, so the agent never reads silence as
44
+ * "correct". One language at a time; others are added the same way.
45
+ */
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const paths_1 = require("./paths");
49
+ const shell_1 = require("./shell");
50
+ const self_1 = require("../lib/self");
51
+ /** A program from the project's own node_modules, walking up, then the PATH. */
52
+ function findTool(root, name) {
53
+ const exts = process.platform === "win32" ? [".cmd", ".exe", ""] : [""];
54
+ for (let dir = root;; dir = path.dirname(dir)) {
55
+ for (const e of exts) {
56
+ const p = path.join(dir, "node_modules", ".bin", name + e);
57
+ if (fs.existsSync(p))
58
+ return p;
59
+ }
60
+ if (path.dirname(dir) === dir)
61
+ break;
62
+ }
63
+ for (const d of (process.env.PATH ?? "").split(path.delimiter)) {
64
+ for (const e of exts) {
65
+ const p = path.join(d, name + e);
66
+ try {
67
+ if (fs.statSync(p).isFile())
68
+ return p;
69
+ }
70
+ catch { /* not here */ }
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ function slashed(p) {
76
+ return p.split(path.sep).join("/");
77
+ }
78
+ /** TypeScript's own script in the project's node_modules, walking up. Run
79
+ * with Node directly, so no `.cmd` launcher is involved on Windows. */
80
+ function findScript(root, parts) {
81
+ for (let dir = root;; dir = path.dirname(dir)) {
82
+ const p = path.join(dir, "node_modules", ...parts);
83
+ if (fs.existsSync(p))
84
+ return p;
85
+ if (path.dirname(dir) === dir)
86
+ return null;
87
+ }
88
+ }
89
+ /** A tool as a program and its first arguments. */
90
+ function launcher(root, name, script) {
91
+ // The ready to run program cannot run another script, so it uses the
92
+ // project's own launcher, which the project's own Node runs.
93
+ const js = (0, self_1.isStandalone)() ? null : findScript(root, script);
94
+ if (js)
95
+ return [process.execPath, [js]];
96
+ const tool = findTool(root, name);
97
+ if (!tool)
98
+ return null;
99
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(tool))
100
+ return ["cmd.exe", ["/d", "/c", tool]];
101
+ return [tool, []];
102
+ }
103
+ async function typescript(root, file, opts) {
104
+ const found0 = launcher(root, "tsc", ["typescript", "bin", "tsc"]);
105
+ if (!found0)
106
+ return null;
107
+ const [tsc, pre] = found0;
108
+ const hasConfig = fs.existsSync(path.join(root, "tsconfig.json"));
109
+ const args = hasConfig ? ["--noEmit", "--pretty", "false", "-p", "."]
110
+ : ["--noEmit", "--pretty", "false", "--strict", ...(file ? [file] : [])];
111
+ const r = await (0, shell_1.runProgram)(tsc, [...pre, ...args], { ...opts, root, timeoutMs: 120_000 });
112
+ const found = [];
113
+ for (const line of r.content.split("\n")) {
114
+ const m = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+: .*)$/.exec(line.trim());
115
+ if (m)
116
+ found.push({ file: slashed(path.relative(root, path.resolve(root, m[1]))), line: Number(m[2]),
117
+ col: Number(m[3]), severity: m[4], text: m[5] });
118
+ }
119
+ return found;
120
+ }
121
+ async function pyright(root, file, opts) {
122
+ const found0 = launcher(root, "pyright", ["pyright", "index.js"]);
123
+ if (!found0)
124
+ return null;
125
+ const [tool, pre] = found0;
126
+ const r = await (0, shell_1.runProgram)(tool, [...pre, "--outputjson", ...(file ? [file] : [])], { ...opts, root, timeoutMs: 120_000 });
127
+ const text = r.content.replace(/^exit -?\d+\n\n/, "");
128
+ try {
129
+ const data = JSON.parse(text.slice(text.indexOf("{")));
130
+ return (data.generalDiagnostics ?? []).map((d) => ({
131
+ file: slashed(path.relative(root, String(d.file))), line: Number(d.range?.start?.line ?? 0) + 1,
132
+ col: Number(d.range?.start?.character ?? 0) + 1, severity: String(d.severity), text: String(d.message),
133
+ }));
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ }
139
+ async function readDiagnostics(root, rel, severity, opts) {
140
+ const realRoot = fs.realpathSync(root);
141
+ const file = rel ? slashed(path.relative(realRoot, (0, paths_1.resolveInside)(realRoot, rel, true))) : null;
142
+ const ext = (file ?? "").toLowerCase().split(".").pop() ?? "";
143
+ const kinds = file ? (["ts", "tsx", "mts", "cts"].includes(ext) ? ["ts"] : ext === "py" ? ["py"] : [])
144
+ : ["ts", "py"];
145
+ if (!kinds.length) {
146
+ return { content: `No checker is set up for .${ext} files, so ${file} could not be checked. `
147
+ + "Run the tests instead, and do not assume it is correct.", summary: "could not check",
148
+ facts: { checked: 0 } };
149
+ }
150
+ const found = [];
151
+ const missing = [];
152
+ let ran = 0;
153
+ for (const kind of kinds) {
154
+ const got = kind === "ts" ? await typescript(realRoot, file, opts) : await pyright(realRoot, file, opts);
155
+ if (got === null) {
156
+ missing.push(kind === "ts" ? "TypeScript's checker (tsc)" : "Pyright");
157
+ continue;
158
+ }
159
+ ran += 1;
160
+ // The changed file's own problems first, then what the change broke
161
+ // elsewhere: a wrong return type shows up where the function is used.
162
+ found.push(...got.filter((p) => !file || p.file === file), ...got.filter((p) => file && p.file !== file));
163
+ }
164
+ if (!ran) {
165
+ return {
166
+ content: `${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} not installed here, so `
167
+ + `${file ?? "the project"} could not be checked. Run the tests instead, and do not claim the code is correct.`,
168
+ summary: "could not check", facts: { checked: 0 },
169
+ };
170
+ }
171
+ const wanted = severity === "all" ? found : found.filter((p) => p.severity === (severity || "error"));
172
+ const errors = found.filter((p) => p.severity === "error").length;
173
+ const lines = wanted.slice(0, 50).map((p) => `${p.file}:${p.line}:${p.col} ${p.severity} ${p.text}`);
174
+ if (wanted.length > 50)
175
+ lines.push(`[... ${wanted.length - 50} more left out]`);
176
+ return {
177
+ content: lines.length ? lines.join("\n") : `No ${severity === "all" ? "problems" : "errors"} in ${file ?? "the project"}.`,
178
+ summary: `${errors} errors`,
179
+ facts: { checked: 1, errors, warnings: found.length - errors },
180
+ };
181
+ }
@@ -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 ?? ""}`);