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/localize.mjs
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐⭐ lib/localize.mjs — WHICH FILES, AND NOTHING ELSE ───────────────────
|
|
3
|
+
*
|
|
4
|
+
* THE MEASUREMENT THIS MODULE EXISTS FOR. File-level localization is worth a
|
|
5
|
+
* **15-17x improvement over a no-file baseline** — larger than any prompt,
|
|
6
|
+
* model or harness change measured on this codebase. Everything downstream (the
|
|
7
|
+
* edit, the patch, the verifier) operates on whatever files arrived; if the
|
|
8
|
+
* right file is not among them, none of it can succeed.
|
|
9
|
+
*
|
|
10
|
+
* ── ⚠️ FOUR RESULTS THAT CONTRADICT THE OBVIOUS DESIGN ──────────────────────
|
|
11
|
+
*
|
|
12
|
+
* 1. **LLM-selected files BEAT gold.** Model-chosen files scored **60.6%**
|
|
13
|
+
* using **8.5 files**; the gold buggy-files-only set scored **56.2%** using
|
|
14
|
+
* **1.25**. Surrounding context is not slack — it is the difference. So
|
|
15
|
+
* this module PADS a thin answer rather than trusting a precise one.
|
|
16
|
+
* 2. **The optimum is 6-10 files.** Not one, not fifty. Both bounds are
|
|
17
|
+
* enforced here (`padToWindow`).
|
|
18
|
+
* 3. **Line-level narrowing DEGRADES results.** Cutting a file down to the
|
|
19
|
+
* suspect lines removes the context that made the file useful and adds
|
|
20
|
+
* noise. ⚠️ THERE IS DELIBERATELY NO LINE-RANGE STEP IN THIS MODULE, and
|
|
21
|
+
* adding one would be a regression, not a feature.
|
|
22
|
+
* 4. **The winning approach uses NO EMBEDDINGS AT ALL.** No index, no vector
|
|
23
|
+
* store, no similarity. Repo tree → an explicit "which folders are
|
|
24
|
+
* IRRELEVANT" filter → 5-10 files ordered by importance → skeletons →
|
|
25
|
+
* stop. The negative filter is the step almost nobody copies and it is the
|
|
26
|
+
* one that makes the rest cheap.
|
|
27
|
+
*
|
|
28
|
+
* Remaining headroom for PERFECT localization is **+7.7 points**, so this is not
|
|
29
|
+
* a place to spend forever — it is a place to stop being blind.
|
|
30
|
+
*
|
|
31
|
+
* ── ⚠️ WHAT THIS MODULE IS NOT ─────────────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* It is NOT `repo-map.mjs`. That module renders a budgeted list of FILES with
|
|
34
|
+
* exported symbol names, and it is the right thing for round 1 of every turn.
|
|
35
|
+
* This one renders DIRECTORIES, because the question here is different: the
|
|
36
|
+
* model is being asked which folders to throw away, and a file list cannot be
|
|
37
|
+
* thrown away by the folder. The two are complementary and share `estimateTokens`
|
|
38
|
+
* and `byCodePoint` rather than re-deriving them.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️⚠️ IT MAKES NO MODEL CALLS AND CANNOT. There is no import of `model.mjs`
|
|
41
|
+
* or `chain.mjs` anywhere in this file; every model interaction goes through an
|
|
42
|
+
* injected `askImpl`, and with no `askImpl` the loop refuses to run. That is not
|
|
43
|
+
* a testing convenience — it means a bug in this module cannot cost money,
|
|
44
|
+
* because there is no client in scope for it to call.
|
|
45
|
+
*
|
|
46
|
+
* ⭐ EVERY EXPORT BELOW IS PURE except `localize`, which is async only because
|
|
47
|
+
* `askImpl` is. No clock, no randomness, no ambient `fs`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { estimateTokens } from './repo-map.mjs';
|
|
51
|
+
import { byCodePoint } from './prefix-order.mjs';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* ⭐ THE DEFAULT IS SMALL ON PURPOSE — 2,000, against `repo-map`'s 9,000.
|
|
55
|
+
*
|
|
56
|
+
* The tree is not the payload, it is a QUESTION: "which of these folders can we
|
|
57
|
+
* throw away?". Measured on the shape this module targets — 50,000 files over
|
|
58
|
+
* 40 packages — the whole tree renders in ~90 lines, roughly 400 tokens, so the
|
|
59
|
+
* budget is not what limits a real monorepo. It limits the pathological case of
|
|
60
|
+
* a repo with thousands of near-empty directories, which is precisely where an
|
|
61
|
+
* unbounded renderer would have produced tens of thousands of tokens to say
|
|
62
|
+
* almost nothing.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_TREE_BUDGET_TOKENS = 2_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ⭐ 6 AND 10 ARE THE MEASURED WINDOW, not a taste call. Below 6 the model is
|
|
68
|
+
* working without the surrounding context that beat gold; above 10 the extra
|
|
69
|
+
* files measure as noise. Both ends are enforced.
|
|
70
|
+
*/
|
|
71
|
+
export const OPTIMAL_MIN_FILES = 6;
|
|
72
|
+
export const OPTIMAL_MAX_FILES = 10;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ⚠️ THE LOOP IS BOUNDED BECAUSE A MODEL CAN NAME A NEW FILE FOREVER. Three
|
|
76
|
+
* rounds is: name → look at skeletons → confirm. A run that has not converged
|
|
77
|
+
* by then is not going to, and every extra round is a whole model call.
|
|
78
|
+
*/
|
|
79
|
+
export const MAX_LOCALIZE_ROUNDS = 3;
|
|
80
|
+
|
|
81
|
+
/** How many directories the omission note names before it stops. */
|
|
82
|
+
const MAX_GAP_LINES = 8;
|
|
83
|
+
|
|
84
|
+
/** A skeleton line longer than this is a minified file, not a signature. */
|
|
85
|
+
const MAX_LINE_CHARS = 200;
|
|
86
|
+
|
|
87
|
+
/** A signature spanning more lines than this is not a signature any more. */
|
|
88
|
+
const MAX_SIGNATURE_LINES = 8;
|
|
89
|
+
|
|
90
|
+
/** Source extensions a padding sibling may have. Prose is not a code sibling. */
|
|
91
|
+
const SOURCE_EXT = /\.(mjs|cjs|jsx?|tsx?|mts|cts|py|go|rs|rb|php|java|kt|swift|cs|c|h|cc|cpp|hpp|vue|svelte)$/i;
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// PATHS
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One spelling of a path, so two spellings of the same file cannot read as two
|
|
99
|
+
* files. ⚠️ THE BACKSLASH CASE IS NOT HYPOTHETICAL — this project is developed
|
|
100
|
+
* on Windows, and a model handed `lib\localize.mjs` in one round and
|
|
101
|
+
* `lib/localize.mjs` in the next would never reach a fixed point.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizePath(p) {
|
|
104
|
+
if (typeof p !== 'string') return '';
|
|
105
|
+
let out = p.replace(/\\/g, '/').trim();
|
|
106
|
+
while (out.startsWith('./')) out = out.slice(2);
|
|
107
|
+
while (out.startsWith('/')) out = out.slice(1);
|
|
108
|
+
while (out.endsWith('/')) out = out.slice(0, -1);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The directory a path sits in. `''` — not `'.'` — for a file at the root. */
|
|
113
|
+
function dirOf(path) {
|
|
114
|
+
const i = path.lastIndexOf('/');
|
|
115
|
+
return i < 0 ? '' : path.slice(0, i);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function depthOf(dir) {
|
|
119
|
+
if (dir === '') return 0;
|
|
120
|
+
return dir.split('/').length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
// THE TREE
|
|
125
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Index every directory implied by a list of paths.
|
|
129
|
+
*
|
|
130
|
+
* ⭐ `own` AND `subtree` ARE BOTH NEEDED, and keeping only one is the bug.
|
|
131
|
+
* `own` alone makes a parent whose children were pruned look empty; `subtree`
|
|
132
|
+
* alone makes a package root look like it holds 1,250 files directly. The model
|
|
133
|
+
* decides whether to cut a folder on the size of what it is cutting, which is
|
|
134
|
+
* `subtree`, and decides whether to look inside on `own`.
|
|
135
|
+
*
|
|
136
|
+
* @param {string[]} paths
|
|
137
|
+
* @returns {Map<string, {dir: string, own: number, subtree: number, depth: number}>}
|
|
138
|
+
*/
|
|
139
|
+
export function buildDirIndex(paths) {
|
|
140
|
+
const index = new Map();
|
|
141
|
+
const touch = (dir) => {
|
|
142
|
+
let row = index.get(dir);
|
|
143
|
+
if (!row) {
|
|
144
|
+
row = { dir, own: 0, subtree: 0, depth: depthOf(dir) };
|
|
145
|
+
index.set(dir, row);
|
|
146
|
+
}
|
|
147
|
+
return row;
|
|
148
|
+
};
|
|
149
|
+
touch('');
|
|
150
|
+
|
|
151
|
+
for (const raw of Array.isArray(paths) ? paths : []) {
|
|
152
|
+
const path = normalizePath(raw);
|
|
153
|
+
if (path === '') continue;
|
|
154
|
+
const dir = dirOf(path);
|
|
155
|
+
touch(dir).own += 1;
|
|
156
|
+
// Every ancestor counts it in its subtree, including the root sentinel.
|
|
157
|
+
let cursor = dir;
|
|
158
|
+
for (;;) {
|
|
159
|
+
touch(cursor).subtree += 1;
|
|
160
|
+
if (cursor === '') break;
|
|
161
|
+
cursor = dirOf(cursor);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return index;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render the directory tree, under a hard budget.
|
|
169
|
+
*
|
|
170
|
+
* ── ⭐⭐ WHY DIRECTORIES, AND WHY THIS IS THE SIZE ANSWER ────────────────────
|
|
171
|
+
*
|
|
172
|
+
* A 50,000-file monorepo must not produce a 50,000-line prompt. It does not,
|
|
173
|
+
* and not because of the budget — because the UNIT is a directory. Measured on
|
|
174
|
+
* exactly that shape (40 packages × 1,250 files): **90 lines, and all 40
|
|
175
|
+
* packages visible**. The budget is the second line of defence, for a tree that
|
|
176
|
+
* is pathological in directories rather than files.
|
|
177
|
+
*
|
|
178
|
+
* ── ⭐ SHALLOW FIRST, AND WHY THAT IS NOT `repo-map`'s DEPTH CLIFF ──────────
|
|
179
|
+
*
|
|
180
|
+
* `repo-map.mjs` documents, with numbers, that ordering a FILE list by depth
|
|
181
|
+
* produced "659 files listed, drawn from 7 of 360 directories" — a cliff. The
|
|
182
|
+
* ordering here is depth-first-shallow anyway, and the inversion is the point:
|
|
183
|
+
* the model is choosing folders to DISCARD, and a folder subsumes its subtree.
|
|
184
|
+
* Cutting `docs/` cuts every `docs/**` whether or not `docs/deep/deeper/` was
|
|
185
|
+
* ever printed. So a shallow line is worth more than a deep one here in a way
|
|
186
|
+
* it never is in a file list, and a deep directory that was not shown is not
|
|
187
|
+
* invisible — its files are still counted in its parent's `subtree`.
|
|
188
|
+
*
|
|
189
|
+
* @param {string[]} paths
|
|
190
|
+
* @param {{budgetTokens?: number}} [opts]
|
|
191
|
+
*/
|
|
192
|
+
export function renderTree(paths, opts = {}) {
|
|
193
|
+
const budgetTokens = opts.budgetTokens ?? DEFAULT_TREE_BUDGET_TOKENS;
|
|
194
|
+
const index = buildDirIndex(paths);
|
|
195
|
+
const filesTotal = index.get('')?.subtree ?? 0;
|
|
196
|
+
|
|
197
|
+
// Directories with nothing in their subtree cannot exist here (they are only
|
|
198
|
+
// created by a path), but a defensive filter costs nothing and keeps the
|
|
199
|
+
// renderer honest if the index is ever fed directly.
|
|
200
|
+
const rows = [...index.values()].filter((r) => r.subtree > 0);
|
|
201
|
+
rows.sort((a, b) => a.depth - b.depth || b.subtree - a.subtree || byCodePoint(a.dir, b.dir));
|
|
202
|
+
|
|
203
|
+
const lineFor = (r) => {
|
|
204
|
+
const name = r.dir === '' ? './' : `${r.dir}/`;
|
|
205
|
+
const deeper = r.subtree - r.own;
|
|
206
|
+
if (deeper > 0) return ` ${name} ${r.own} files (+${deeper} deeper)`;
|
|
207
|
+
return ` ${name} ${r.own} files`;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const render = (n) => {
|
|
211
|
+
const shown = rows.slice(0, n);
|
|
212
|
+
const hidden = rows.slice(n);
|
|
213
|
+
const parts = [
|
|
214
|
+
'REPO TREE — directories only. Counts are files directly in the folder;',
|
|
215
|
+
'"+N deeper" is everything below it, shown or not.',
|
|
216
|
+
'',
|
|
217
|
+
...[...shown].sort((a, b) => byCodePoint(a.dir, b.dir)).map(lineFor),
|
|
218
|
+
];
|
|
219
|
+
if (hidden.length > 0) {
|
|
220
|
+
/**
|
|
221
|
+
* ⚠️ NAMED PARENTS, NOT A BARE COUNT. `repo-map.mjs` learned this the
|
|
222
|
+
* same way: "42 directories omitted" is unactionable, while "src/ 31
|
|
223
|
+
* directories" is a next move — and here it is more than a courtesy,
|
|
224
|
+
* because the model is about to declare folders irrelevant and must not
|
|
225
|
+
* declare one irrelevant on the strength of a listing it cannot see.
|
|
226
|
+
*/
|
|
227
|
+
const byTop = new Map();
|
|
228
|
+
for (const r of hidden) {
|
|
229
|
+
const top = r.dir.includes('/') ? `${r.dir.slice(0, r.dir.indexOf('/'))}/` : `${r.dir}/`;
|
|
230
|
+
byTop.set(top, (byTop.get(top) ?? 0) + 1);
|
|
231
|
+
}
|
|
232
|
+
const gaps = [...byTop.entries()]
|
|
233
|
+
.sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
|
|
234
|
+
.slice(0, MAX_GAP_LINES);
|
|
235
|
+
parts.push('', `NOT SHOWN ${hidden.length} of ${rows.length} directories — deepest first`);
|
|
236
|
+
for (const [top, n2] of gaps) parts.push(` under ${top} ${n2} directories`);
|
|
237
|
+
}
|
|
238
|
+
parts.push('', `TOTALS ${rows.length} directories, ${filesTotal} files`);
|
|
239
|
+
return parts.join('\n');
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Largest prefix that fits. Monotonic in `n`, so a binary search is exact and
|
|
243
|
+
// cannot loop — a renderer that hangs on a big repo is the failure this whole
|
|
244
|
+
// module is meant to prevent.
|
|
245
|
+
let lo = 0;
|
|
246
|
+
let hi = rows.length;
|
|
247
|
+
if (estimateTokens(render(hi)) > budgetTokens) {
|
|
248
|
+
while (lo < hi) {
|
|
249
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
250
|
+
if (estimateTokens(render(mid)) <= budgetTokens) lo = mid;
|
|
251
|
+
else hi = mid - 1;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
lo = rows.length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const text = render(lo);
|
|
258
|
+
const tokens = estimateTokens(text);
|
|
259
|
+
return {
|
|
260
|
+
text,
|
|
261
|
+
dirsTotal: rows.length,
|
|
262
|
+
dirsShown: lo,
|
|
263
|
+
filesTotal,
|
|
264
|
+
truncated: lo < rows.length,
|
|
265
|
+
tokens,
|
|
266
|
+
/**
|
|
267
|
+
* ── ⚠️ THE BUDGET BOUNDS THE LISTING, NOT THE FRAME, AND IT SAYS SO ──────
|
|
268
|
+
*
|
|
269
|
+
* Measured while writing this: at `budgetTokens: 60` the renderer returned
|
|
270
|
+
* 127 tokens with ZERO directories listed, because the header, the
|
|
271
|
+
* NOT-SHOWN note and the totals are a fixed ~120-token frame. A budget below
|
|
272
|
+
* the frame cannot be met by dropping lines, and the only two honest
|
|
273
|
+
* options are to return a truncated frame that lies about being a tree, or
|
|
274
|
+
* to return the frame and FLAG the overrun.
|
|
275
|
+
*
|
|
276
|
+
* ⭐ Flagging is the one that cannot mislead. A caller that must fit a hard
|
|
277
|
+
* ceiling can test this and drop the tree entirely; a caller that cannot
|
|
278
|
+
* still gets a tree that states its own totals. Silently exceeding the
|
|
279
|
+
* budget while reporting nothing is the shape this repo keeps paying for.
|
|
280
|
+
*/
|
|
281
|
+
budgetOverrun: tokens > budgetTokens,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
286
|
+
// THE NEGATIVE FILTER — the step almost nobody copies
|
|
287
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Apply a model-named list of IRRELEVANT folders to a path list.
|
|
291
|
+
*
|
|
292
|
+
* ── ⭐ WHY ASK WHAT TO THROW AWAY RATHER THAN WHAT TO KEEP ──────────────────
|
|
293
|
+
*
|
|
294
|
+
* A "which folders matter" question makes the model commit to a small set from
|
|
295
|
+
* a listing it has only skimmed, and everything it forgot is gone. A "which
|
|
296
|
+
* folders are irrelevant" question is answered from confidence: `docs/`,
|
|
297
|
+
* `vendor/`, `fixtures/` are obviously not where a login bug lives, and
|
|
298
|
+
* anything the model is unsure about simply survives. The failure mode of the
|
|
299
|
+
* first question is losing the answer; the failure mode of the second is doing
|
|
300
|
+
* less work than we hoped. Those are not comparable costs.
|
|
301
|
+
*
|
|
302
|
+
* ── ⚠️ TWO GUARDS, BOTH OF THEM LOAD-BEARING ───────────────────────────────
|
|
303
|
+
*
|
|
304
|
+
* · SEGMENT MATCHING. `src/app` must not delete `src/apple.js`. A bare
|
|
305
|
+
* `startsWith` does exactly that, silently, and the file it eats is a source
|
|
306
|
+
* file in the same directory as the one the model actually wanted.
|
|
307
|
+
* · REFUSAL. A filter that removes EVERY candidate is rejected whole. An
|
|
308
|
+
* empty candidate set does not read downstream as "the filter was wrong",
|
|
309
|
+
* it reads as "this repo has no relevant files" — an error disguised as an
|
|
310
|
+
* answer, which is the one shape this codebase keeps paying for.
|
|
311
|
+
*
|
|
312
|
+
* @returns {{kept: string[], removed: string[], unmatched: string[], rejected: string[], refused: boolean, reason: string}}
|
|
313
|
+
*/
|
|
314
|
+
export function applyIrrelevantFilter(paths, folders) {
|
|
315
|
+
const all = (Array.isArray(paths) ? paths : []).map(normalizePath).filter((p) => p !== '');
|
|
316
|
+
const rejected = [];
|
|
317
|
+
const wanted = [];
|
|
318
|
+
for (const raw of Array.isArray(folders) ? folders : []) {
|
|
319
|
+
const f = normalizePath(raw);
|
|
320
|
+
// ⚠️ `.`, `/`, `` and `..` all mean "the whole repo" after normalization.
|
|
321
|
+
// They are rejected here rather than caught by the emptiness guard below,
|
|
322
|
+
// so the caller can tell a bad ENTRY from an over-broad but legitimate one.
|
|
323
|
+
if (f === '' || f === '.' || f === '..') { rejected.push(String(raw)); continue; }
|
|
324
|
+
wanted.push(f);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const hits = new Map(wanted.map((f) => [f, 0]));
|
|
328
|
+
const kept = [];
|
|
329
|
+
const removed = [];
|
|
330
|
+
for (const p of all) {
|
|
331
|
+
let cut = null;
|
|
332
|
+
for (const f of wanted) {
|
|
333
|
+
// Equality catches a folder name that is actually a file; the trailing
|
|
334
|
+
// slash is what makes this a SEGMENT match rather than a prefix match.
|
|
335
|
+
if (p === f || p.startsWith(`${f}/`)) { cut = f; break; }
|
|
336
|
+
}
|
|
337
|
+
if (cut === null) kept.push(p);
|
|
338
|
+
else { removed.push(p); hits.set(cut, hits.get(cut) + 1); }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const unmatched = [...hits.entries()].filter(([, n]) => n === 0).map(([f]) => f).sort(byCodePoint);
|
|
342
|
+
|
|
343
|
+
if (all.length > 0 && kept.length === 0) {
|
|
344
|
+
return {
|
|
345
|
+
kept: all,
|
|
346
|
+
removed: [],
|
|
347
|
+
unmatched,
|
|
348
|
+
rejected,
|
|
349
|
+
refused: true,
|
|
350
|
+
reason: 'the filter would have removed every candidate file, so it was not applied',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { kept, removed, unmatched, rejected, refused: false, reason: '' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
358
|
+
// THE SKELETON — signatures and declarations, never a body
|
|
359
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* ⚠️ EXTENSION → LANGUAGE, and an unknown one produces NOTHING.
|
|
363
|
+
* `repo-map.mjs` states the rule for symbol names and it is stronger here: a
|
|
364
|
+
* markdown file containing the words "export function" is not a module, and a
|
|
365
|
+
* fabricated skeleton is worse than no skeleton because the model reads a
|
|
366
|
+
* skeleton as a PARSE and stops looking.
|
|
367
|
+
*/
|
|
368
|
+
function languageOf(path) {
|
|
369
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(String(path || ''));
|
|
370
|
+
if (!m) return null;
|
|
371
|
+
const ext = m[1].toLowerCase();
|
|
372
|
+
if (['mjs', 'cjs', 'js', 'jsx'].includes(ext)) return 'js';
|
|
373
|
+
if (['ts', 'tsx', 'mts', 'cts'].includes(ext)) return 'ts';
|
|
374
|
+
if (ext === 'py') return 'py';
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Words that begin a STATEMENT, not a declaration. A body line, in short. */
|
|
379
|
+
const JS_CONTROL = /^(if|for|while|switch|catch|return|else|do|try|throw|await|yield|case|break|continue|new|delete|typeof|void)\b/;
|
|
380
|
+
|
|
381
|
+
const JS_DECL = [
|
|
382
|
+
/^export\s+default\b/,
|
|
383
|
+
/^export\s+(async\s+)?function\b/,
|
|
384
|
+
/^export\s+(abstract\s+)?class\b/,
|
|
385
|
+
/^export\s+(const|let|var)\b/,
|
|
386
|
+
/^export\s+(type|interface|enum|namespace|declare)\b/,
|
|
387
|
+
/^export\s*[{*]/,
|
|
388
|
+
/^import\b/,
|
|
389
|
+
/^(async\s+)?function\s*\*?\s*[A-Za-z_$]/,
|
|
390
|
+
/^(abstract\s+)?class\s+[A-Za-z_$]/,
|
|
391
|
+
/^(const|let|var)\s+[A-Za-z_$][\w$]*\s*[:=]/,
|
|
392
|
+
/^(type|interface|enum|namespace|declare)\s+[A-Za-z_$]/,
|
|
393
|
+
/^module\.exports\b/,
|
|
394
|
+
/^exports\.[A-Za-z_$]/,
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
/** Inside a class body: `run(x) {`, `async run(x) {`, `get size() {`, `#priv(` … */
|
|
398
|
+
const JS_MEMBER = /^((public|private|protected|static|readonly|abstract|override|async|get|set)\s+)*[#*]?[A-Za-z_$][\w$]*\s*[(<]/;
|
|
399
|
+
|
|
400
|
+
const PY_DECL = [
|
|
401
|
+
/^(async\s+)?def\s+/,
|
|
402
|
+
/^class\s+/,
|
|
403
|
+
/^@[A-Za-z_]/,
|
|
404
|
+
/^(import|from)\s+/,
|
|
405
|
+
/^[A-Z_][A-Z0-9_]*\s*[:=]/,
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
/** Parens/brackets still open at the end of a line — i.e. the signature runs on. */
|
|
409
|
+
function openDelta(line) {
|
|
410
|
+
let d = 0;
|
|
411
|
+
let str = null;
|
|
412
|
+
for (let i = 0; i < line.length; i++) {
|
|
413
|
+
const c = line[i];
|
|
414
|
+
if (str) {
|
|
415
|
+
if (c === '\\') { i += 1; continue; }
|
|
416
|
+
if (c === str) str = null;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (c === '"' || c === "'" || c === '`') { str = c; continue; }
|
|
420
|
+
if (c === '(' || c === '[') d += 1;
|
|
421
|
+
else if (c === ')' || c === ']') d -= 1;
|
|
422
|
+
}
|
|
423
|
+
return d;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* ⭐ THE BODY IS CUT AT THE BRACE, not at the line. `export function f(a) { return a; }`
|
|
428
|
+
* is a declaration AND a body on one line, and keeping it whole leaks exactly
|
|
429
|
+
* what the skeleton exists to drop.
|
|
430
|
+
*/
|
|
431
|
+
function trimBody(line, language) {
|
|
432
|
+
let out = line;
|
|
433
|
+
if (language !== 'py') {
|
|
434
|
+
/**
|
|
435
|
+
* ⚠️⚠️ THE FIRST VERSION ATE EVERY IMPORT, AND THE TEST CAUGHT IT.
|
|
436
|
+
* `import { readFileSync } from 'node:fs';` rendered as the single word
|
|
437
|
+
* `import` — the brace-cut fired on a BINDING LIST. An import line that
|
|
438
|
+
* says nothing about what was imported is worse than no import line: the
|
|
439
|
+
* model reads "this file imports something" and learns nothing, while
|
|
440
|
+
* paying for the line. On an import/export the braces ARE the payload.
|
|
441
|
+
*/
|
|
442
|
+
/**
|
|
443
|
+
* ⚠️⚠️ THE FIRST FIX FOR THAT WAS `/^(import|export)\b/` AND IT WAS TOO
|
|
444
|
+
* WIDE — a second test caught it. It exempted `export function f(a) {
|
|
445
|
+
* return SECRET; }` as well, so a one-line function body was reproduced
|
|
446
|
+
* verbatim in the "signatures only" output. The exemption belongs to the
|
|
447
|
+
* two forms where a brace is a BINDING LIST rather than a block: an import,
|
|
448
|
+
* and a re-export (`export { a }` / `export * from`). `export function`,
|
|
449
|
+
* `export class` and `export const` all open real bodies.
|
|
450
|
+
*/
|
|
451
|
+
const isBindingList = /^import\b/.test(out) || /^export\s*[{*]/.test(out);
|
|
452
|
+
const brace = isBindingList ? -1 : out.indexOf('{');
|
|
453
|
+
// ⚠️ NOT WHEN THE BRACE IS THE TYPE. `function f(o: { a: number })` opens a
|
|
454
|
+
// brace inside the parameter list; cutting there destroys the signature.
|
|
455
|
+
if (brace >= 0 && openDelta(out.slice(0, brace)) <= 0) out = out.slice(0, brace).replace(/\s+$/, '');
|
|
456
|
+
} else {
|
|
457
|
+
const colon = out.lastIndexOf(':');
|
|
458
|
+
if (colon >= 0 && colon === out.length - 1) out = out.slice(0, colon + 1);
|
|
459
|
+
}
|
|
460
|
+
if (out.length > MAX_LINE_CHARS) out = `${out.slice(0, MAX_LINE_CHARS)}…`;
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Signatures and declarations for one file, with line numbers, no bodies.
|
|
466
|
+
*
|
|
467
|
+
* ── ⭐ WHY THIS IS THE STEP THAT MAKES 6-10 FILES AFFORDABLE ────────────────
|
|
468
|
+
*
|
|
469
|
+
* The window is 6-10 files because that is what measures best — but ten whole
|
|
470
|
+
* files is tens of thousands of tokens and blows the round budget on its own.
|
|
471
|
+
* A skeleton is what makes the measured optimum reachable rather than
|
|
472
|
+
* theoretical: the model sees the shape of ten files for the price of two, and
|
|
473
|
+
* then spends `read_file` on the one or two it actually needs.
|
|
474
|
+
*
|
|
475
|
+
* ── ⭐ THE LINE NUMBERS ARE THE ELISION MARKER ──────────────────────────────
|
|
476
|
+
*
|
|
477
|
+
* There is no `// …` between kept lines, because the gap in the numbering says
|
|
478
|
+
* the same thing for free AND says how big the gap is. They are also the only
|
|
479
|
+
* way back: a model that wants the body of `run()` reads `read_around` at that
|
|
480
|
+
* number instead of re-reading the file.
|
|
481
|
+
*
|
|
482
|
+
* ⚠️ THE OUTPUT IS A REGEX GUESS AND SAYS SO. A missing member proves nothing.
|
|
483
|
+
*
|
|
484
|
+
* @returns {{ok: boolean, language: string|null, text: string, linesKept: number, linesTotal: number, bytesIn: number, bytesOut: number, reason: string}}
|
|
485
|
+
*/
|
|
486
|
+
export function extractSkeleton(path, source, opts = {}) {
|
|
487
|
+
const language = languageOf(path);
|
|
488
|
+
const bytesIn = typeof source === 'string' ? source.length : 0;
|
|
489
|
+
const nothing = (reason) => ({
|
|
490
|
+
ok: false, language, text: '', linesKept: 0, linesTotal: 0, bytesIn, bytesOut: 0, reason,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
if (typeof source !== 'string' || source === '') return nothing('the file is empty or could not be read');
|
|
494
|
+
if (!language) return nothing(`no skeleton for this file type — read it with read_file if you need it`);
|
|
495
|
+
|
|
496
|
+
const maxLines = opts.maxLines ?? 400;
|
|
497
|
+
const lines = source.split(/\r?\n/);
|
|
498
|
+
const kept = [];
|
|
499
|
+
|
|
500
|
+
let classIndent = -1;
|
|
501
|
+
let carry = 0; // parens still open from an unfinished signature
|
|
502
|
+
|
|
503
|
+
for (let i = 0; i < lines.length && kept.length < maxLines; i++) {
|
|
504
|
+
const raw = lines[i];
|
|
505
|
+
const trimmed = raw.trim();
|
|
506
|
+
const indent = raw.length - raw.trimStart().length;
|
|
507
|
+
|
|
508
|
+
if (carry > 0) {
|
|
509
|
+
// ⚠️ A SIGNATURE THAT SPANS LINES IS ONE SIGNATURE. Cutting it at the
|
|
510
|
+
// first line leaves `export function wide(` — a fragment that tells the
|
|
511
|
+
// model the function exists and lies about how it is called.
|
|
512
|
+
kept.push({ n: i + 1, text: trimBody(trimmed, language) });
|
|
513
|
+
carry += openDelta(trimmed);
|
|
514
|
+
if (carry < 0) carry = 0;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (trimmed === '') continue;
|
|
519
|
+
|
|
520
|
+
if (language === 'py') {
|
|
521
|
+
if (!PY_DECL.some((rx) => rx.test(trimmed))) continue;
|
|
522
|
+
kept.push({ n: i + 1, text: `${' '.repeat(Math.min(indent, 8))}${trimBody(trimmed, language)}` });
|
|
523
|
+
const d = openDelta(trimmed);
|
|
524
|
+
if (d > 0) carry = Math.min(d, MAX_SIGNATURE_LINES);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// JS/TS. Class membership is tracked by indentation rather than by counting
|
|
529
|
+
// braces: a member is a line INSIDE a class that looks callable, and the
|
|
530
|
+
// class ends at the first line indented no further than the class itself.
|
|
531
|
+
if (classIndent >= 0 && indent <= classIndent && !/^[})\]]/.test(trimmed)) classIndent = -1;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* ── ⚠️⚠️ INDENTATION IS WHAT SEPARATES A DECLARATION FROM A LOCAL ──────
|
|
535
|
+
*
|
|
536
|
+
* The first version matched `^(const|let|var)\s+\w+\s*[:=]` at ANY indent
|
|
537
|
+
* and therefore kept `const intermediate = a * SECRET_BODY_TOKEN;` — a line
|
|
538
|
+
* from inside a function body, which is exactly the thing a skeleton exists
|
|
539
|
+
* to drop. It is not a small leak either: locals are the majority of lines
|
|
540
|
+
* in most functions, so the "skeleton" was reproducing the file.
|
|
541
|
+
*
|
|
542
|
+
* ⭐ A skeleton is MODULE-LEVEL structure plus CLASS MEMBERS, and both of
|
|
543
|
+
* those have a known indentation. `import`/`export` are exempt because a
|
|
544
|
+
* `declare module { … }` block legitimately indents them.
|
|
545
|
+
*/
|
|
546
|
+
const isDecl = indent === 0
|
|
547
|
+
? JS_DECL.some((rx) => rx.test(trimmed))
|
|
548
|
+
: /^(export|import)\b/.test(trimmed) && JS_DECL.some((rx) => rx.test(trimmed));
|
|
549
|
+
const isMember = classIndent >= 0
|
|
550
|
+
&& indent > classIndent
|
|
551
|
+
&& !JS_CONTROL.test(trimmed)
|
|
552
|
+
&& JS_MEMBER.test(trimmed)
|
|
553
|
+
// ⚠️ A CALL IS NOT A MEMBER. `doSomething(x);` matches JS_MEMBER's shape
|
|
554
|
+
// exactly; what separates a declaration from a call is that a declaration
|
|
555
|
+
// OPENS A BODY on the same line or runs on to the next.
|
|
556
|
+
&& (trimmed.includes('{') || openDelta(trimmed) > 0 || /[)>]\s*[:;]?\s*$/.test(trimmed))
|
|
557
|
+
&& !trimmed.endsWith(';');
|
|
558
|
+
|
|
559
|
+
if (!isDecl && !isMember) continue;
|
|
560
|
+
|
|
561
|
+
kept.push({ n: i + 1, text: `${classIndent >= 0 && isMember ? ' ' : ''}${trimBody(trimmed, language)}` });
|
|
562
|
+
|
|
563
|
+
if (/^(export\s+)?(abstract\s+)?class\s/.test(trimmed) || /^export\s+default\s+class\s/.test(trimmed)) {
|
|
564
|
+
classIndent = indent;
|
|
565
|
+
}
|
|
566
|
+
const d = openDelta(trimmed);
|
|
567
|
+
if (d > 0) carry = Math.min(d, MAX_SIGNATURE_LINES);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (kept.length === 0) return nothing('no declarations found — this file may be data, or all body');
|
|
571
|
+
|
|
572
|
+
const width = String(lines.length).length;
|
|
573
|
+
const body = kept.map((k) => `${String(k.n).padStart(width, ' ')}: ${k.text}`).join('\n');
|
|
574
|
+
const text = `${path} — signatures only (a regex guess; a missing member proves nothing)\n${body}`;
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
language,
|
|
579
|
+
text,
|
|
580
|
+
linesKept: kept.length,
|
|
581
|
+
linesTotal: lines.length,
|
|
582
|
+
bytesIn,
|
|
583
|
+
bytesOut: text.length,
|
|
584
|
+
reason: '',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
589
|
+
// THE WINDOW
|
|
590
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Enforce the measured 6-10 file window.
|
|
594
|
+
*
|
|
595
|
+
* ⭐ PADDING IS THE COUNTER-INTUITIVE HALF, and it is the finding this whole
|
|
596
|
+
* module is built on: LLM-selected files at **8.5 files scored 60.6%**, gold
|
|
597
|
+
* buggy-files-only at **1.25 files scored 56.2%**. A precise answer measured
|
|
598
|
+
* WORSE than a loose one. So a model that names two files is not rewarded for
|
|
599
|
+
* its confidence — its neighbours are added, because "the file next to the bug"
|
|
600
|
+
* is where the caller, the type and the test live.
|
|
601
|
+
*
|
|
602
|
+
* ⚠️ SIBLINGS ONLY, AND SOURCE ONLY. Padding with `docs/x.md` spends the window
|
|
603
|
+
* on something that cannot contain the answer; padding from an unrelated
|
|
604
|
+
* directory is the noise that line-level narrowing was measured to introduce.
|
|
605
|
+
*/
|
|
606
|
+
export function padToWindow(chosen, allPaths, min = OPTIMAL_MIN_FILES, max = OPTIMAL_MAX_FILES) {
|
|
607
|
+
const all = (Array.isArray(allPaths) ? allPaths : []).map(normalizePath).filter(Boolean);
|
|
608
|
+
const files = [];
|
|
609
|
+
const seen = new Set();
|
|
610
|
+
for (const raw of Array.isArray(chosen) ? chosen : []) {
|
|
611
|
+
const p = normalizePath(raw);
|
|
612
|
+
if (p === '' || seen.has(p)) continue;
|
|
613
|
+
seen.add(p);
|
|
614
|
+
files.push(p);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const before = files.length;
|
|
618
|
+
if (files.length < min) {
|
|
619
|
+
const dirs = [...new Set(files.map(dirOf))];
|
|
620
|
+
const siblings = all
|
|
621
|
+
.filter((p) => !seen.has(p) && SOURCE_EXT.test(p) && dirs.includes(dirOf(p)))
|
|
622
|
+
.sort(byCodePoint);
|
|
623
|
+
for (const s of siblings) {
|
|
624
|
+
if (files.length >= min) break;
|
|
625
|
+
seen.add(s);
|
|
626
|
+
files.push(s);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const out = files.slice(0, max);
|
|
631
|
+
return { files: out, padded: Math.max(0, out.length - before), truncated: files.length > max };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
635
|
+
// THE FIXED POINT
|
|
636
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Has the model stopped asking for new files?
|
|
640
|
+
*
|
|
641
|
+
* ⚠️ "NOTHING NEW", NOT "THE SAME LIST". Context is monotone — a file that was
|
|
642
|
+
* put in the prompt last round is still in the prompt — so a round that drops a
|
|
643
|
+
* file it named before has still added nothing and the loop is done. Testing
|
|
644
|
+
* for an identical list would spin forever against a model that reorders, which
|
|
645
|
+
* they do, and each spin is a whole paid round.
|
|
646
|
+
*/
|
|
647
|
+
export function convergence(inContext, named) {
|
|
648
|
+
const have = new Set((Array.isArray(inContext) ? inContext : []).map(normalizePath).filter(Boolean));
|
|
649
|
+
const added = [];
|
|
650
|
+
for (const raw of Array.isArray(named) ? named : []) {
|
|
651
|
+
const p = normalizePath(raw);
|
|
652
|
+
if (p === '' || have.has(p) || added.includes(p)) continue;
|
|
653
|
+
added.push(p);
|
|
654
|
+
}
|
|
655
|
+
return { converged: added.length === 0, added, union: [...have, ...added] };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
659
|
+
// THE LOOP
|
|
660
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Agentless's sequence, with every model call injected.
|
|
664
|
+
*
|
|
665
|
+
* tree → "which folders are IRRELEVANT" → 5-10 files by importance →
|
|
666
|
+
* skeletons → ask again → stop when nothing new is named.
|
|
667
|
+
*
|
|
668
|
+
* ⚠️ NO EMBEDDINGS, NO INDEX, NO SIMILARITY SEARCH. That is not a shortcut —
|
|
669
|
+
* it is the approach that measured best, and it is why this module has zero
|
|
670
|
+
* dependencies and needs no build step.
|
|
671
|
+
*
|
|
672
|
+
* ⚠️⚠️ AND NO LINE RANGES. Line-level narrowing measured WORSE than whole
|
|
673
|
+
* files. If a future round of this module grows a "which lines" step, it is
|
|
674
|
+
* undoing a measurement, not adding a feature.
|
|
675
|
+
*
|
|
676
|
+
* @param {object} o
|
|
677
|
+
* @param {string[]} o.paths every candidate path in the repo
|
|
678
|
+
* @param {(req: {step: string, task: string, tree?: string, candidates?: string[], skeletons?: string}) => Promise<string[]>} o.askImpl
|
|
679
|
+
* @param {(path: string) => string|null} [o.readImpl]
|
|
680
|
+
*/
|
|
681
|
+
export async function localize({
|
|
682
|
+
paths = [],
|
|
683
|
+
task = '',
|
|
684
|
+
askImpl = null,
|
|
685
|
+
readImpl = () => null,
|
|
686
|
+
budgetTokens = DEFAULT_TREE_BUDGET_TOKENS,
|
|
687
|
+
minFiles = OPTIMAL_MIN_FILES,
|
|
688
|
+
maxFiles = OPTIMAL_MAX_FILES,
|
|
689
|
+
maxRounds = MAX_LOCALIZE_ROUNDS,
|
|
690
|
+
} = {}) {
|
|
691
|
+
const stats = { askCalls: 0, skeletonBytesIn: 0, skeletonBytesOut: 0 };
|
|
692
|
+
const fail = (error) => ({
|
|
693
|
+
ok: false, error, files: [], skeletons: [], rounds: 0, converged: false,
|
|
694
|
+
tree: '', irrelevant: [], unmatched: [], refused: false, stats,
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* ⚠️ THIS IS THE SPEND GUARD, AND IT IS STRUCTURAL. The module imports no
|
|
699
|
+
* model client, so "no askImpl" is not a missing option — it is the absence
|
|
700
|
+
* of any way to make a call at all. Refusing here makes that explicit rather
|
|
701
|
+
* than returning a plausible empty answer.
|
|
702
|
+
*/
|
|
703
|
+
if (typeof askImpl !== 'function') {
|
|
704
|
+
return fail('localize needs an askImpl — this module makes no model calls of its own');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const candidates = (Array.isArray(paths) ? paths : []).map(normalizePath).filter(Boolean);
|
|
708
|
+
if (candidates.length === 0) return fail('no candidate paths — nothing to localize against');
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
// ── 1. the tree, and the negative filter ────────────────────────────────
|
|
712
|
+
const tree = renderTree(candidates, { budgetTokens });
|
|
713
|
+
stats.askCalls += 1;
|
|
714
|
+
const folders = await askImpl({ step: 'irrelevant', task, tree: tree.text });
|
|
715
|
+
const filtered = applyIrrelevantFilter(candidates, folders);
|
|
716
|
+
|
|
717
|
+
// ── 2..N. files, skeletons, fixed point ────────────────────────────────
|
|
718
|
+
let context = [];
|
|
719
|
+
let skeletons = [];
|
|
720
|
+
let rounds = 0;
|
|
721
|
+
let converged = false;
|
|
722
|
+
|
|
723
|
+
for (let r = 0; r < Math.max(1, maxRounds); r++) {
|
|
724
|
+
rounds += 1;
|
|
725
|
+
stats.askCalls += 1;
|
|
726
|
+
const named = await askImpl({
|
|
727
|
+
step: 'files',
|
|
728
|
+
task,
|
|
729
|
+
tree: tree.text,
|
|
730
|
+
candidates: filtered.kept,
|
|
731
|
+
skeletons: skeletons.map((s) => s.text).join('\n\n'),
|
|
732
|
+
round: rounds,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
const conv = convergence(context, named);
|
|
736
|
+
context = conv.union;
|
|
737
|
+
if (conv.converged) { converged = true; break; }
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* ⚠️ SKELETONS ARE BUILT ONLY FOR WHAT IS NEW. Re-reading a file the
|
|
741
|
+
* model already has in context spends I/O to produce bytes that are
|
|
742
|
+
* already in the prompt — and it would also move them, which costs the
|
|
743
|
+
* prompt cache the whole tail of the message.
|
|
744
|
+
*/
|
|
745
|
+
for (const p of conv.added.slice(0, maxFiles)) {
|
|
746
|
+
let src = null;
|
|
747
|
+
try { src = readImpl(p); } catch { src = null; }
|
|
748
|
+
const sk = extractSkeleton(p, src);
|
|
749
|
+
stats.skeletonBytesIn += sk.bytesIn;
|
|
750
|
+
stats.skeletonBytesOut += sk.bytesOut;
|
|
751
|
+
if (sk.ok) skeletons.push({ path: p, text: sk.text });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const windowed = padToWindow(context, filtered.kept, minFiles, maxFiles);
|
|
756
|
+
// Only keep skeletons for files that survived the window, so the two halves
|
|
757
|
+
// of the answer can never disagree about which files were chosen.
|
|
758
|
+
skeletons = skeletons.filter((s) => windowed.files.includes(s.path));
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
ok: true,
|
|
762
|
+
files: windowed.files,
|
|
763
|
+
skeletons,
|
|
764
|
+
rounds,
|
|
765
|
+
converged,
|
|
766
|
+
tree: tree.text,
|
|
767
|
+
irrelevant: (Array.isArray(folders) ? folders : []).map(normalizePath).filter(Boolean),
|
|
768
|
+
unmatched: filtered.unmatched,
|
|
769
|
+
refused: filtered.refused,
|
|
770
|
+
padded: windowed.padded,
|
|
771
|
+
stats,
|
|
772
|
+
error: '',
|
|
773
|
+
};
|
|
774
|
+
} catch (err) {
|
|
775
|
+
// ⚠️ NEVER THROWS. Localization is an optimisation on top of a turn, exactly
|
|
776
|
+
// as `repoMapForExecutor` is; a provider hiccup must degrade to "we did not
|
|
777
|
+
// narrow it down" and let the turn proceed, never take the turn down.
|
|
778
|
+
return fail(err?.message ? String(err.message) : String(err));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
783
|
+
// REACHABILITY — schema, dispatch, and the sentence that says it exists
|
|
784
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* ⭐ THREE PARTS OR IT SCORES ZERO. A capability needs a SCHEMA (the model can
|
|
788
|
+
* name it), a DISPATCH (calling it does something), and a DESCRIPTION (the
|
|
789
|
+
* model knows it exists and when to reach). This repo has shipped
|
|
790
|
+
* built-but-unreachable four times in one day; the schema and the handler live
|
|
791
|
+
* here together so wiring is one import and one case rather than three edits in
|
|
792
|
+
* three files that can drift apart.
|
|
793
|
+
*/
|
|
794
|
+
export function localizeToolSchemas() {
|
|
795
|
+
return [{
|
|
796
|
+
type: 'function',
|
|
797
|
+
function: {
|
|
798
|
+
name: 'localize_files',
|
|
799
|
+
description:
|
|
800
|
+
'Work out WHICH FILES a task touches, before reading any of them. Renders the repo as a directory tree, '
|
|
801
|
+
+ 'discards folders that are irrelevant, then returns 6-10 files ordered by importance with a signatures-only '
|
|
802
|
+
+ 'skeleton of each. Use this FIRST on an unfamiliar or large repository — file-level localization measures '
|
|
803
|
+
+ '15-17x better than working without it, and 6-10 files with surrounding context measures better than one '
|
|
804
|
+
+ 'exact file. Do not ask it for line ranges: narrowing below the file measures WORSE.',
|
|
805
|
+
parameters: {
|
|
806
|
+
type: 'object',
|
|
807
|
+
properties: {
|
|
808
|
+
task: { type: 'string', description: 'What you are trying to change or fix, in one sentence.' },
|
|
809
|
+
},
|
|
810
|
+
required: ['task'],
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
}];
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* The dispatch half. `deps` carries the seam: the caller supplies `paths`,
|
|
818
|
+
* `askImpl` and `readImpl`, so this function is as network-free as the rest of
|
|
819
|
+
* the module.
|
|
820
|
+
*/
|
|
821
|
+
export async function runLocalizeTool(args = {}, deps = {}) {
|
|
822
|
+
const task = typeof args.task === 'string' ? args.task : '';
|
|
823
|
+
const res = await localize({ ...deps, task });
|
|
824
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
825
|
+
const header = res.converged
|
|
826
|
+
? `localized to ${res.files.length} files in ${res.rounds} round${res.rounds === 1 ? '' : 's'}`
|
|
827
|
+
: `localized to ${res.files.length} files — did NOT converge in ${res.rounds} rounds, so treat this as a starting point`;
|
|
828
|
+
return {
|
|
829
|
+
ok: true,
|
|
830
|
+
files: res.files,
|
|
831
|
+
converged: res.converged,
|
|
832
|
+
text: [header, '', ...res.files.map((f, i) => ` ${i + 1}. ${f}`), '', ...res.skeletons.map((s) => s.text)].join('\n'),
|
|
833
|
+
};
|
|
834
|
+
}
|