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/search.mjs
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ SEARCH — THE DIFFERENCE BETWEEN WRITING FILES AND WORKING IN A REPO ───
|
|
3
|
+
*
|
|
4
|
+
* Until now Acuvo Code could only touch files it was HANDED: the deterministic
|
|
5
|
+
* gather reads the tree and a few small files, and everything else is invisible.
|
|
6
|
+
* That is fine for "create src/thing.js" and useless for "rename this function
|
|
7
|
+
* everywhere" or "why does the login page 404". Asked to change something it
|
|
8
|
+
* cannot find, the model does the only thing left to it — invents a plausible
|
|
9
|
+
* file and writes over the wrong one.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ So this is the highest-value tool the CLI was missing, and it is the one
|
|
12
|
+
* every real coding agent leans on hardest. Two verbs, deliberately:
|
|
13
|
+
* · `find_files` — locate by NAME (glob)
|
|
14
|
+
* · `search_text` — locate by CONTENT (regex, with line numbers)
|
|
15
|
+
*
|
|
16
|
+
* ── ⚠️ WHY IT IS IMPLEMENTED IN NODE AND NOT BY SHELLING TO ripgrep ─────────
|
|
17
|
+
* `command.mjs` exists to keep a shell away from model-authored strings, and its
|
|
18
|
+
* allowlist deliberately excludes every binary but four. Adding `rg` would mean
|
|
19
|
+
* either a new binary in that list (with a pattern argument the model controls —
|
|
20
|
+
* the exact shape the allowlist refuses) or `shell: true`, which hands a shell
|
|
21
|
+
* the string the module exists to keep from one. A directory walk in Node costs
|
|
22
|
+
* a few hundred lines and gives up nothing that matters at repo scale.
|
|
23
|
+
*
|
|
24
|
+
* ── ⚠️⚠️ AND THE THING THIS MODULE GOT WRONG FOR ITS FIRST LIFE ────────────
|
|
25
|
+
* A search tool's output is not a list, it is a CLAIM: "I looked, and here is
|
|
26
|
+
* what is there". `searchText` used to make that claim while quietly declining
|
|
27
|
+
* to open files — too big, NUL byte, unreadable — and returning
|
|
28
|
+
* `{ matches: [], truncated: false, scanned: N }`, three fields that together
|
|
29
|
+
* say "everything was looked at and the string is not there". The model then
|
|
30
|
+
* stops looking, which is the whole cost: a wrong answer it has no reason to
|
|
31
|
+
* doubt. The rule the module already applied to truncation ("a model told the
|
|
32
|
+
* first will stop looking") applies identically to skips, so every file the
|
|
33
|
+
* walk reached and did not read now comes back named, with a reason.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
37
|
+
import { join, relative, sep } from 'node:path';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ⭐ IMPORTED, NEVER RE-IMPLEMENTED. `git.mjs` already owns the one list of
|
|
41
|
+
* "files that must never leave this machine"; `turn.mjs` reuses it for the same
|
|
42
|
+
* reason. A second copy here would be the copy that goes stale the day someone
|
|
43
|
+
* adds a filename to the first one.
|
|
44
|
+
*/
|
|
45
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
46
|
+
import { rankMatches } from './search-rank.mjs';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ⚠️ NEVER DESCENDED. Walking `node_modules` on a real project is tens of
|
|
50
|
+
* thousands of files and would blow both the time budget and the model's
|
|
51
|
+
* context with matches from other people's code. `.git` is worse than useless —
|
|
52
|
+
* it is binary objects that happen to contain fragments of your source.
|
|
53
|
+
*/
|
|
54
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.vercel', 'coverage', '.turbo']);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ── ⭐ HIDDEN DIRECTORIES THAT ARE ORDINARY SOURCE ───────────────────────────
|
|
58
|
+
*
|
|
59
|
+
* DIRECTORIES ONLY, and deliberately short. Every entry here is a place teams
|
|
60
|
+
* keep code they routinely ask an agent to change — CI workflows above all —
|
|
61
|
+
* and none of them is a conventional home for a credential. `.env` is a FILE,
|
|
62
|
+
* so nothing in this set can expose it; the hidden-file rule is untouched.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ THE BAR FOR ADDING ONE: it must be a directory whose contents a stranger
|
|
65
|
+
* would expect to appear in a code search, and which no common tool uses to
|
|
66
|
+
* store secrets. `.aws`, `.ssh`, `.docker` and `.gnupg` fail that test and must
|
|
67
|
+
* never appear here.
|
|
68
|
+
*/
|
|
69
|
+
const HIDDEN_DIRS_ALLOWED = new Set(['.github', '.vscode', '.husky', '.circleci', '.changeset', '.storybook']);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* ── ⭐⭐ THE AGENT COULD NOT FIND ITS OWN WORK ────────────────────────────────
|
|
73
|
+
*
|
|
74
|
+
* `.acuvo/` is where THIS package writes the plan, the board, the checkpoint
|
|
75
|
+
* journal, the policy, the acceptance record and the audit log. All of it is
|
|
76
|
+
* hidden, so all of it was invisible to `find_files` and `search_text` — an
|
|
77
|
+
* agent resuming a job could not answer "what did I already do?" about files it
|
|
78
|
+
* had written itself, and got the `matches: [] / skippedCount: 0` triple this
|
|
79
|
+
* module documents as meaning NOT THERE.
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ IT IS DELIBERATELY NOT IN `HIDDEN_DIRS_ALLOWED` ABOVE. That set's stated
|
|
82
|
+
* bar is "no common tool uses it to store secrets", and `.acuvo` FAILS it —
|
|
83
|
+
* `credentials.json` lives in a directory of exactly this name. Folding it in
|
|
84
|
+
* would quietly widen a set whose comment promises the opposite.
|
|
85
|
+
*
|
|
86
|
+
* ⭐ WHAT KEEPS IT SAFE IS NOT THIS LINE. `searchText` already refuses the
|
|
87
|
+
* CONTENTS of anything `refusedCommitPath` names, and that pattern list matches
|
|
88
|
+
* `credentials.json` wherever it sits. The containment is at the FILE level,
|
|
89
|
+
* which is the level that survives someone running the agent with the workspace
|
|
90
|
+
* set to their home directory. The test that matters asserts the token never
|
|
91
|
+
* comes back, not that this constant has a particular value.
|
|
92
|
+
*/
|
|
93
|
+
const ACUVO_DIR = '.acuvo';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* ⚠️ SESSIONS ARE THE AGENT'S OWN TRANSCRIPT, AND SEARCHING THEM IS AN ECHO.
|
|
97
|
+
* A session file is every word the model wrote, so a search for a symbol finds
|
|
98
|
+
* the agent's own earlier SPECULATION about that symbol alongside the source —
|
|
99
|
+
* indistinguishable in a result list, and the older guess reads as evidence.
|
|
100
|
+
* One session measured here is 43KB of prose. `acuvo --sessions` lists them
|
|
101
|
+
* properly; this walk stays out.
|
|
102
|
+
*/
|
|
103
|
+
const ACUVO_SUBDIRS_SKIPPED = new Set(['sessions']);
|
|
104
|
+
|
|
105
|
+
/** Files we will never read as text. Reading them yields replacement chars. */
|
|
106
|
+
const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|mp4|mov|mp3|wav|woff2?|ttf|eot|so|dll|exe|node)$/i;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Bounds. Every one of these exists because the failure it prevents is silent:
|
|
110
|
+
* a search that walks forever looks like a hung terminal, and one that returns
|
|
111
|
+
* 4,000 matches costs more in context than the answer is worth.
|
|
112
|
+
*/
|
|
113
|
+
export const MAX_FILES_SCANNED = 4_000;
|
|
114
|
+
export const MAX_MATCHES = 60;
|
|
115
|
+
export const MAX_FILE_BYTES = 512 * 1024;
|
|
116
|
+
export const MAX_LINE_CHARS = 200;
|
|
117
|
+
/**
|
|
118
|
+
* ── ⭐ HOW FAR PAST THE CURRENT PAGE WE KEEP COUNTING ───────────────────────
|
|
119
|
+
*
|
|
120
|
+
* `total` is the field that turns "truncated" from a dead end into a decision:
|
|
121
|
+
* 61 hits and 6,000 hits are the same word today, and they call for opposite
|
|
122
|
+
* next moves. Counting is far cheaper than RETURNING — a counted match costs an
|
|
123
|
+
* integer, a returned one costs a line of context — so we keep counting after
|
|
124
|
+
* the page is full.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ BUT NOT FOREVER. `searchText` has to READ a file to count matches in it,
|
|
127
|
+
* and the old code's early break is what made a search for a common token
|
|
128
|
+
* finish after three files. So counting stops this far past the requested page,
|
|
129
|
+
* and when it does the reply says `countCapped: true` and `totalExact: false`
|
|
130
|
+
* rather than passing a floor off as a census.
|
|
131
|
+
*/
|
|
132
|
+
export const MAX_COUNTED = 1_000;
|
|
133
|
+
/**
|
|
134
|
+
* How many skipped paths are NAMED in a reply. The count is always exact; this
|
|
135
|
+
* caps only the list, because a repo with 400 images must not spend the model's
|
|
136
|
+
* whole context enumerating them.
|
|
137
|
+
*/
|
|
138
|
+
export const MAX_SKIPPED_LISTED = 20;
|
|
139
|
+
|
|
140
|
+
/** Depth-first walk, honouring the skip list and the file cap. */
|
|
141
|
+
function* walk(root, dir, budget, inAcuvo = false) {
|
|
142
|
+
let entries;
|
|
143
|
+
try {
|
|
144
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
145
|
+
} catch {
|
|
146
|
+
return; // an unreadable directory is skipped, never fatal
|
|
147
|
+
}
|
|
148
|
+
// Sorted so two runs over an unchanged tree return the same order — a search
|
|
149
|
+
// whose results shuffle makes a diff between runs unreadable.
|
|
150
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
151
|
+
for (const e of entries) {
|
|
152
|
+
if (budget.scanned >= MAX_FILES_SCANNED) return;
|
|
153
|
+
if (e.name.startsWith('.') && e.name !== '.env.example') {
|
|
154
|
+
/**
|
|
155
|
+
* ── ⚠️ HIDDEN ENTRIES: REFUSE THE CREDENTIALS, NOT THE SOURCE ──────────
|
|
156
|
+
*
|
|
157
|
+
* The original rule skipped EVERY dotted entry and recorded nothing. Its
|
|
158
|
+
* reason is right and stays: a search that surfaces `.env` into a model's
|
|
159
|
+
* context is an exfiltration channel with good intentions, and hidden
|
|
160
|
+
* FILES are overwhelmingly config and credentials.
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ BUT IT ALSO MADE ORDINARY SOURCE UNREACHABLE AND SAID NOTHING.
|
|
163
|
+
* `.github/workflows`, `.vscode`, `.husky` and `.circleci` are code people
|
|
164
|
+
* ask an agent to change constantly — "fix the CI workflow" is a top task
|
|
165
|
+
* — and the reply was the exact `matches: [] / truncated: false /
|
|
166
|
+
* skippedCount: 0` triple this module documents as meaning "not there".
|
|
167
|
+
* Being unable to see a file is survivable. Reporting it as ABSENT is not,
|
|
168
|
+
* because the model stops looking and then invents.
|
|
169
|
+
*
|
|
170
|
+
* ⭐ So: walk a small allowlist of hidden directories that are plainly
|
|
171
|
+
* source, skip everything else hidden as before, and RECORD every skip so
|
|
172
|
+
* the caller can name it. Hidden FILES are still never walked — the
|
|
173
|
+
* allowlist is directories only, so `.env` is untouched by this.
|
|
174
|
+
*/
|
|
175
|
+
if (e.isDirectory() && HIDDEN_DIRS_ALLOWED.has(e.name)) {
|
|
176
|
+
yield* walk(root, join(dir, e.name), budget);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (e.isDirectory() && e.name === ACUVO_DIR) {
|
|
180
|
+
yield* walk(root, join(dir, e.name), budget, true);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (budget.hiddenSkipped && budget.hiddenSkipped.length < MAX_SKIPPED_LISTED) {
|
|
184
|
+
budget.hiddenSkipped.push({
|
|
185
|
+
path: relative(root, join(dir, e.name)).split(sep).join('/'),
|
|
186
|
+
reason: e.isDirectory() ? 'hidden directory, not searched' : 'hidden file, not read',
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
budget.hiddenSkippedCount = (budget.hiddenSkippedCount ?? 0) + 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (e.isDirectory()) {
|
|
193
|
+
if (inAcuvo && ACUVO_SUBDIRS_SKIPPED.has(e.name)) {
|
|
194
|
+
if (budget.hiddenSkipped && budget.hiddenSkipped.length < MAX_SKIPPED_LISTED) {
|
|
195
|
+
budget.hiddenSkipped.push({
|
|
196
|
+
path: relative(root, join(dir, e.name)).split(sep).join('/'),
|
|
197
|
+
reason: 'the agent\'s own session transcripts — list them with `acuvo --sessions`',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
budget.hiddenSkippedCount = (budget.hiddenSkippedCount ?? 0) + 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (SKIP_DIRS.has(e.name)) {
|
|
204
|
+
if (budget.hiddenSkipped && budget.hiddenSkipped.length < MAX_SKIPPED_LISTED) {
|
|
205
|
+
budget.hiddenSkipped.push({
|
|
206
|
+
path: relative(root, join(dir, e.name)).split(sep).join('/'),
|
|
207
|
+
reason: 'skipped directory (build output or dependencies)',
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
budget.hiddenSkippedCount = (budget.hiddenSkippedCount ?? 0) + 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
yield* walk(root, join(dir, e.name), budget);
|
|
214
|
+
} else if (e.isFile()) {
|
|
215
|
+
budget.scanned += 1;
|
|
216
|
+
yield join(dir, e.name);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Placeholders used while expanding a glob. They stand in for an expansion that
|
|
223
|
+
* itself contains glob characters, so the later passes cannot chew up the regex
|
|
224
|
+
* an earlier pass just wrote. Control characters because a path may not contain
|
|
225
|
+
* one, so no real pattern can collide with them.
|
|
226
|
+
*/
|
|
227
|
+
const TOK_STARSTAR = '\x00'; // `**` not followed by a slash
|
|
228
|
+
const TOK_GLOBSTAR_SEG = '\x01'; // `**/` — a whole directory span, possibly empty
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A tiny glob: `*` (not across `/`), `**` (across `/`), `?`. Anchored whole.
|
|
232
|
+
*
|
|
233
|
+
* ⚠️ BUILT RATHER THAN REGEX-FROM-USER-INPUT. Handing a model's string straight
|
|
234
|
+
* to `new RegExp` is a catastrophic-backtracking DoS in one call — `(a+)+$` on a
|
|
235
|
+
* long path hangs the process. Escaping everything and expanding only the three
|
|
236
|
+
* glob tokens means the pattern can only ever describe a path shape.
|
|
237
|
+
*
|
|
238
|
+
* ── ⚠️ `**` + `/` MATCHES ZERO DIRECTORIES, AND USED NOT TO ────────────────
|
|
239
|
+
* `**` became `.*` and the `/` after it stayed a literal, so the pattern
|
|
240
|
+
* `**` `/` `*.json` compiled to `^.*\/[^/]*\.json$` — with a MANDATORY slash.
|
|
241
|
+
* Effect: it found `src/a.json` and missed `package.json`, which means the
|
|
242
|
+
* BROADER pattern returned strictly FEWER files than the narrower `*.json`.
|
|
243
|
+
* That is the opposite of what a glob promises, and it fails silently: the
|
|
244
|
+
* model asks for "every json in the repo", gets a list with the root one
|
|
245
|
+
* absent, and concludes the file does not exist. Bash globstar, minimatch and
|
|
246
|
+
* ripgrep all read a `**` segment as ZERO or more directories, so the honest
|
|
247
|
+
* expansion is `(?:.*\/)?` and never `.*\/`.
|
|
248
|
+
*
|
|
249
|
+
* ⚠️ It has to go behind its own placeholder because the replacement contains
|
|
250
|
+
* both `?` and `*`; written any earlier, the two token passes below would
|
|
251
|
+
* rewrite it into nonsense.
|
|
252
|
+
*/
|
|
253
|
+
export function globToRegExp(pattern) {
|
|
254
|
+
const escaped = String(pattern)
|
|
255
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
256
|
+
.replace(/\*\*\//g, TOK_GLOBSTAR_SEG) // a directory span, zero or more deep
|
|
257
|
+
.replace(/\*\*/g, TOK_STARSTAR) // placeholder so * does not eat it
|
|
258
|
+
.replace(/\*/g, '[^/]*')
|
|
259
|
+
.replace(/\?/g, '[^/]')
|
|
260
|
+
.replace(new RegExp(TOK_STARSTAR, 'g'), '.*')
|
|
261
|
+
.replace(new RegExp(TOK_GLOBSTAR_SEG, 'g'), '(?:.*/)?');
|
|
262
|
+
return new RegExp(`^${escaped}$`, 'i');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* ── ⚠️ AN OFFSET IS EITHER A WHOLE COUNT OR A MISTAKE ───────────────────────
|
|
267
|
+
*
|
|
268
|
+
* Coercing `-1` or `"later"` to 0 would hand back page one while the model
|
|
269
|
+
* believes it is reading page two — a silent wrong answer of exactly the class
|
|
270
|
+
* this module exists to stop, and one the model cannot detect. A refusal it can
|
|
271
|
+
* read is strictly better.
|
|
272
|
+
*
|
|
273
|
+
* ⚠️ The shapes a real tool-call carries are still accepted: absent, `0`, and a
|
|
274
|
+
* NUMERIC STRING, because JSON emitted by a model routinely types numbers as
|
|
275
|
+
* strings and refusing that would fail correct work.
|
|
276
|
+
*/
|
|
277
|
+
function normaliseOffset(offset, tool) {
|
|
278
|
+
if (offset === undefined || offset === null || offset === '') return { ok: true, value: 0 };
|
|
279
|
+
const n = typeof offset === 'number' ? offset : Number(String(offset).trim());
|
|
280
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
error: `${tool}: offset must be a whole number ≥ 0 — the number of results already seen, e.g. ${MAX_MATCHES} for the second page. Got ${JSON.stringify(offset)}.`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return { ok: true, value: n };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Locate files by name. Returns repo-relative POSIX paths.
|
|
291
|
+
*
|
|
292
|
+
* ⭐ `offset` is how many matches to SKIP, so a model told `truncated: true` has
|
|
293
|
+
* somewhere to go. The walk is not repeated to find page two — it never was
|
|
294
|
+
* capable of stopping early in a way that mattered here, because matching a
|
|
295
|
+
* NAME costs a regex test, not a file read. So the whole tree is matched once,
|
|
296
|
+
* every hit is counted into `total`, and only the requested window is recorded.
|
|
297
|
+
*/
|
|
298
|
+
export function findFiles(root, pattern, { offset = 0 } = {}) {
|
|
299
|
+
if (!pattern || typeof pattern !== 'string') return { ok: false, error: 'a glob pattern is required' };
|
|
300
|
+
const off = normaliseOffset(offset, 'find_files');
|
|
301
|
+
if (!off.ok) return off;
|
|
302
|
+
const start = off.value;
|
|
303
|
+
let rx;
|
|
304
|
+
try {
|
|
305
|
+
rx = globToRegExp(pattern);
|
|
306
|
+
} catch {
|
|
307
|
+
return { ok: false, error: `not a usable pattern: ${pattern}` };
|
|
308
|
+
}
|
|
309
|
+
const budget = { scanned: 0, hiddenSkipped: [], hiddenSkippedCount: 0 };
|
|
310
|
+
const hits = [];
|
|
311
|
+
let total = 0;
|
|
312
|
+
for (const abs of walk(root, root, budget)) {
|
|
313
|
+
const rel = relative(root, abs).split(sep).join('/');
|
|
314
|
+
if (!rx.test(rel) && !rx.test(rel.split('/').pop())) continue;
|
|
315
|
+
total += 1;
|
|
316
|
+
// Counted always; recorded only inside the requested window. The old code
|
|
317
|
+
// broke out of the walk here, which is why it could say "there are more"
|
|
318
|
+
// and never say how many, nor let anyone ask for them.
|
|
319
|
+
if (total > start && hits.length < MAX_MATCHES) hits.push(rel);
|
|
320
|
+
}
|
|
321
|
+
const scanCapped = budget.scanned >= MAX_FILES_SCANNED;
|
|
322
|
+
const unseen = start + hits.length < total;
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
pattern,
|
|
326
|
+
files: hits,
|
|
327
|
+
/** How many matches were skipped to build this page. Echoed so a caller that
|
|
328
|
+
* asked for page two and got page one can SEE that its offset was dropped. */
|
|
329
|
+
offset: start,
|
|
330
|
+
/** Every match the walk found, not just the ones returned. */
|
|
331
|
+
total,
|
|
332
|
+
/**
|
|
333
|
+
* ⚠️ `total` IS A FLOOR, NOT A CENSUS, WHEN THE WALK WAS CAPPED. Saying
|
|
334
|
+
* "47 files" about a tree we only walked a third of is the same lie as
|
|
335
|
+
* `truncated: false`, wearing a number.
|
|
336
|
+
*/
|
|
337
|
+
totalExact: !scanCapped,
|
|
338
|
+
/** Where to resume. `null` means there is nothing after this page. */
|
|
339
|
+
nextOffset: unseen ? start + hits.length : null,
|
|
340
|
+
/**
|
|
341
|
+
* ⚠️ TRUNCATION IS REPORTED, NEVER SILENT. "12 files" and "the first 60 of
|
|
342
|
+
* many" are different answers, and a model told the first will stop looking.
|
|
343
|
+
*
|
|
344
|
+
* ⚠️⚠️ THIS COMMENT WAS TRUE AND THE CODE UNDER IT WAS NOT. It read
|
|
345
|
+
* `hits.length >= MAX_MATCHES` — the MATCH cap only — while `walk()` also
|
|
346
|
+
* stops dead at MAX_FILES_SCANNED. A walk cut short after 4,000 of 12,000
|
|
347
|
+
* files, finding 3 matches, answered `truncated: false`: a claim of
|
|
348
|
+
* completeness over a third of the tree, printed directly beneath a comment
|
|
349
|
+
* promising the opposite.
|
|
350
|
+
*
|
|
351
|
+
* ⭐ `searchText` HAD ALREADY FIXED EXACTLY THIS (see its `scanCapped`, and
|
|
352
|
+
* the note above it recording the same bug). Its sibling never got the fix.
|
|
353
|
+
* Both causes mean "there may be more", so both set the flag and
|
|
354
|
+
* `scanCapped` says which.
|
|
355
|
+
*
|
|
356
|
+
* ⭐ NOW EXACT RATHER THAN PESSIMISTIC. `hits.length >= MAX_MATCHES` was a
|
|
357
|
+
* proxy for "there are probably more"; with `total` counted we know. A full
|
|
358
|
+
* page that happens to be the last page honestly says `truncated: false`.
|
|
359
|
+
*/
|
|
360
|
+
truncated: unseen || scanCapped,
|
|
361
|
+
scanned: budget.scanned,
|
|
362
|
+
scanCapped,
|
|
363
|
+
// Places the walk declined to enter, so "not found" is never confused with
|
|
364
|
+
// "not looked at". The count is exact even when the list is capped.
|
|
365
|
+
skipped: budget.hiddenSkipped,
|
|
366
|
+
skippedCount: budget.hiddenSkippedCount,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Search file CONTENT.
|
|
372
|
+
*
|
|
373
|
+
* ⚠️ The pattern is a regex because that is what makes search useful, so it is
|
|
374
|
+
* compiled inside a try and every match is bounded — see MAX_* above. A literal
|
|
375
|
+
* search is available by escaping, which the description tells the model.
|
|
376
|
+
*
|
|
377
|
+
* Returns, on success:
|
|
378
|
+
* matches the hits, capped at MAX_MATCHES
|
|
379
|
+
* truncated true when there may be more — capped matches OR a capped walk
|
|
380
|
+
* scanned files the walk reached
|
|
381
|
+
* skipped up to MAX_SKIPPED_LISTED `{ path, reason }` of files NOT read
|
|
382
|
+
* skippedCount the exact number not read, even when the list above is capped
|
|
383
|
+
* withheld how many of those were withheld as credential files
|
|
384
|
+
*/
|
|
385
|
+
export function searchText(root, pattern, { glob = null, offset = 0 } = {}) {
|
|
386
|
+
if (!pattern || typeof pattern !== 'string') return { ok: false, error: 'a search pattern is required' };
|
|
387
|
+
const off = normaliseOffset(offset, 'search_text');
|
|
388
|
+
if (!off.ok) return off;
|
|
389
|
+
const start = off.value;
|
|
390
|
+
let rx;
|
|
391
|
+
try {
|
|
392
|
+
rx = new RegExp(pattern, 'i');
|
|
393
|
+
} catch (err) {
|
|
394
|
+
return { ok: false, error: `not a valid regular expression: ${String(err?.message || err)}` };
|
|
395
|
+
}
|
|
396
|
+
const nameFilter = glob ? globToRegExp(glob) : null;
|
|
397
|
+
/**
|
|
398
|
+
* ⭐ The walk records the directories it declined to ENTER, which the
|
|
399
|
+
* per-file `note()` below could never see — it only ever hears about files
|
|
400
|
+
* the walk actually yielded. A directory skipped at the walk level was
|
|
401
|
+
* previously invisible to both, which is how "not searched" came back as
|
|
402
|
+
* "not there".
|
|
403
|
+
*/
|
|
404
|
+
const budget = { scanned: 0, hiddenSkipped: [], hiddenSkippedCount: 0 };
|
|
405
|
+
const matches = [];
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* ⚠️ EVERY `continue` IN THE LOOP BELOW GOES THROUGH HERE. That is the whole
|
|
409
|
+
* fix: the old code had four bare `continue`s, and four bare `continue`s in a
|
|
410
|
+
* function that reports `matches: []` is a function that says "not there"
|
|
411
|
+
* when it means "not opened". If you add a fifth reason to skip a file, it
|
|
412
|
+
* gets a `note()` too — a skip nobody can see is the bug returning.
|
|
413
|
+
*/
|
|
414
|
+
const skipped = [];
|
|
415
|
+
let skippedCount = 0;
|
|
416
|
+
let withheld = 0;
|
|
417
|
+
const note = (path, reason) => {
|
|
418
|
+
skippedCount += 1;
|
|
419
|
+
if (skipped.length < MAX_SKIPPED_LISTED) skipped.push({ path, reason });
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* How far past the requested page we keep counting before admitting the count
|
|
424
|
+
* is a floor. See MAX_COUNTED — counting costs a file read here, which is why
|
|
425
|
+
* this is bounded at all.
|
|
426
|
+
*/
|
|
427
|
+
const countLimit = start + MAX_MATCHES + MAX_COUNTED;
|
|
428
|
+
let total = 0;
|
|
429
|
+
let countCapped = false;
|
|
430
|
+
|
|
431
|
+
scan:
|
|
432
|
+
for (const abs of walk(root, root, budget)) {
|
|
433
|
+
const rel = relative(root, abs).split(sep).join('/');
|
|
434
|
+
// A caller's own `glob` narrowing is not a skip — they asked for it, and
|
|
435
|
+
// reporting it back as "not looked at" would bury the real skips in noise.
|
|
436
|
+
if (nameFilter && !nameFilter.test(rel) && !nameFilter.test(rel.split('/').pop())) continue;
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* ── ⚠️⚠️ THE SECOND DOOR INTO THE WORST BUG THIS PACKAGE HAS HAD ────────
|
|
440
|
+
*
|
|
441
|
+
* `turn.mjs:112` documents the first: the deterministic pre-load read every
|
|
442
|
+
* small file in the tree into the prompt, `.env` and `id_rsa` included, and
|
|
443
|
+
* the provider chain then fanned them across up to four upstreams. It was
|
|
444
|
+
* fixed there, and the fix stopped exactly one caller.
|
|
445
|
+
*
|
|
446
|
+
* This tool is the other way in, and a worse one, because the pre-load only
|
|
447
|
+
* reads what happens to be lying around while this reads what the model
|
|
448
|
+
* ASKS for — in round 1, before any plan is reviewed, through the same
|
|
449
|
+
* prompt and the same chain. Verified on a fixture 2026-08-10: searching
|
|
450
|
+
* `CANARY` returned `password: …` from `config/credentials.yml`, the body
|
|
451
|
+
* of `id_rsa`, and `{"aws_secret":"…"}` from `secrets.json`.
|
|
452
|
+
*
|
|
453
|
+
* ⭐ The hidden-file rule in `walk` was never cover for this. It stops
|
|
454
|
+
* `.env`, and stops nothing else: `id_rsa`, `secrets.json`, `server.key`
|
|
455
|
+
* and `config/credentials.yml` are all ordinary visible files.
|
|
456
|
+
*
|
|
457
|
+
* ⭐ WITHHELD, NOT DROPPED. Silently omitting them would be the very lie
|
|
458
|
+
* this function was just fixed for — the model would search for a config
|
|
459
|
+
* key, find nothing, and go write a duplicate. It is told the file exists
|
|
460
|
+
* and that the contents are not coming.
|
|
461
|
+
*/
|
|
462
|
+
if (refusedCommitPath(rel)) {
|
|
463
|
+
withheld += 1;
|
|
464
|
+
note(rel, 'credential file — the path is shown, the contents are never returned');
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (BINARY_EXT.test(rel)) { note(rel, 'binary file extension — not read as text'); continue; }
|
|
469
|
+
|
|
470
|
+
let stat;
|
|
471
|
+
try { stat = statSync(abs); } catch { note(rel, 'could not be stat-ed — it may have been deleted mid-walk'); continue; }
|
|
472
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
473
|
+
note(rel, `over ${MAX_FILE_BYTES / 1024}KB — read it directly with read_file if you need this one`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let text;
|
|
478
|
+
try { text = readFileSync(abs, 'utf8'); } catch { note(rel, 'could not be read — check the file permissions'); continue; }
|
|
479
|
+
// A NUL in the first block means binary regardless of extension — the same
|
|
480
|
+
// heuristic readFile uses, so search and read agree about what is text.
|
|
481
|
+
// ⚠️ UTF-16 source lands here too, so this is not only "real" binaries.
|
|
482
|
+
if (text.includes('\x00')) { note(rel, 'binary (contains a NUL byte) — UTF-16 text looks like this too'); continue; }
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* ── ⚠️ A UTF-8 BOM DEFEATS A `^` ANCHOR, SILENTLY ──────────────────────
|
|
486
|
+
* `readFileSync(..., 'utf8')` hands back the BOM as a real U+FEFF at index
|
|
487
|
+
* 0, so line 1 of a BOM file is `"import …"` and `/^import/` does not
|
|
488
|
+
* match it. The reply is `matches: []` — the exact triple this module
|
|
489
|
+
* documents as meaning "it is not there" — for a line that plainly is.
|
|
490
|
+
* The BOM is an encoding marker, not content, so it comes off before
|
|
491
|
+
* anything looks at the text, which also keeps it out of the returned line.
|
|
492
|
+
*/
|
|
493
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
|
|
494
|
+
|
|
495
|
+
const lines = text.split('\n');
|
|
496
|
+
for (let i = 0; i < lines.length; i++) {
|
|
497
|
+
const raw = lines[i];
|
|
498
|
+
/**
|
|
499
|
+
* ── ⚠️ CRLF: THE OTHER HALF OF A REAL WINDOWS TREE ────────────────────
|
|
500
|
+
* Splitting on `\n` leaves the `\r` sitting on the end of every line, so
|
|
501
|
+
* `$` can never match anything but the carriage return, and a
|
|
502
|
+
* `$`-anchored search over a CRLF file returns NOTHING. Same silent
|
|
503
|
+
* "it is not there". `\r` is half of the line TERMINATOR, not content.
|
|
504
|
+
*/
|
|
505
|
+
const line = raw.charCodeAt(raw.length - 1) === 13 ? raw.slice(0, -1) : raw;
|
|
506
|
+
if (!rx.test(line)) continue;
|
|
507
|
+
|
|
508
|
+
total += 1;
|
|
509
|
+
if (total > start && matches.length < MAX_MATCHES) {
|
|
510
|
+
/**
|
|
511
|
+
* ── ⭐⭐ THE ONE-LINE FIX THIS FUNCTION EXISTED TO GET WRONG ────────
|
|
512
|
+
*
|
|
513
|
+
* This used to be `lines[i].trim()`, and `trim()` takes the LEADING
|
|
514
|
+
* whitespace off. Search feeds edit: the model builds an `edit_file`
|
|
515
|
+
* old_string out of this exact string, and a left-aligned copy of an
|
|
516
|
+
* indented line CANNOT MATCH the file. It is refused, guesses the
|
|
517
|
+
* indentation, is refused again, and the round budget is gone —
|
|
518
|
+
* `read-window.mjs:34` records the session that died this way, six
|
|
519
|
+
* spaces guessed where `lib/git.mjs:282` has two.
|
|
520
|
+
*
|
|
521
|
+
* ⭐ TRAILING whitespace only. That direction is safe in the way the
|
|
522
|
+
* other is not: the result stays a byte-exact SUBSTRING of the file,
|
|
523
|
+
* which is the only property `edit_file` actually tests.
|
|
524
|
+
*/
|
|
525
|
+
const kept = line.trimEnd();
|
|
526
|
+
const clipped = kept.length > MAX_LINE_CHARS;
|
|
527
|
+
const m = { path: rel, line: i + 1, text: kept.slice(0, MAX_LINE_CHARS) };
|
|
528
|
+
/**
|
|
529
|
+
* ⚠️ SAID OUT LOUD when it happens. A clipped line is a PREFIX, so an
|
|
530
|
+
* old_string built from it is refused — and being told why beats
|
|
531
|
+
* guessing. An 8,000-character minified line would otherwise swamp the
|
|
532
|
+
* whole reply with one useless match, so the cap stays.
|
|
533
|
+
*/
|
|
534
|
+
if (clipped) m.clipped = true;
|
|
535
|
+
matches.push(m);
|
|
536
|
+
}
|
|
537
|
+
if (total >= countLimit) { countCapped = true; break scan; }
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* ⚠️ THE WALK CAP IS A TRUNCATION TOO. `walk` stops dead at
|
|
543
|
+
* MAX_FILES_SCANNED, and the old code still answered `truncated: false` — the
|
|
544
|
+
* same claim of completeness, made by a different mechanism. Both causes mean
|
|
545
|
+
* "there may be more", so both set the flag, and `scanCapped` says which.
|
|
546
|
+
*/
|
|
547
|
+
const scanCapped = budget.scanned >= MAX_FILES_SCANNED;
|
|
548
|
+
const unseen = start + matches.length < total;
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
ok: true,
|
|
552
|
+
pattern,
|
|
553
|
+
/**
|
|
554
|
+
* ⭐ RANKED, NOT WALK-ORDERED. Matches were pushed in raw filesystem
|
|
555
|
+
* traversal order and capped at MAX_MATCHES, so a model hunting a symbol
|
|
556
|
+
* read whatever the directory walk reached first and the DEFINITION being
|
|
557
|
+
* near the top was luck. `rankMatches` puts definitions and exports above
|
|
558
|
+
* mentions.
|
|
559
|
+
*
|
|
560
|
+
* ⚠️ IT REORDERS THE PAGE AND CHANGES NOTHING ELSE. `total`, `unseen` and
|
|
561
|
+
* `nextOffset` are computed above from the unranked window and are
|
|
562
|
+
* untouched — re-ranking ACROSS pages would require collecting every match
|
|
563
|
+
* before returning any, which breaks the bounded-scan contract that keeps a
|
|
564
|
+
* search on a large repo from reading the whole thing.
|
|
565
|
+
*/
|
|
566
|
+
matches: rankMatches(matches, pattern),
|
|
567
|
+
/** Matches skipped to build this page — echoed so a dropped offset is visible. */
|
|
568
|
+
offset: start,
|
|
569
|
+
/** Every match found, not just the ones returned. 61 and 6,000 are different answers. */
|
|
570
|
+
total,
|
|
571
|
+
/** ⚠️ `total` is a FLOOR when the walk or the count was cut short. */
|
|
572
|
+
totalExact: !scanCapped && !countCapped,
|
|
573
|
+
/** Where to resume; `null` when nothing follows this page. */
|
|
574
|
+
nextOffset: unseen || countCapped ? start + matches.length : null,
|
|
575
|
+
countCapped,
|
|
576
|
+
truncated: unseen || countCapped || scanCapped,
|
|
577
|
+
scanned: budget.scanned,
|
|
578
|
+
scanCapped,
|
|
579
|
+
/**
|
|
580
|
+
* ⭐ Two sources, one answer. `skipped` holds files the walk YIELDED and
|
|
581
|
+
* this function then declined to read; `budget.hiddenSkipped` holds
|
|
582
|
+
* directories and hidden files the walk never entered at all. Reporting only
|
|
583
|
+
* the first is what let a whole `.github/` tree vanish behind
|
|
584
|
+
* `skippedCount: 0`. The list is capped for context; the count is exact.
|
|
585
|
+
*/
|
|
586
|
+
skipped: [...skipped, ...budget.hiddenSkipped].slice(0, MAX_SKIPPED_LISTED),
|
|
587
|
+
skippedCount: skippedCount + budget.hiddenSkippedCount,
|
|
588
|
+
withheld,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function searchToolSchemas() {
|
|
593
|
+
return [
|
|
594
|
+
{
|
|
595
|
+
type: 'function',
|
|
596
|
+
function: {
|
|
597
|
+
name: 'find_files',
|
|
598
|
+
description: [
|
|
599
|
+
'Find files by NAME anywhere in the workspace, using a glob.',
|
|
600
|
+
'Examples: "*.test.ts", "src/**/*.tsx", "package.json".',
|
|
601
|
+
'A `**/` segment matches zero or more directories, so "**/*.json" includes a top-level package.json.',
|
|
602
|
+
'Use this before assuming where something lives — never guess a path.',
|
|
603
|
+
'node_modules, .git, dist and build are never searched.',
|
|
604
|
+
'Your own `.acuvo/` IS searched — the plan, board, checkpoints, policy and audit log are all findable — except `.acuvo/sessions`, which you list with `acuvo --sessions` instead.',
|
|
605
|
+
// ⚠️ A cap the model cannot step past is a dead end, and a cap with no
|
|
606
|
+
// size attached makes 61 files and 6,000 files read identically.
|
|
607
|
+
`Results are capped at ${MAX_MATCHES} per call: \`total\` is how many matched in all, and \`offset\` skips that many to read the next page (use the \`nextOffset\` the reply gives you). \`totalExact: false\` means even \`total\` is a floor.`,
|
|
608
|
+
].join(' '),
|
|
609
|
+
parameters: {
|
|
610
|
+
type: 'object',
|
|
611
|
+
properties: {
|
|
612
|
+
pattern: { type: 'string', description: 'A glob, e.g. "src/**/*.ts".' },
|
|
613
|
+
offset: {
|
|
614
|
+
type: 'integer',
|
|
615
|
+
minimum: 0,
|
|
616
|
+
description: `How many matches to skip. Defaults to 0. Pass ${MAX_MATCHES} for the second page, or just use the reply's nextOffset.`,
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
required: ['pattern'],
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
type: 'function',
|
|
625
|
+
function: {
|
|
626
|
+
name: 'search_text',
|
|
627
|
+
description: [
|
|
628
|
+
'Search file CONTENTS with a regular expression and get back path, line number and the line.',
|
|
629
|
+
'This is how you find where a function is defined, who calls it, or where a string comes from.',
|
|
630
|
+
'Optionally narrow by filename with `glob`. Escape regex characters for a literal search.',
|
|
631
|
+
// ⭐ THE HANDOFF, STATED. The model has to know the string is safe to
|
|
632
|
+
// paste into an edit, or it will "helpfully" re-indent it and fail.
|
|
633
|
+
'Each hit\'s `text` is the line VERBATIM — leading indentation and all, byte-exact except for trailing whitespace — so you can copy it straight into an edit_file old_string. (`clipped: true` means the line was longer than the display cap and the text is only a prefix; read_around it instead.)',
|
|
634
|
+
`Results are capped at ${MAX_MATCHES} per call: \`total\` is how many lines matched in all, and \`offset\` skips that many to read the next page (use the \`nextOffset\` the reply gives you). \`totalExact: false\` means even \`total\` is a floor.`,
|
|
635
|
+
// ⚠️ SAID OUT LOUD, because a field the model is not told about is a
|
|
636
|
+
// field it does not read, and then the honest reply is as misleading
|
|
637
|
+
// as the dishonest one was.
|
|
638
|
+
'An empty `matches` means NOT FOUND only if `skippedCount` is 0: `skipped` lists files that could not be read (too large, binary) and `withheld` counts credential files whose contents are never returned. If what you want may be in one of those, open it with read_file instead.',
|
|
639
|
+
].join(' '),
|
|
640
|
+
parameters: {
|
|
641
|
+
type: 'object',
|
|
642
|
+
properties: {
|
|
643
|
+
pattern: {
|
|
644
|
+
type: 'string',
|
|
645
|
+
description: 'A JavaScript regular expression, case-insensitive. `^` and `$` anchor to the line, and work on CRLF files and files with a UTF-8 BOM.',
|
|
646
|
+
},
|
|
647
|
+
glob: { type: 'string', description: 'Optional filename filter, e.g. "*.ts".' },
|
|
648
|
+
offset: {
|
|
649
|
+
type: 'integer',
|
|
650
|
+
minimum: 0,
|
|
651
|
+
description: `How many matches to skip. Defaults to 0. Pass ${MAX_MATCHES} for the second page, or just use the reply's nextOffset.`,
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
required: ['pattern'],
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
];
|
|
659
|
+
}
|