acuvo-code 0.2.0
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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/repo-map.mjs
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── lib/repo-map.mjs — LET THE MODEL SEE THE WHOLE REPO, CHEAPLY ────────────
|
|
3
|
+
*
|
|
4
|
+
* THE MEASURED DEFECT THIS REPLACES. `gatherWorkspaceContext` (lib/turn.mjs)
|
|
5
|
+
* walks TWO directory levels and inlines whole file BODIES in ALPHABETICAL
|
|
6
|
+
* order, capped at 12 files / 40KB. On any real repository that shows the model
|
|
7
|
+
* roughly 5% of the paths, and spends thousands of tokens doing it — on
|
|
8
|
+
* READMEs, changelogs and build junk, because alphabetical order is not
|
|
9
|
+
* relevance order. The file the model actually needs is invisible.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ AND AN INVISIBLE FILE IS NOT A NEUTRAL ABSENCE. A model that cannot see
|
|
12
|
+
* `lib/chain.mjs` does not go looking for it; it invents a plausible
|
|
13
|
+
* `src/chain.js` and writes there. Blindness does not read as blindness from
|
|
14
|
+
* the inside — it reads as "that file does not exist".
|
|
15
|
+
*
|
|
16
|
+
* ⭐ THE ECONOMICS ARE THE ENTIRE ARGUMENT. A path is a handful of tokens; a
|
|
17
|
+
* file is thousands. Listing two thousand paths costs less than inlining five
|
|
18
|
+
* files. So this module trades CONTENT for COVERAGE, and the trade is not close.
|
|
19
|
+
*
|
|
20
|
+
* ── THE FOUR PROPERTIES THAT ARE LOAD-BEARING ───────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* 1. DETERMINISM. Same tree, same bytes, byte for byte, every run. `readdir`
|
|
23
|
+
* makes NO order promise, so every list here is sorted by CODE POINT (not
|
|
24
|
+
* `localeCompare`, which is ICU-dependent and therefore machine-dependent).
|
|
25
|
+
* There are no timestamps and no rendered ages anywhere in the output —
|
|
26
|
+
* "3 minutes ago" changes every single run. A map that reshuffles destroys
|
|
27
|
+
* the cached prompt prefix, and prefix stability is worth 3.05x.
|
|
28
|
+
*
|
|
29
|
+
* 2. HONEST TRUNCATION. It never implies completeness it does not have, it
|
|
30
|
+
* states the total, and it says WHERE the gaps are rather than only how
|
|
31
|
+
* many — a bare count is unactionable, a named directory is a next move.
|
|
32
|
+
*
|
|
33
|
+
* 3. NO CONTENT LEAVES. It emits paths and symbol NAMES, never a file body.
|
|
34
|
+
* The old pre-read shipped `.env` verbatim to four upstream providers; the
|
|
35
|
+
* prompt is an exfiltration path and this module treats it as one. It
|
|
36
|
+
* reuses `refusedCommitPath` from git.mjs deliberately — that list already
|
|
37
|
+
* means "must never leave this machine", and a second copy is the copy
|
|
38
|
+
* that goes stale.
|
|
39
|
+
*
|
|
40
|
+
* 4. THE GUESS IS LABELLED. Symbols come from a regex, not a parser. A wrong
|
|
41
|
+
* guess is acceptable; presenting one as authoritative is not, because a
|
|
42
|
+
* missing name would otherwise read as proof of absence.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ EVERY IMPL IS INJECTED. No clock, no randomness, no ambient `fs` inside
|
|
45
|
+
* the logic — the defaults at the bottom are the only place the real
|
|
46
|
+
* filesystem is touched, so every property above is provable with data.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
50
|
+
import { join } from 'node:path';
|
|
51
|
+
|
|
52
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
53
|
+
/**
|
|
54
|
+
* ⚠️ THIS COMPARATOR WAS DEFINED HERE AND THE RULE WAS WRITTEN HERE — and the
|
|
55
|
+
* two other modules that render into the prompt sorted with `localeCompare`
|
|
56
|
+
* anyway. It now lives in one place, so a module cannot follow the comment
|
|
57
|
+
* without also following the code. See `prefix-order.mjs`.
|
|
58
|
+
*/
|
|
59
|
+
import { byCodePoint } from './prefix-order.mjs';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* ⚠️ BYTE-IDENTICAL TO lib/search.mjs ON PURPOSE, AND GUARDED BY A TEST.
|
|
63
|
+
*
|
|
64
|
+
* Two ideas about which directories exist IS the bug: the map would tell the
|
|
65
|
+
* model a file is absent that `search_text` can find, or list one that
|
|
66
|
+
* `find_files` will never return. The drift guard in the test suite reads
|
|
67
|
+
* search.mjs's declaration and compares. If you change one, change both.
|
|
68
|
+
*/
|
|
69
|
+
export const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.vercel', 'coverage', '.turbo']);
|
|
70
|
+
|
|
71
|
+
/** Hidden DIRECTORIES that are ordinary source. Same list, same reasons. */
|
|
72
|
+
export const HIDDEN_DIRS_ALLOWED = new Set(['.github', '.vscode', '.husky', '.circleci', '.changeset', '.storybook']);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⚠️ HIDDEN FILES ARE WITHHELD BY DEFAULT, and this allowlist is the exception.
|
|
76
|
+
*
|
|
77
|
+
* The default has to be "withhold", because hidden files are overwhelmingly
|
|
78
|
+
* config and credentials — `.env`, `.netrc`, `.pgpass`, `.npmrc`. An allowlist
|
|
79
|
+
* is safe by construction in a way a denylist never is: a file nobody thought
|
|
80
|
+
* of is excluded rather than leaked.
|
|
81
|
+
*
|
|
82
|
+
* ⭐ `.gitignore` earns its place because the model is repeatedly asked to add
|
|
83
|
+
* a line to it, and a file it cannot see is a file it will recreate from
|
|
84
|
+
* scratch and clobber.
|
|
85
|
+
*/
|
|
86
|
+
export const HIDDEN_FILES_ALLOWED = new Set([
|
|
87
|
+
'.gitignore', '.gitattributes', '.gitmodules', '.dockerignore',
|
|
88
|
+
'.editorconfig', '.nvmrc', '.node-version', '.prettierrc', '.eslintrc',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** Extensions we will try to read for symbols. Everything else yields nothing. */
|
|
92
|
+
const SYMBOL_EXT = /\.(mjs|cjs|jsx?|tsx?|mts|cts|py|go|rs)$/i;
|
|
93
|
+
|
|
94
|
+
/** Source we would rather show than an asset when the budget is tight. */
|
|
95
|
+
const SOURCE_EXT = /\.(mjs|cjs|jsx?|tsx?|mts|cts|py|go|rs|rb|php|java|kt|swift|cs|c|h|cc|cpp|hpp|sql|sh|vue|svelte|css|scss|html)$/i;
|
|
96
|
+
|
|
97
|
+
/** Config and prose: worth listing, not worth crowding out a source file. */
|
|
98
|
+
const DOC_EXT = /\.(json|ya?ml|toml|ini|md|mdx|txt|env\.example)$/i;
|
|
99
|
+
|
|
100
|
+
/** Directories whose name answers "where are the tests". */
|
|
101
|
+
const TEST_DIR_NAMES = new Set(['test', 'tests', '__tests__', 'spec', 'e2e', 'testing']);
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* ── ⚠️⚠️⭐ THE SCRIPT LIST WAS ALPHABETICAL, AND IT HID `npm test` ───────────
|
|
105
|
+
*
|
|
106
|
+
* The old line was `Object.keys(pkg.scripts).sort(byCodePoint).slice(0, 6)`.
|
|
107
|
+
* Alphabetical order is not importance order — that is the argument this
|
|
108
|
+
* module's own header makes about FILES, and the scripts list was doing exactly
|
|
109
|
+
* what it condemns.
|
|
110
|
+
*
|
|
111
|
+
* ⚠️ MEASURED ON `console/`. It has 16 scripts. The first six alphabetically:
|
|
112
|
+
*
|
|
113
|
+
* bench · bench:all · bench:apps · bench:creative · bench:creative:all ·
|
|
114
|
+
* bench:creative:selftest
|
|
115
|
+
*
|
|
116
|
+
* Six spellings of one verb, and `test`, `build`, `lint`, `dev`, `start` and
|
|
117
|
+
* `type-check` were ALL cut. **A model that cannot see the project's own test
|
|
118
|
+
* command cannot verify its work** — it invents one, runs `npm run tests`, gets
|
|
119
|
+
* "missing script", and concludes the repo has no tests.
|
|
120
|
+
*
|
|
121
|
+
* ⚠️ AND THIS REPO SURVIVED BY ONE SLOT, WHICH IS WHY NOBODY SAW IT. We have
|
|
122
|
+
* exactly six scripts (`bundle`, `bundle:mcp`, `machine`, `machine:stop`,
|
|
123
|
+
* `test`, `test:raw`), so `test` was the sixth and made the cut by luck. Add one
|
|
124
|
+
* script sorting before it and `npm test` disappears from our own map.
|
|
125
|
+
*
|
|
126
|
+
* ⭐ SO THE LIST IS RANKED BY WHAT AN AGENT NEEDS, and the ranking is ordered by
|
|
127
|
+
* that need: how do I verify (`test`), how do I build it, how do I check it, how
|
|
128
|
+
* do I run it. `format` last because it changes files rather than reporting on
|
|
129
|
+
* them.
|
|
130
|
+
*/
|
|
131
|
+
export const SCRIPT_VERBS = Object.freeze([
|
|
132
|
+
'test', 'build', 'lint', 'typecheck', 'type-check', 'check',
|
|
133
|
+
'verify', 'e2e', 'dev', 'start', 'format',
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* ⭐ 8, NOT 6, AND THE NUMBER IS THE SMALLER HALF OF THE FIX. Eleven ranked
|
|
138
|
+
* verbs cannot all fit, and that is fine — but six could not even hold the four
|
|
139
|
+
* that matter alongside anything project-specific. A script line is ~30
|
|
140
|
+
* characters, so the whole section costs ~70 tokens: it is not what the budget
|
|
141
|
+
* is fighting over.
|
|
142
|
+
*/
|
|
143
|
+
export const MAX_SCRIPTS_SHOWN = 8;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Choose which scripts to show.
|
|
147
|
+
*
|
|
148
|
+
* ⭐ THE SECOND RULE IS WHAT ACTUALLY KILLED `console/`: after the ranked verbs,
|
|
149
|
+
* remaining slots take at most ONE script per `:` family. `bench:all` tells a
|
|
150
|
+
* model nothing it did not learn from `bench`, and six of them told it nothing
|
|
151
|
+
* six times while pushing out `test`. A family is the part before the first `:`.
|
|
152
|
+
*
|
|
153
|
+
* ⚠️ IT RETURNS THE OMITTED COUNT because a truncated list that does not say it
|
|
154
|
+
* is truncated is the same lie the FILES section refuses to tell.
|
|
155
|
+
*/
|
|
156
|
+
export function rankScripts(names, max = MAX_SCRIPTS_SHOWN) {
|
|
157
|
+
const all = [...new Set(names)].sort(byCodePoint);
|
|
158
|
+
const chosen = [];
|
|
159
|
+
const taken = new Set();
|
|
160
|
+
for (const verb of SCRIPT_VERBS) {
|
|
161
|
+
if (chosen.length >= max) break;
|
|
162
|
+
if (!all.includes(verb) || taken.has(verb)) continue;
|
|
163
|
+
chosen.push(verb);
|
|
164
|
+
taken.add(verb);
|
|
165
|
+
}
|
|
166
|
+
const families = new Set(chosen.map((n) => n.split(':')[0]));
|
|
167
|
+
for (const name of all) {
|
|
168
|
+
if (chosen.length >= max) break;
|
|
169
|
+
if (taken.has(name)) continue;
|
|
170
|
+
const family = name.split(':')[0];
|
|
171
|
+
if (families.has(family)) continue;
|
|
172
|
+
chosen.push(name);
|
|
173
|
+
taken.add(name);
|
|
174
|
+
families.add(family);
|
|
175
|
+
}
|
|
176
|
+
return { chosen, omitted: all.length - chosen.length };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* ⚠️ NEVER READ FOR SYMBOLS ABOVE THIS. Matches search.mjs's ceiling. A 900KB
|
|
181
|
+
* generated bundle is not a file whose export list helps anyone, and reading it
|
|
182
|
+
* costs real milliseconds per entry across a big tree.
|
|
183
|
+
*/
|
|
184
|
+
const MAX_SYMBOL_FILE_BYTES = 512 * 1024;
|
|
185
|
+
|
|
186
|
+
/** A pathological file cannot produce a thousand-symbol line. */
|
|
187
|
+
const MAX_SYMBOLS_PER_FILE = 64;
|
|
188
|
+
/** …and the RENDERED line is shorter still, because the model pays per token. */
|
|
189
|
+
const MAX_SYMBOLS_SHOWN = 6;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* ⚠️ THE WALK IS BOUNDED BY ENTRY COUNT, NOT BY DEPTH — that inversion is the
|
|
193
|
+
* whole point of this module. Depth is what made the old pre-read blind; a
|
|
194
|
+
* count is what actually protects against a pathological tree.
|
|
195
|
+
*/
|
|
196
|
+
export const DEFAULT_MAX_ENTRIES = 12_000;
|
|
197
|
+
|
|
198
|
+
/** A depth cap exists only so a symlink cycle cannot hang the process. */
|
|
199
|
+
const MAX_DEPTH = 24;
|
|
200
|
+
|
|
201
|
+
/** Symbols are extracted for the highest-priority files only; reads are not free. */
|
|
202
|
+
const MAX_SYMBOL_READS = 800;
|
|
203
|
+
|
|
204
|
+
/** How many directories the omission report names before it stops. */
|
|
205
|
+
const MAX_GAP_LINES = 10;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* ── ⭐ THE DEFAULT IS A MEASUREMENT, NOT A ROUND NUMBER ─────────────────────
|
|
209
|
+
*
|
|
210
|
+
* Measured against `gatherWorkspaceContext` on two real trees:
|
|
211
|
+
*
|
|
212
|
+
* this repo (144 files) — old: 9,627 tokens for 12 file BODIES and a
|
|
213
|
+
* two-level tree
|
|
214
|
+
* new: 2,554 tokens for ALL 144 paths + symbols
|
|
215
|
+
* console/ (2,246 files) — old: 10,889 tokens, still 12 bodies, ~5% of paths
|
|
216
|
+
* new at 6,000: 638 paths, 44 symbol lists
|
|
217
|
+
*
|
|
218
|
+
* So 6,000 is CHEAPER than what the old pre-read actually spent on both, and
|
|
219
|
+
* buys an order of magnitude more coverage.
|
|
220
|
+
*
|
|
221
|
+
* ⭐ AND IT IS CHEAPER STILL ON EVERY ROUND AFTER THE FIRST. The map is
|
|
222
|
+
* byte-identical run to run by construction, so it sits inside the cached
|
|
223
|
+
* prompt prefix — 3.05x on DeepSeek. A stable 6,000 tokens costs about what an
|
|
224
|
+
* unstable 2,000 does, which is exactly why determinism was worth building.
|
|
225
|
+
*
|
|
226
|
+
* ── ⚠️ RAISED 6,000 → 9,000 (2026-08-16). THE REASONING ABOVE IS ALL STILL
|
|
227
|
+
* TRUE; IT WAS JUST ANSWERING A DIFFERENT QUESTION ─────────────────────────
|
|
228
|
+
*
|
|
229
|
+
* Every number above is a comparison against the OLD pre-read, and against that
|
|
230
|
+
* baseline 6,000 wins easily. It was never checked against the only question
|
|
231
|
+
* that matters on a big repo — how much of the tree actually arrives. Measured
|
|
232
|
+
* on `console/` (2,406 files) at 6,000: **659 files listed, and they came from
|
|
233
|
+
* 7 of the 360 directories.** Nothing below depth 1 was visible at all.
|
|
234
|
+
*
|
|
235
|
+
* ⭐ THE ORDERING WAS THE BULK OF THAT (see `orderForBudget`) and its fix is
|
|
236
|
+
* free. The raise is the smaller, second lever, and it is defensible on the same
|
|
237
|
+
* ground the original number was chosen on: **9,000 is still below what the
|
|
238
|
+
* pre-read this module replaced actually spent on BOTH trees** (9,627 and
|
|
239
|
+
* 10,889). We have not made round 1 more expensive than the thing we deleted.
|
|
240
|
+
*
|
|
241
|
+
* ⚠️⚠️ AND IT IS A CEILING, NOT A SPEND — which is the whole reason the raise
|
|
242
|
+
* is cheap. Measured: this repo's own 344 files render in 5,701 tokens, so they
|
|
243
|
+
* fit under the OLD 6,000 with room to spare and cost exactly the same after the
|
|
244
|
+
* raise as before it. Nothing changes for any repo that already fitted. The
|
|
245
|
+
* extra tokens are spent only where the map was blind, which is precisely the
|
|
246
|
+
* case that was paying 6,000 tokens to see 1.9% of the directories.
|
|
247
|
+
*/
|
|
248
|
+
export const DEFAULT_BUDGET_TOKENS = 9_000;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* ── THE TOKEN ESTIMATE ──────────────────────────────────────────────────────
|
|
252
|
+
*
|
|
253
|
+
* Deliberately crude, deliberately PESSIMISTIC. ~3.5 chars per token rather
|
|
254
|
+
* than the usual 4, because paths tokenize worse than prose: every `/`, `-`
|
|
255
|
+
* and `.` is a boundary. Under-estimating means the real prompt overruns the
|
|
256
|
+
* budget the caller set, which is the failure that matters here.
|
|
257
|
+
*/
|
|
258
|
+
export function estimateTokens(text) {
|
|
259
|
+
if (!text) return 0;
|
|
260
|
+
return Math.ceil(String(text).length / 3.5);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
265
|
+
// .gitignore
|
|
266
|
+
//
|
|
267
|
+
// ⚠️ THE OLD PRE-READ IGNORED .gitignore ENTIRELY, and therefore shipped the
|
|
268
|
+
// CONTENTS of gitignored files to the model provider. Those files are
|
|
269
|
+
// gitignored for a reason and the reason is frequently "it has a secret in it".
|
|
270
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse a `.gitignore` into ordered rules. Blank lines and comments vanish;
|
|
274
|
+
* ORDER SURVIVES, because in git the LAST matching rule wins and a negation
|
|
275
|
+
* that arrives before its pattern means nothing.
|
|
276
|
+
*/
|
|
277
|
+
export function parseGitignore(text) {
|
|
278
|
+
const rules = [];
|
|
279
|
+
if (typeof text !== 'string') return rules;
|
|
280
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
281
|
+
let line = raw.replace(/\s+$/, '');
|
|
282
|
+
if (line === '') continue;
|
|
283
|
+
if (line.startsWith('#')) continue;
|
|
284
|
+
let negate = false;
|
|
285
|
+
if (line.startsWith('!')) {
|
|
286
|
+
negate = true;
|
|
287
|
+
line = line.slice(1);
|
|
288
|
+
} else if (line.startsWith('\\#') || line.startsWith('\\!')) {
|
|
289
|
+
// An escaped leading `#` or `!` is a LITERAL first character, not syntax.
|
|
290
|
+
line = line.slice(1);
|
|
291
|
+
}
|
|
292
|
+
if (line === '') continue;
|
|
293
|
+
rules.push({ pattern: line, negate, dirOnly: line.endsWith('/') });
|
|
294
|
+
}
|
|
295
|
+
return rules;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Glob → regex source, with the one reading everyone else uses: `*` does not
|
|
300
|
+
* cross a slash, and `**` spans ZERO OR MORE directories.
|
|
301
|
+
*
|
|
302
|
+
* ⚠️ THE ZERO CASE IS THE BUG search.mjs ALREADY FIXED ONCE. `docs/**"/"draft.md`
|
|
303
|
+
* must match `docs/draft.md`. Reading `**` as "one or more" makes the pattern
|
|
304
|
+
* silently miss the commonest case.
|
|
305
|
+
*/
|
|
306
|
+
function globSource(glob) {
|
|
307
|
+
let out = '';
|
|
308
|
+
for (let i = 0; i < glob.length; i++) {
|
|
309
|
+
const c = glob[i];
|
|
310
|
+
if (c === '*') {
|
|
311
|
+
if (glob[i + 1] === '*') {
|
|
312
|
+
const prevIsSlash = i === 0 || glob[i - 1] === '/';
|
|
313
|
+
const nextIsSlash = glob[i + 2] === '/';
|
|
314
|
+
if (prevIsSlash && nextIsSlash) {
|
|
315
|
+
out += '(?:.*/)?';
|
|
316
|
+
i += 2; // consume the second `*` and the `/` the group already covers
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
out += '.*';
|
|
320
|
+
i += 1;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
out += '[^/]*';
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (c === '?') { out += '[^/]'; continue; }
|
|
327
|
+
out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function compileRule(rule) {
|
|
333
|
+
let p = rule.pattern;
|
|
334
|
+
const dirOnly = p.endsWith('/');
|
|
335
|
+
if (dirOnly) p = p.slice(0, -1);
|
|
336
|
+
// A leading slash anchors to the ignore file's own directory. So does an
|
|
337
|
+
// interior slash — that is git's rule, not an approximation of it.
|
|
338
|
+
let anchored = p.startsWith('/');
|
|
339
|
+
if (anchored) p = p.slice(1);
|
|
340
|
+
if (p.includes('/')) anchored = true;
|
|
341
|
+
return { rx: new RegExp(`^${globSource(p)}$`), anchored, dirOnly, negate: rule.negate };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Build a matcher: `(relPath, isDir) => boolean`.
|
|
346
|
+
*
|
|
347
|
+
* ⭐ ANCESTORS ARE CHECKED SEPARATELY, and that is not an optimisation — it is
|
|
348
|
+
* the semantics. `src/generated/` ignores `src/generated/x.js`, and the walk is
|
|
349
|
+
* not the only caller, so the matcher cannot rely on "we never descended".
|
|
350
|
+
*/
|
|
351
|
+
export function makeIgnoreMatcher(rules) {
|
|
352
|
+
const compiled = rules.map(compileRule);
|
|
353
|
+
if (compiled.length === 0) return () => false;
|
|
354
|
+
|
|
355
|
+
/** @returns {boolean | undefined} the last matching rule's verdict, or none. */
|
|
356
|
+
const verdict = (rel, isDir) => {
|
|
357
|
+
let out;
|
|
358
|
+
for (const r of compiled) {
|
|
359
|
+
if (r.dirOnly && !isDir) continue;
|
|
360
|
+
if (r.anchored) {
|
|
361
|
+
if (r.rx.test(rel)) out = !r.negate;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Unanchored: match the whole path or any trailing segment sequence.
|
|
365
|
+
if (r.rx.test(rel)) { out = !r.negate; continue; }
|
|
366
|
+
let hit = false;
|
|
367
|
+
for (let i = 0; i < rel.length; i++) {
|
|
368
|
+
if (rel[i] !== '/') continue;
|
|
369
|
+
if (r.rx.test(rel.slice(i + 1))) { hit = true; break; }
|
|
370
|
+
}
|
|
371
|
+
if (hit) out = !r.negate;
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
return (rel, isDir = false) => {
|
|
377
|
+
const own = verdict(rel, isDir);
|
|
378
|
+
if (own !== undefined) return own;
|
|
379
|
+
// No rule spoke about this path. An ignored ANCESTOR still buries it.
|
|
380
|
+
const parts = rel.split('/');
|
|
381
|
+
for (let i = 1; i < parts.length; i++) {
|
|
382
|
+
const ancestor = parts.slice(0, i).join('/');
|
|
383
|
+
if (verdict(ancestor, true) === true) return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
390
|
+
// SYMBOLS — a regex guess, labelled as one
|
|
391
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
394
|
+
/** Words a naive regex catches that are syntax, not names. */
|
|
395
|
+
const NOT_A_NAME = new Set(['default', 'from', 'as', 'function', 'class', 'const', 'let', 'var', 'async', 'type', 'interface', 'enum']);
|
|
396
|
+
|
|
397
|
+
const JS_PATTERNS = [
|
|
398
|
+
/^\s*export\s+default\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/gm,
|
|
399
|
+
/^\s*export\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/gm,
|
|
400
|
+
/^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
|
|
401
|
+
/^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/gm,
|
|
402
|
+
/^\s*export\s+(?:type|interface|enum)\s+([A-Za-z_$][\w$]*)/gm,
|
|
403
|
+
/^\s*exports\.([A-Za-z_$][\w$]*)\s*=/gm,
|
|
404
|
+
];
|
|
405
|
+
/** `export { a, b as c }` and `module.exports = { a, b }` — a LIST, not a name. */
|
|
406
|
+
const JS_LIST_PATTERNS = [
|
|
407
|
+
/^\s*export\s*\{([^}]*)\}/gm,
|
|
408
|
+
/^\s*module\.exports\s*=\s*\{([^}]*)\}/gm,
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
const PY_PATTERNS = [/^(?:async\s+)?def\s+([A-Za-z_]\w*)/gm, /^class\s+([A-Za-z_]\w*)/gm];
|
|
412
|
+
const GO_PATTERNS = [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm, /^type\s+([A-Za-z_]\w*)/gm];
|
|
413
|
+
const RS_PATTERNS = [
|
|
414
|
+
/^\s*pub\s+(?:async\s+)?fn\s+([A-Za-z_]\w*)/gm,
|
|
415
|
+
/^\s*pub\s+(?:struct|enum|trait|mod|type|const|static)\s+([A-Za-z_]\w*)/gm,
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Exported symbol names for one file, by regex.
|
|
420
|
+
*
|
|
421
|
+
* ⚠️ IT RETURNS NOTHING RATHER THAN GARBAGE for a file it does not understand.
|
|
422
|
+
* A markdown file containing the words "export function" is not a module, and
|
|
423
|
+
* emitting `fake` from it would be worse than emitting nothing — the model
|
|
424
|
+
* would go looking for a symbol that never existed.
|
|
425
|
+
*/
|
|
426
|
+
export function extractExports(path, source) {
|
|
427
|
+
if (typeof source !== 'string' || source === '') return [];
|
|
428
|
+
if (!SYMBOL_EXT.test(path)) return [];
|
|
429
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
|
|
430
|
+
|
|
431
|
+
const found = new Set();
|
|
432
|
+
const add = (name) => {
|
|
433
|
+
const n = String(name).trim();
|
|
434
|
+
if (!IDENT.test(n)) return;
|
|
435
|
+
if (NOT_A_NAME.has(n)) return;
|
|
436
|
+
found.add(n);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const run = (patterns) => {
|
|
440
|
+
for (const rx of patterns) {
|
|
441
|
+
rx.lastIndex = 0;
|
|
442
|
+
let m;
|
|
443
|
+
while ((m = rx.exec(source)) !== null) add(m[1]);
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
if (ext === 'py') run(PY_PATTERNS);
|
|
448
|
+
else if (ext === 'go') run(GO_PATTERNS);
|
|
449
|
+
else if (ext === 'rs') run(RS_PATTERNS);
|
|
450
|
+
else {
|
|
451
|
+
run(JS_PATTERNS);
|
|
452
|
+
for (const rx of JS_LIST_PATTERNS) {
|
|
453
|
+
rx.lastIndex = 0;
|
|
454
|
+
let m;
|
|
455
|
+
while ((m = rx.exec(source)) !== null) {
|
|
456
|
+
for (const piece of m[1].split(',')) {
|
|
457
|
+
const parts = piece.trim().split(/\s+as\s+/);
|
|
458
|
+
add(parts[parts.length - 1]);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return [...found].sort(byCodePoint).slice(0, MAX_SYMBOLS_PER_FILE);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
468
|
+
// PRIORITY — which paths survive a tight budget
|
|
469
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* ⭐ THE ORDER IS THE PRODUCT WHEN THE BUDGET BITES. Alphabetical is what the
|
|
473
|
+
* old pre-read used, and alphabetical is why two hundred `assets/img000.png`
|
|
474
|
+
* crowded out the one `src/target.ts` the task was about.
|
|
475
|
+
*
|
|
476
|
+
* Lower is better. Source beats prose beats assets.
|
|
477
|
+
*/
|
|
478
|
+
function fileCategory(file, entryTargets) {
|
|
479
|
+
if (file.path === 'package.json' || entryTargets.has(file.path)) return 0;
|
|
480
|
+
if (SOURCE_EXT.test(file.path)) return 1;
|
|
481
|
+
if (DOC_EXT.test(file.path)) return 2;
|
|
482
|
+
return 3;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The directory a path sits in. `''` for a file at the root. */
|
|
486
|
+
function dirOf(path) {
|
|
487
|
+
const i = path.lastIndexOf('/');
|
|
488
|
+
return i < 0 ? '' : path.slice(0, i);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* ── ⚠️⚠️⭐ THE ORDER USED TO CONTAIN A DEPTH TERM, AND IT REBUILT THE EXACT
|
|
493
|
+
* BLINDNESS THIS MODULE WAS WRITTEN TO DELETE ────────────────────────────
|
|
494
|
+
*
|
|
495
|
+
* The old comparator was `category * 1_000_000 + min(depth, 40) * 10_000`, and
|
|
496
|
+
* its stated reason was right in spirit: *"shallow beats deep, so the SHAPE of
|
|
497
|
+
* the project survives even when most of it does not."* Wanting the shape to
|
|
498
|
+
* survive is correct. Expressing it as a STRICT tiebreak ahead of the path is
|
|
499
|
+
* what broke, because a strict ordering on depth is not a bias toward the
|
|
500
|
+
* shape — it is a breadth-first cut, and a breadth-first cut is a DEPTH CLIFF.
|
|
501
|
+
* Every file at depth N is listed before any file at depth N+1, so the budget
|
|
502
|
+
* runs out inside one depth band and everything below it vanishes together.
|
|
503
|
+
*
|
|
504
|
+
* ⚠️⚠️ MEASURED ON `console/` (2,406 files, 360 directories) BEFORE THIS FIX:
|
|
505
|
+
*
|
|
506
|
+
* depth 0 : 6 of 39 listed
|
|
507
|
+
* depth 1 : 653 of 1281 listed
|
|
508
|
+
* depth 2 : 0 of 482 ← the cliff
|
|
509
|
+
* depth 3 : 0 of 285
|
|
510
|
+
* depth 4+: 0 of 319
|
|
511
|
+
*
|
|
512
|
+
* 659 files listed, drawn from **7 of the 360 directories**. 1,610 of the
|
|
513
|
+
* 1,747 omitted files were SOURCE — 1,017 `.ts` and 386 `.tsx`.
|
|
514
|
+
*
|
|
515
|
+
* ⭐ THIS IS THE SAME DEFECT THE HEADER OF THIS FILE ATTACKS BY NAME. It opens
|
|
516
|
+
* by indicting `gatherWorkspaceContext` for walking "TWO directory levels", and
|
|
517
|
+
* the replacement WALKED the whole tree and then threw everything past two
|
|
518
|
+
* levels away at render time, for 6,000 tokens instead of 12 file bodies. The
|
|
519
|
+
* walk was fixed and the ordering quietly undid it. That is worth stating
|
|
520
|
+
* plainly, because "we already fixed that" is why nobody looked.
|
|
521
|
+
*
|
|
522
|
+
* ⭐⭐ AND IT MATTERS MORE THAN THE FILE COUNT, because of the header's own
|
|
523
|
+
* argument: *"an invisible file is not a neutral absence … it reads as 'that
|
|
524
|
+
* file does not exist'."* A model that can see 659 files from 7 directories does
|
|
525
|
+
* not know 353 other directories exist. A model that can see ONE file in every
|
|
526
|
+
* directory knows where everything lives and can `read_file` the rest. Reach
|
|
527
|
+
* across the tree is the product; the count is a proxy that stopped tracking it.
|
|
528
|
+
*
|
|
529
|
+
* ── ⭐ SO THE CUT IS A BREADTH SAMPLE, NOT A PREFIX ─────────────────────────
|
|
530
|
+
*
|
|
531
|
+
* Files are dealt out one per directory per pass: every directory's first
|
|
532
|
+
* source file, then every directory's second, and so on. The budget then runs
|
|
533
|
+
* out at "the 7th file of the big directories" instead of "every directory
|
|
534
|
+
* below depth 1". Measured at the same 6,000 tokens the cliff was measured at:
|
|
535
|
+
* **453 files but 347 of 347 source directories, reaching depth 7.**
|
|
536
|
+
*
|
|
537
|
+
* ⚠️ DEPTH IS STILL HERE AND STILL DOES ITS JOB — it just ranks WITHIN a pass
|
|
538
|
+
* rather than above one. Among all the directories' first files, the shallow
|
|
539
|
+
* ones are listed first, so the shape still survives a budget too tight for a
|
|
540
|
+
* full pass. That is what the original comment wanted; this is the ordering
|
|
541
|
+
* that delivers it without the cliff.
|
|
542
|
+
*
|
|
543
|
+
* ⚠️ CATEGORY STILL OUTRANKS EVERYTHING, so `src/target.ts` still beats two
|
|
544
|
+
* hundred `assets/img000.png` — the sample is taken within a category, so an
|
|
545
|
+
* asset-only directory is not represented until every source file has been.
|
|
546
|
+
*
|
|
547
|
+
* ⚠️ DETERMINISM SURVIVES: the pass index is assigned after a CODE POINT sort,
|
|
548
|
+
* so which file is a directory's "first" cannot depend on `readdir` order.
|
|
549
|
+
*/
|
|
550
|
+
export function orderForBudget(files, entryTargets = new Set()) {
|
|
551
|
+
const byPath = [...files].sort((a, b) => byCodePoint(a.path, b.path));
|
|
552
|
+
const seen = new Map();
|
|
553
|
+
const rows = byPath.map((file) => {
|
|
554
|
+
const category = fileCategory(file, entryTargets);
|
|
555
|
+
// The pass is counted per (directory, category) rather than per directory:
|
|
556
|
+
// a directory's README must not consume the slot its `index.ts` needs.
|
|
557
|
+
const key = `${dirOf(file.path)}\u0000${category}`;
|
|
558
|
+
const pass = seen.get(key) ?? 0;
|
|
559
|
+
seen.set(key, pass + 1);
|
|
560
|
+
return { file, category, pass, depth: Math.min(file.depth, 40) };
|
|
561
|
+
});
|
|
562
|
+
rows.sort((a, b) => a.category - b.category
|
|
563
|
+
|| a.pass - b.pass
|
|
564
|
+
|| a.depth - b.depth
|
|
565
|
+
|| byCodePoint(a.file.path, b.file.path));
|
|
566
|
+
return rows.map((r) => r.file);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
570
|
+
// THE WALK
|
|
571
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
function defaultImpls(root) {
|
|
574
|
+
return {
|
|
575
|
+
existsImpl: (rel) => existsSync(rel === '' ? root : join(root, rel)),
|
|
576
|
+
readdirImpl: (rel) => readdirSync(rel === '' ? root : join(root, rel), { withFileTypes: true })
|
|
577
|
+
.map((d) => ({ name: d.name, type: d.isDirectory() ? 'dir' : d.isFile() ? 'file' : 'other' })),
|
|
578
|
+
statImpl: (rel) => {
|
|
579
|
+
const st = statSync(join(root, rel), { throwIfNoEntry: false });
|
|
580
|
+
if (!st) return null;
|
|
581
|
+
return { size: st.size, mtimeMs: st.mtimeMs, dir: st.isDirectory() };
|
|
582
|
+
},
|
|
583
|
+
readFileImpl: (rel) => {
|
|
584
|
+
try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; }
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Build the map.
|
|
591
|
+
*
|
|
592
|
+
* @param {string} root absolute path, used only by the default impls
|
|
593
|
+
* @param {object} [impls] existsImpl / readdirImpl / statImpl / readFileImpl
|
|
594
|
+
* @param {object} [opts] { budgetTokens, maxEntries }
|
|
595
|
+
* @returns {{ ok: boolean, text: string, files: object[], truncated: boolean, stats: object, error?: string }}
|
|
596
|
+
*/
|
|
597
|
+
export function buildRepoMap(root, impls = {}, opts = {}) {
|
|
598
|
+
const io = { ...defaultImpls(root), ...(impls || {}) };
|
|
599
|
+
// Tolerate the options being folded into the second argument — a caller that
|
|
600
|
+
// writes `buildRepoMap(root, { budgetTokens: 800 })` means something obvious
|
|
601
|
+
// and refusing it would fail correct work.
|
|
602
|
+
const budgetTokens = opts.budgetTokens ?? impls?.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
|
|
603
|
+
const maxEntries = opts.maxEntries ?? impls?.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
604
|
+
|
|
605
|
+
const stats = {
|
|
606
|
+
totalFiles: 0,
|
|
607
|
+
listedFiles: 0,
|
|
608
|
+
omittedFiles: 0,
|
|
609
|
+
skippedDirs: 0,
|
|
610
|
+
skippedDirNames: [],
|
|
611
|
+
hidden: 0,
|
|
612
|
+
withheld: 0,
|
|
613
|
+
gitignored: 0,
|
|
614
|
+
gitignoreUsed: false,
|
|
615
|
+
unreadableDirs: 0,
|
|
616
|
+
walkCapped: false,
|
|
617
|
+
maxDepthReached: 0,
|
|
618
|
+
entryPoints: [],
|
|
619
|
+
budgetTokens,
|
|
620
|
+
tokensEstimated: 0,
|
|
621
|
+
/**
|
|
622
|
+
* ⭐ DIRECTORY REACH, BECAUSE THE FILE COUNT STOPPED TRACKING THE PRODUCT.
|
|
623
|
+
* See `orderForBudget`: 659 of 2,406 files reads as 27% coverage and was
|
|
624
|
+
* actually 7 of 360 directories. A count of files cannot tell those two
|
|
625
|
+
* apart; this pair can, and it is what the regression test binds to.
|
|
626
|
+
*/
|
|
627
|
+
dirsTotal: 0,
|
|
628
|
+
dirsListed: 0,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const fail = (error) => ({ ok: false, text: '', files: [], truncated: false, stats, error });
|
|
632
|
+
|
|
633
|
+
let rootOk = false;
|
|
634
|
+
try { rootOk = io.existsImpl('') !== false; } catch { rootOk = false; }
|
|
635
|
+
if (!rootOk) return fail(`could not read the workspace root — it does not exist, or permission was denied (EACCES)`);
|
|
636
|
+
|
|
637
|
+
const readdir = (rel) => {
|
|
638
|
+
try {
|
|
639
|
+
const out = io.readdirImpl(rel);
|
|
640
|
+
return Array.isArray(out) ? out : null;
|
|
641
|
+
} catch (err) {
|
|
642
|
+
return { error: err };
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const rootEntries = readdir('');
|
|
647
|
+
if (rootEntries === null || (rootEntries && rootEntries.error)) {
|
|
648
|
+
const code = rootEntries?.error?.code ?? rootEntries?.error?.message ?? 'unknown';
|
|
649
|
+
return fail(`could not read the workspace root: ${code}`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** @type {{path:string, depth:number, size:number, mtimeMs:number}[]} */
|
|
653
|
+
const candidates = [];
|
|
654
|
+
const skippedNames = new Set();
|
|
655
|
+
let entriesSeen = 0;
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* ⚠️ AN EXPLICIT STACK, NOT RECURSION. A deep tree is exactly the case this
|
|
659
|
+
* module exists to handle, and blowing the JS stack on it would be a comic
|
|
660
|
+
* failure mode.
|
|
661
|
+
*/
|
|
662
|
+
const stack = [{ rel: '', depth: 0, ignore: [] }];
|
|
663
|
+
|
|
664
|
+
while (stack.length > 0) {
|
|
665
|
+
if (entriesSeen >= maxEntries) { stats.walkCapped = true; break; }
|
|
666
|
+
const dir = stack.pop();
|
|
667
|
+
if (dir.depth > stats.maxDepthReached) stats.maxDepthReached = dir.depth;
|
|
668
|
+
|
|
669
|
+
const listed = dir.rel === '' ? rootEntries : readdir(dir.rel);
|
|
670
|
+
if (listed === null || (listed && listed.error)) {
|
|
671
|
+
stats.unreadableDirs += 1;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ⚠️ SORTED HERE, ONCE. Everything downstream inherits a stable order, so
|
|
676
|
+
// no property of the output can depend on what readdir felt like doing.
|
|
677
|
+
const entries = [...listed]
|
|
678
|
+
.filter((e) => e && typeof e.name === 'string')
|
|
679
|
+
.sort((a, b) => byCodePoint(a.name, b.name));
|
|
680
|
+
|
|
681
|
+
// A nested .gitignore governs its own subtree and nothing above it.
|
|
682
|
+
let ignoreChain = dir.ignore;
|
|
683
|
+
const gitignoreEntry = entries.find((e) => e.name === '.gitignore' && e.type === 'file');
|
|
684
|
+
if (gitignoreEntry) {
|
|
685
|
+
stats.gitignoreUsed = true;
|
|
686
|
+
let text = null;
|
|
687
|
+
try { text = io.readFileImpl(dir.rel === '' ? '.gitignore' : `${dir.rel}/.gitignore`); } catch { text = null; }
|
|
688
|
+
const rules = parseGitignore(text);
|
|
689
|
+
if (rules.length > 0) {
|
|
690
|
+
ignoreChain = [...dir.ignore, { base: dir.rel, match: makeIgnoreMatcher(rules) }];
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const ignored = (rel, isDir) => {
|
|
695
|
+
for (const layer of ignoreChain) {
|
|
696
|
+
const scoped = layer.base === '' ? rel : rel.slice(layer.base.length + 1);
|
|
697
|
+
if (layer.match(scoped, isDir)) return true;
|
|
698
|
+
}
|
|
699
|
+
return false;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
const childDirs = [];
|
|
703
|
+
for (const entry of entries) {
|
|
704
|
+
if (entriesSeen >= maxEntries) { stats.walkCapped = true; break; }
|
|
705
|
+
entriesSeen += 1;
|
|
706
|
+
const name = entry.name;
|
|
707
|
+
const rel = dir.rel === '' ? name : `${dir.rel}/${name}`;
|
|
708
|
+
|
|
709
|
+
if (entry.type === 'dir') {
|
|
710
|
+
if (SKIP_DIRS.has(name)) { stats.skippedDirs += 1; skippedNames.add(name); continue; }
|
|
711
|
+
if (name.startsWith('.') && !HIDDEN_DIRS_ALLOWED.has(name)) { stats.hidden += 1; continue; }
|
|
712
|
+
if (refusedCommitPath(`${rel}/`)) { stats.withheld += 1; continue; }
|
|
713
|
+
if (ignored(rel, true)) { stats.gitignored += 1; continue; }
|
|
714
|
+
if (dir.depth + 1 > MAX_DEPTH) continue;
|
|
715
|
+
childDirs.push({ rel, depth: dir.depth + 1, ignore: ignoreChain });
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
// ⚠️ A symlink, socket or fifo is neither. Skipping every non-file,
|
|
719
|
+
// non-dir entry is what makes a cycle structurally impossible.
|
|
720
|
+
if (entry.type !== 'file') continue;
|
|
721
|
+
|
|
722
|
+
if (name.startsWith('.') && !HIDDEN_FILES_ALLOWED.has(name)) { stats.hidden += 1; continue; }
|
|
723
|
+
if (refusedCommitPath(rel)) { stats.withheld += 1; continue; }
|
|
724
|
+
if (ignored(rel, false)) { stats.gitignored += 1; continue; }
|
|
725
|
+
|
|
726
|
+
let st = null;
|
|
727
|
+
try { st = io.statImpl(rel); } catch { st = null; }
|
|
728
|
+
candidates.push({
|
|
729
|
+
path: rel,
|
|
730
|
+
depth: dir.depth,
|
|
731
|
+
size: typeof st?.size === 'number' ? st.size : 0,
|
|
732
|
+
mtimeMs: typeof st?.mtimeMs === 'number' ? st.mtimeMs : 0,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Pushed in reverse so the stack pops them in sorted order. Purely
|
|
737
|
+
// cosmetic for correctness, load-bearing for reading a debug dump.
|
|
738
|
+
for (let i = childDirs.length - 1; i >= 0; i--) stack.push(childDirs[i]);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
stats.skippedDirNames = [...skippedNames].sort(byCodePoint);
|
|
742
|
+
stats.totalFiles = candidates.length;
|
|
743
|
+
|
|
744
|
+
// ── package.json: entry points, scripts ───────────────────────────────────
|
|
745
|
+
const pkg = readPackageJson(io, candidates);
|
|
746
|
+
stats.entryPoints = pkg.entryPoints;
|
|
747
|
+
const entryTargets = new Set(pkg.entryPoints.map((e) => e.target.replace(/^\.\//, '')));
|
|
748
|
+
|
|
749
|
+
// ── priority order ────────────────────────────────────────────────────────
|
|
750
|
+
const ordered = orderForBudget(candidates, entryTargets);
|
|
751
|
+
|
|
752
|
+
// ── symbols, for the files most likely to be shown ────────────────────────
|
|
753
|
+
for (const f of ordered.slice(0, MAX_SYMBOL_READS)) {
|
|
754
|
+
if (!SYMBOL_EXT.test(f.path)) continue;
|
|
755
|
+
if (f.size > MAX_SYMBOL_FILE_BYTES) continue;
|
|
756
|
+
let src = null;
|
|
757
|
+
try { src = io.readFileImpl(f.path); } catch { src = null; }
|
|
758
|
+
if (typeof src !== 'string') continue;
|
|
759
|
+
const names = extractExports(f.path, src);
|
|
760
|
+
// ⚠️ AN EMPTY LIST IS LEFT UNDEFINED, NOT STORED AS []. `[]` renders as
|
|
761
|
+
// "this file exports nothing", which is a claim a regex cannot make.
|
|
762
|
+
if (names.length > 0) f.symbols = names;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (candidates.length === 0) {
|
|
766
|
+
const text = 'REPO MAP — the workspace is empty (no files this agent may list)';
|
|
767
|
+
stats.tokensEstimated = estimateTokens(text);
|
|
768
|
+
return { ok: true, text, files: [], truncated: false, stats };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* ── ⭐ TWO LEVERS, AND THE ORDER BETWEEN THEM IS THE WHOLE DESIGN ──────────
|
|
773
|
+
*
|
|
774
|
+
* MEASURED on a real 2,246-file Next.js repo: an annotated line averages 93
|
|
775
|
+
* characters, a bare path 28. So symbols cost 3.3x, and at a 3,000-token
|
|
776
|
+
* budget they were buying 77 symbol lists at the price of 230 PATHS.
|
|
777
|
+
*
|
|
778
|
+
* ⚠️ THAT TRADE IS BACKWARDS AND IT INVERTS THE MODULE'S OWN THESIS. Coverage
|
|
779
|
+
* is the product — the thing that turns "I cannot find it, I will invent a
|
|
780
|
+
* plausible file" into "I can see it, let me open it". A symbol list is a
|
|
781
|
+
* convenience on top; the model can always call `read_file`. So symbols are
|
|
782
|
+
* surrendered FIRST and paths LAST, never the other way round.
|
|
783
|
+
*
|
|
784
|
+
* Lever A: how many of the highest-priority files carry symbols (all → none).
|
|
785
|
+
* Lever B: how many files are listed at all (all → one).
|
|
786
|
+
*/
|
|
787
|
+
const render = (n, symCount) => {
|
|
788
|
+
const chosen = ordered.slice(0, n);
|
|
789
|
+
const omitted = ordered.slice(n);
|
|
790
|
+
const symAllowed = new Set(ordered.slice(0, symCount).filter((f) => f.symbols).map((f) => f.path));
|
|
791
|
+
const lineFor = (f) => {
|
|
792
|
+
if (!f.symbols || !symAllowed.has(f.path)) return ` ${f.path}`;
|
|
793
|
+
const shown = f.symbols.slice(0, MAX_SYMBOLS_SHOWN);
|
|
794
|
+
const extra = f.symbols.length - shown.length;
|
|
795
|
+
return ` ${f.path} [${shown.join(', ')}${extra > 0 ? ` +${extra}` : ''}]`;
|
|
796
|
+
};
|
|
797
|
+
return { text: renderMap({ chosen, omitted, ordered, stats, pkg, lineFor, symAllowed }), chosen, omitted };
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
/** Largest symbol count that still fits, for a fixed file count. Monotonic. */
|
|
801
|
+
const fitSymbols = (n) => {
|
|
802
|
+
let lo = 0;
|
|
803
|
+
let hi = n;
|
|
804
|
+
while (lo < hi) {
|
|
805
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
806
|
+
if (estimateTokens(render(n, mid).text) <= budgetTokens) lo = mid;
|
|
807
|
+
else hi = mid - 1;
|
|
808
|
+
}
|
|
809
|
+
return lo;
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
let take = ordered.length;
|
|
813
|
+
let symCount = ordered.length;
|
|
814
|
+
let out = render(take, symCount);
|
|
815
|
+
|
|
816
|
+
if (estimateTokens(out.text) > budgetTokens) {
|
|
817
|
+
symCount = fitSymbols(take);
|
|
818
|
+
out = render(take, symCount);
|
|
819
|
+
// Still over with zero symbols? Only then does a PATH get dropped, from the
|
|
820
|
+
// lowest-priority end, in batches. Bounded: a runaway here is a hang.
|
|
821
|
+
for (let guard = 0; guard < 64 && take > 1; guard++) {
|
|
822
|
+
const used = estimateTokens(out.text);
|
|
823
|
+
if (used <= budgetTokens) break;
|
|
824
|
+
const excess = used - budgetTokens;
|
|
825
|
+
const perLine = Math.max(1, Math.ceil(used / Math.max(1, take)));
|
|
826
|
+
take = Math.max(1, take - Math.max(1, Math.ceil(excess / perLine)));
|
|
827
|
+
out = render(take, symCount);
|
|
828
|
+
}
|
|
829
|
+
// Dropping paths freed room; hand it back to symbols rather than waste it.
|
|
830
|
+
// One pass, after `take` has settled, so this can never oscillate.
|
|
831
|
+
const regained = fitSymbols(take);
|
|
832
|
+
if (regained > symCount) {
|
|
833
|
+
symCount = regained;
|
|
834
|
+
out = render(take, symCount);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* ⚠️ `files` CARRIES EVERY SYMBOL LIST THAT WAS EXTRACTED; `text` carries
|
|
839
|
+
* only the ones the budget paid for. That is a deliberate asymmetry — a
|
|
840
|
+
* programmatic caller should not lose data to a rendering decision — and
|
|
841
|
+
* `symbolsShown` is the number that reconciles the two. Do not read a bare
|
|
842
|
+
* path in `text` as "this file exports nothing".
|
|
843
|
+
*/
|
|
844
|
+
stats.symbolsShown = ordered.slice(0, Math.min(symCount, take)).filter((f) => f.symbols).length;
|
|
845
|
+
|
|
846
|
+
const listed = [...out.chosen].sort((a, b) => byCodePoint(a.path, b.path));
|
|
847
|
+
stats.listedFiles = out.chosen.length;
|
|
848
|
+
stats.omittedFiles = out.omitted.length;
|
|
849
|
+
stats.dirsTotal = new Set(ordered.map((f) => dirOf(f.path))).size;
|
|
850
|
+
stats.dirsListed = new Set(out.chosen.map((f) => dirOf(f.path))).size;
|
|
851
|
+
stats.tokensEstimated = estimateTokens(out.text);
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
ok: true,
|
|
855
|
+
text: out.text,
|
|
856
|
+
files: listed.map((f) => ({ path: f.path, bytes: f.size, ...(f.symbols ? { symbols: f.symbols } : {}) })),
|
|
857
|
+
truncated: out.omitted.length > 0 || stats.walkCapped,
|
|
858
|
+
stats,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
863
|
+
// RENDERING
|
|
864
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
865
|
+
|
|
866
|
+
function readPackageJson(io, candidates) {
|
|
867
|
+
const empty = { entryPoints: [], scripts: [], scriptsOmitted: 0 };
|
|
868
|
+
if (!candidates.some((f) => f.path === 'package.json')) return empty;
|
|
869
|
+
let raw = null;
|
|
870
|
+
try { raw = io.readFileImpl('package.json'); } catch { return empty; }
|
|
871
|
+
if (typeof raw !== 'string') return empty;
|
|
872
|
+
let pkg;
|
|
873
|
+
// ⚠️ A REPO MID-EDIT STILL GETS A MAP. A half-typed package.json is a normal
|
|
874
|
+
// state of a working tree, and it is not a reason to blind the model.
|
|
875
|
+
try { pkg = JSON.parse(raw); } catch { return empty; }
|
|
876
|
+
if (!pkg || typeof pkg !== 'object') return empty;
|
|
877
|
+
|
|
878
|
+
const entryPoints = [];
|
|
879
|
+
if (typeof pkg.main === 'string') entryPoints.push({ kind: 'main', name: 'main', target: pkg.main });
|
|
880
|
+
if (typeof pkg.bin === 'string') entryPoints.push({ kind: 'bin', name: pkg.name ?? 'bin', target: pkg.bin });
|
|
881
|
+
else if (pkg.bin && typeof pkg.bin === 'object') {
|
|
882
|
+
for (const name of Object.keys(pkg.bin).sort(byCodePoint)) {
|
|
883
|
+
if (typeof pkg.bin[name] === 'string') entryPoints.push({ kind: 'bin', name, target: pkg.bin[name] });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const scripts = [];
|
|
887
|
+
let scriptsOmitted = 0;
|
|
888
|
+
if (pkg.scripts && typeof pkg.scripts === 'object') {
|
|
889
|
+
// ⚠️ RANKED, NOT ALPHABETICAL — see `rankScripts`; alphabetical hid `npm test`.
|
|
890
|
+
const runnable = Object.keys(pkg.scripts).filter((n) => typeof pkg.scripts[n] === 'string');
|
|
891
|
+
const picked = rankScripts(runnable);
|
|
892
|
+
scriptsOmitted = picked.omitted;
|
|
893
|
+
for (const name of picked.chosen) {
|
|
894
|
+
const cmd = pkg.scripts[name];
|
|
895
|
+
scripts.push({ name, cmd: cmd.length > 70 ? `${cmd.slice(0, 70)}…` : cmd });
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return { entryPoints, scripts, scriptsOmitted };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function renderMap({ chosen, omitted, ordered, stats, pkg, lineFor, symAllowed }) {
|
|
902
|
+
const total = ordered.length;
|
|
903
|
+
const truncated = omitted.length > 0 || stats.walkCapped;
|
|
904
|
+
const parts = [];
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* ── ⭐⭐ THE COUNT WAS AT BYTE 0, AND IT IS THE THING THAT CHANGES MOST ──────
|
|
908
|
+
*
|
|
909
|
+
* ⚠️⚠️ MEASURED 2026-08-16 on this repo (346 files, a 19,950-byte / ~5,000-token
|
|
910
|
+
* map), by building the map, creating ONE file, and building it again:
|
|
911
|
+
*
|
|
912
|
+
* agent creates one new source file 13 of 19,975 bytes survive 0.1%
|
|
913
|
+
* agent creates one new test file 13 of 19,987 bytes survive 0.1%
|
|
914
|
+
* agent EDITS an existing file 19,760 of 19,952 bytes survive 99.0%
|
|
915
|
+
*
|
|
916
|
+
* The edit case is 99% because the fix below this function already moved
|
|
917
|
+
* LARGEST and RECENTLY CHANGED behind FILES. The CREATE case was 0.1% because
|
|
918
|
+
* of THIS LINE: `346 files` became `347 files` at byte 13, and a prefix cache
|
|
919
|
+
* is worth nothing past its first differing byte, so the entire map — every
|
|
920
|
+
* section, including the ones that did not change — was re-paid at full price.
|
|
921
|
+
*
|
|
922
|
+
* ⚠️ AND CREATING A FILE IS NOT AN EDGE CASE. It is what a coding agent does;
|
|
923
|
+
* the CLI is then invoked again in the same repo, which is the whole shape of
|
|
924
|
+
* the cache floor the pricing is sized on.
|
|
925
|
+
*
|
|
926
|
+
* ⭐ SAME INFORMATION, MOVED, NOT DROPPED. The counts render in `TOTALS` at the
|
|
927
|
+
* very end, beside `NOT LISTED`, which is where the omission breakdown already
|
|
928
|
+
* lives and is the more useful place to read them anyway. What stays here is
|
|
929
|
+
* COMPLETE / INCOMPLETE — the one fact the model must have BEFORE it reads the
|
|
930
|
+
* listing ("is this everything?"), and the one that does NOT move when a file
|
|
931
|
+
* appears: it flips only when the repo crosses the token budget.
|
|
932
|
+
*
|
|
933
|
+
* ⚠️ THE HEADER MUST NAME WHERE THE NUMBERS WENT. A map that says INCOMPLETE
|
|
934
|
+
* and never says how incomplete is unactionable, which is the defect the
|
|
935
|
+
* original line was written to close — the fix is placement, not deletion.
|
|
936
|
+
*
|
|
937
|
+
* ⚠️⚠️ AND IT MUST BE SHORTER THAN WHAT IT REPLACED, NOT LONGER. The first
|
|
938
|
+
* draft spent an explanatory sentence here and turned the depth-cliff test
|
|
939
|
+
* red — measured, and the mechanism is exact: the map renders against a TOKEN
|
|
940
|
+
* BUDGET, so every fixed byte of preamble is a file line the budget can no
|
|
941
|
+
* longer afford. That fixture needed 401 estimated tokens to reach its 33rd
|
|
942
|
+
* file and had 400; 61 bytes of new prose bought the deepest directory band
|
|
943
|
+
* back out of the map. A caching change that quietly shrinks coverage is not a
|
|
944
|
+
* win, so both lines below are SHORTER than the counted versions they replace.
|
|
945
|
+
*/
|
|
946
|
+
parts.push(truncated
|
|
947
|
+
? 'REPO MAP — INCOMPLETE (counts at the end)'
|
|
948
|
+
: 'REPO MAP — COMPLETE (counts at the end)');
|
|
949
|
+
|
|
950
|
+
// ⭐ THE LABEL ONLY APPEARS WHEN THERE IS A GUESS TO LABEL. Printing it over
|
|
951
|
+
// a map with no symbols spends tokens warning about nothing.
|
|
952
|
+
if (chosen.some((f) => f.symbols && symAllowed.has(f.path))) {
|
|
953
|
+
parts.push('symbol names are a regex guess, not a parse — a missing name proves nothing');
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (pkg.entryPoints.length > 0 || pkg.scripts.length > 0) {
|
|
957
|
+
const lines = ['', 'ENTRY POINTS'];
|
|
958
|
+
for (const e of pkg.entryPoints) lines.push(` ${e.kind} ${e.name} ${e.target}`);
|
|
959
|
+
for (const s of pkg.scripts) lines.push(` script ${s.name} ${s.cmd}`);
|
|
960
|
+
// ⚠️ SAID OUT LOUD, for the same reason the FILES section states its total:
|
|
961
|
+
// a list that is silently short reads as the complete set, and the model
|
|
962
|
+
// then believes a script it cannot see does not exist.
|
|
963
|
+
if (pkg.scriptsOmitted > 0) {
|
|
964
|
+
lines.push(` ${pkg.scriptsOmitted} further script${pkg.scriptsOmitted === 1 ? '' : 's'} not shown — read package.json for the rest`);
|
|
965
|
+
}
|
|
966
|
+
parts.push(lines.join('\n'));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const testDirs = new Map();
|
|
970
|
+
for (const f of chosen) {
|
|
971
|
+
const top = f.path.includes('/') ? f.path.slice(0, f.path.indexOf('/')) : '';
|
|
972
|
+
if (top && TEST_DIR_NAMES.has(top)) testDirs.set(top, (testDirs.get(top) ?? 0) + 1);
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* ⚠️ NAMES HERE, COUNTS IN `TOTALS` — for the same measured reason as the
|
|
976
|
+
* header. `test/ 190 files` becomes `191 files` the moment the agent writes
|
|
977
|
+
* one test, and this section sits AHEAD of the FILES listing, so with the
|
|
978
|
+
* header fixed this line would simply become the new byte-13. The question
|
|
979
|
+
* this section answers is "where are the tests", and a directory name answers
|
|
980
|
+
* it whole; the size of the suite is a total, and totals now live together.
|
|
981
|
+
*/
|
|
982
|
+
const testDirNames = [...testDirs.keys()].sort(byCodePoint);
|
|
983
|
+
if (testDirs.size > 0) {
|
|
984
|
+
parts.push(['', 'TESTS', ...testDirNames.map((name) => ` ${name}/`)].join('\n'));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const listed = [...chosen].sort((a, b) => byCodePoint(a.path, b.path));
|
|
988
|
+
parts.push(['', 'FILES', ...listed.map(lineFor)].join('\n'));
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* ── ⭐⭐ THE TWO VOLATILE SECTIONS COME LAST, AND THAT IS THE WHOLE POINT ──
|
|
992
|
+
*
|
|
993
|
+
* ⚠️⚠️ THEY USED TO COME BEFORE `FILES`, AND IT COST ~60% OF THE MAP. Both
|
|
994
|
+
* are ordered by something the AGENT ITSELF CHANGES — byte size and mtime — so
|
|
995
|
+
* writing ONE file reshuffles them. Sitting ahead of `FILES` (hundreds of lines
|
|
996
|
+
* on any real repo, against ~10 here) that meant a single write diverged the
|
|
997
|
+
* prompt at roughly a third of the way in; from down here it diverges at ~95%.
|
|
998
|
+
* The map is the bulk of round 1's user message, so this decides how much of a
|
|
999
|
+
* fresh run can be served out of the previous run's cache — the common case for
|
|
1000
|
+
* a CLI, which is invoked over and over in the same repo.
|
|
1001
|
+
*
|
|
1002
|
+
* ⚠️ DETERMINISM AND STABILITY ARE DIFFERENT PROPERTIES, and only the first
|
|
1003
|
+
* was designed for. The header rule ("deterministic, never a rendered age") is
|
|
1004
|
+
* about `readdir` order and clocks; it says nothing about the agent's own
|
|
1005
|
+
* edits. Both are needed, and the fix for the second is placement, not sorting.
|
|
1006
|
+
*
|
|
1007
|
+
* ⚠️ SAME INFORMATION, SAME MAP — nothing is dropped, and no line changes.
|
|
1008
|
+
* A model reading top to bottom now meets the stable inventory first and the two
|
|
1009
|
+
* ranked hints after it, which is also the better reading order.
|
|
1010
|
+
*/
|
|
1011
|
+
// ⚠️ TWO IS THE THRESHOLD, NOT THREE. A "largest files" list of one entry is
|
|
1012
|
+
// noise, but a two-file repo still has a biggest file and a newest one.
|
|
1013
|
+
if (chosen.length >= 2) {
|
|
1014
|
+
const largest = [...chosen]
|
|
1015
|
+
.sort((a, b) => (b.size - a.size) || byCodePoint(a.path, b.path))
|
|
1016
|
+
.slice(0, 5);
|
|
1017
|
+
parts.push(['', 'LARGEST', ...largest.map((f) => ` ${f.path} ${f.size} bytes`)].join('\n'));
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* ⭐ AN ORDER, NEVER A TIMESTAMP. "modified 4 minutes ago" changes on every
|
|
1021
|
+
* single run, which changes the prompt prefix, which throws away the 3.05x
|
|
1022
|
+
* cache discount for a fact nobody reads. The rank carries the whole signal.
|
|
1023
|
+
*/
|
|
1024
|
+
const recent = [...chosen]
|
|
1025
|
+
.sort((a, b) => (b.mtimeMs - a.mtimeMs) || byCodePoint(a.path, b.path))
|
|
1026
|
+
.slice(0, 5);
|
|
1027
|
+
parts.push(['', 'RECENTLY CHANGED — newest first', ...recent.map((f) => ` ${f.path}`)].join('\n'));
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const notes = [];
|
|
1031
|
+
if (omitted.length > 0) {
|
|
1032
|
+
notes.push(` omitted for budget ${omitted.length} files — use find_files or search_text to reach them`);
|
|
1033
|
+
const byTop = new Map();
|
|
1034
|
+
for (const f of omitted) {
|
|
1035
|
+
const top = f.path.includes('/') ? `${f.path.slice(0, f.path.indexOf('/'))}/` : './';
|
|
1036
|
+
byTop.set(top, (byTop.get(top) ?? 0) + 1);
|
|
1037
|
+
}
|
|
1038
|
+
const gaps = [...byTop.entries()]
|
|
1039
|
+
.sort((a, b) => (b[1] - a[1]) || byCodePoint(a[0], b[0]))
|
|
1040
|
+
.slice(0, MAX_GAP_LINES);
|
|
1041
|
+
for (const [dir, n] of gaps) notes.push(` ${dir} ${n} files`);
|
|
1042
|
+
}
|
|
1043
|
+
if (stats.walkCapped) notes.push(' walk capped the tree exceeded the entry limit and was cut short');
|
|
1044
|
+
if (stats.skippedDirNames.length > 0) notes.push(` not walked ${stats.skippedDirNames.join(', ')}`);
|
|
1045
|
+
if (stats.gitignored > 0) notes.push(` gitignored ${stats.gitignored} entries`);
|
|
1046
|
+
if (stats.hidden > 0) notes.push(` hidden ${stats.hidden} entries`);
|
|
1047
|
+
if (stats.withheld > 0) notes.push(` withheld ${stats.withheld} credential-shaped files`);
|
|
1048
|
+
if (stats.unreadableDirs > 0) notes.push(` unreadable ${stats.unreadableDirs} directories`);
|
|
1049
|
+
if (notes.length > 0) parts.push(['', 'NOT LISTED', ...notes].join('\n'));
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* ── ⭐⭐ EVERY NUMBER THAT MOVES WHEN A FILE APPEARS, IN ONE PLACE, LAST ─────
|
|
1053
|
+
*
|
|
1054
|
+
* ⚠️ THIS IS NOT A NEW FACT, IT IS A RELOCATED ONE. `${total} files found,
|
|
1055
|
+
* ${chosen.length} listed` used to be the first thirteen bytes of the map and
|
|
1056
|
+
* `test/ N files` the ~350th; both changed on any file creation and both sat
|
|
1057
|
+
* ahead of the FILES listing, which is the bulk of the map. Down here they
|
|
1058
|
+
* cost the tail instead of the whole thing.
|
|
1059
|
+
*
|
|
1060
|
+
* ⚠️ IT IS UNCONDITIONAL, unlike `NOT LISTED`. A map with nothing omitted
|
|
1061
|
+
* still has a total, and "how big is this project" is a question the model
|
|
1062
|
+
* answers wrongly by guessing if nothing states it.
|
|
1063
|
+
*
|
|
1064
|
+
* ⚠️ AND IT GOES AFTER `NOT LISTED`, NOT BEFORE. Both are volatile, so the
|
|
1065
|
+
* order between them costs nothing — but the omission breakdown is what a
|
|
1066
|
+
* reader wants immediately after "INCOMPLETE", and the totals are the summary
|
|
1067
|
+
* it adds up to.
|
|
1068
|
+
*/
|
|
1069
|
+
const totals = [
|
|
1070
|
+
'',
|
|
1071
|
+
truncated ? `TOTALS ${total} files found, ${chosen.length} listed` : `TOTALS ${total} files, all listed`,
|
|
1072
|
+
...testDirNames.map((name) => ` ${name}/ ${testDirs.get(name)} files`),
|
|
1073
|
+
];
|
|
1074
|
+
parts.push(totals.join('\n'));
|
|
1075
|
+
|
|
1076
|
+
return parts.join('\n');
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1080
|
+
// THE WIRING SEAM
|
|
1081
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* The one call `turn.mjs` needs.
|
|
1085
|
+
*
|
|
1086
|
+
* ⚠️ IT RETURNS A STRING AND NEVER THROWS. A pre-read is an optimisation, not
|
|
1087
|
+
* a precondition: an unreadable workspace must degrade to "no map" and let the
|
|
1088
|
+
* turn proceed, never take the turn down with it. That is why every failure
|
|
1089
|
+
* here is an empty string rather than an exception or an apology in the prompt.
|
|
1090
|
+
*/
|
|
1091
|
+
export function repoMapForExecutor(executor, opts = {}) {
|
|
1092
|
+
try {
|
|
1093
|
+
const root = executor?.root;
|
|
1094
|
+
if (!root || typeof root !== 'string') return '';
|
|
1095
|
+
if (!existsSync(root)) return '';
|
|
1096
|
+
const map = buildRepoMap(root, {}, opts);
|
|
1097
|
+
return map.ok ? map.text : '';
|
|
1098
|
+
} catch {
|
|
1099
|
+
return '';
|
|
1100
|
+
}
|
|
1101
|
+
}
|