@esneiderbravo/speclaw 0.3.13 → 1.0.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/README.md +88 -72
- package/dist/cli/commands/index-build.js +12 -3
- package/dist/cli/commands/lawbook.js +1 -0
- package/dist/cli/commands/laws.js +149 -8
- package/dist/cli/commands/owners.js +44 -0
- package/dist/cli/commands/query.js +52 -10
- package/dist/cli/commands/update.js +35 -5
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +15 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/diff-context.js +134 -0
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +134 -0
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -0
- package/dist/modules/compass/impact-summary.js +33 -0
- package/dist/modules/compass/indexer.js +204 -33
- package/dist/modules/compass/merkle.js +76 -0
- package/dist/modules/compass/pagerank.js +122 -0
- package/dist/modules/compass/rank.js +95 -0
- package/dist/modules/compass/register.js +169 -75
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- package/dist/modules/foundation/context-budget.js +1 -14
- package/dist/modules/foundation/dialects/agentsmd.js +95 -0
- package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
- package/dist/modules/foundation/dialects/coderabbit.js +27 -0
- package/dist/modules/foundation/dialects/copilot.js +35 -0
- package/dist/modules/foundation/dialects/index.js +5 -0
- package/dist/modules/foundation/dialects/types.js +58 -0
- package/dist/modules/foundation/doctor.js +266 -14
- package/dist/modules/foundation/import-rules.js +67 -0
- package/dist/modules/foundation/integrity.js +307 -0
- package/dist/modules/foundation/laws-parse.js +131 -0
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/foundation/lock.js +283 -0
- package/dist/modules/foundation/ownership.js +4 -0
- package/dist/modules/foundation/register-core.js +57 -88
- package/dist/modules/foundation/register.js +1 -21
- package/dist/modules/foundation/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/setup-tool.js +96 -0
- package/dist/modules/foundation/verify.js +9 -1
- package/dist/modules/lawbook/assets/commands/archive.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +1 -1
- package/dist/modules/lawbook/assets/commands/explore.md +1 -1
- package/dist/modules/lawbook/assets/commands/sync.md +2 -2
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
- package/dist/modules/lawbook/change-tool.js +90 -0
- package/dist/modules/lawbook/coverage.js +45 -6
- package/dist/modules/lawbook/ears.js +417 -0
- package/dist/modules/lawbook/engine.js +29 -0
- package/dist/modules/lawbook/register.js +96 -54
- package/dist/modules/lawbook/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/dist/modules/tools/register.js +4 -26
- package/dist/shared/deprecation.js +99 -0
- package/dist/shared/exposure.js +4 -19
- package/dist/shared/git.js +25 -0
- package/dist/shared/mcp.js +29 -3
- package/dist/shared/output-budget.js +68 -0
- package/dist/shared/tool-catalog.js +49 -0
- package/package.json +4 -3
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { explore,
|
|
1
|
+
import { explore, impact, trace } from "../../modules/compass/query.js";
|
|
2
|
+
import { hybridSearch } from "../../modules/compass/hybrid.js";
|
|
2
3
|
import { affectedTests } from "../../modules/compass/affected.js";
|
|
3
4
|
import { hotspots, coupling } from "../../modules/compass/hotspots.js";
|
|
5
|
+
import { diffContext, formatDiffContext } from "../../modules/compass/diff-context.js";
|
|
4
6
|
import { list } from "../lib/args.js";
|
|
5
7
|
import { ui } from "../lib/ui.js";
|
|
6
8
|
/**
|
|
@@ -15,6 +17,9 @@ export async function runQuery(cmd, flags) {
|
|
|
15
17
|
const cwd = process.cwd();
|
|
16
18
|
const args = flags._;
|
|
17
19
|
const asJson = Boolean(flags.json);
|
|
20
|
+
const focus = list(flags.focus);
|
|
21
|
+
const maxTokens = flags["max-tokens"] ? Number(flags["max-tokens"]) : undefined;
|
|
22
|
+
const explain = Boolean(flags.explain);
|
|
18
23
|
try {
|
|
19
24
|
switch (cmd) {
|
|
20
25
|
case "explore": {
|
|
@@ -33,16 +38,34 @@ export async function runQuery(cmd, flags) {
|
|
|
33
38
|
r.callers?.forEach((c) => ui.info(`${c.name} (${c.file}:${c.line})`));
|
|
34
39
|
return;
|
|
35
40
|
}
|
|
36
|
-
case "search":
|
|
37
|
-
const hits = search(cwd, need(args[0], "search <query>"));
|
|
38
|
-
ui.heading(`${hits.length} result(s)`);
|
|
39
|
-
hits.forEach((h) => ui.info(`${h.name} (${h.kind}) ${h.file}:${h.line}`));
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
41
|
+
case "search":
|
|
42
42
|
case "recall": {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
const q = need(args[0], cmd === "recall" ? 'recall "<query>"' : "search <query>");
|
|
44
|
+
const mode = cmd === "recall" ? "concept" : "exact";
|
|
45
|
+
const result = await hybridSearch(cwd, q, {
|
|
46
|
+
mode,
|
|
47
|
+
focus: focus.length ? focus : undefined,
|
|
48
|
+
maxTokens,
|
|
49
|
+
});
|
|
50
|
+
if (asJson) {
|
|
51
|
+
console.log(JSON.stringify(result, null, 2));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
ui.heading(`${result.hits.length} hybrid hit(s) · route=${result.route} · tokens=${result.tokens}/${result.budget}`);
|
|
55
|
+
if (result.degraded.length)
|
|
56
|
+
ui.warn(`degraded: ${result.degraded.join(", ")}`);
|
|
57
|
+
if (result.focus.length)
|
|
58
|
+
ui.info(`focus: ${result.focus.join(", ")}`);
|
|
59
|
+
for (const h of result.hits) {
|
|
60
|
+
const sig = explain
|
|
61
|
+
? ` [bm25=${h.signals.bm25Rank ?? "-"} knn=${h.signals.knnRank ?? "-"} name=${h.signals.nameRank ?? "-"} pr=${h.signals.pagerank.toFixed(4)} hops=${h.signals.hops} score=${h.signals.score.toFixed(4)}]`
|
|
62
|
+
: "";
|
|
63
|
+
ui.info(`${h.name} (${h.kind}) ${h.file}:${h.line}${sig}`);
|
|
64
|
+
}
|
|
65
|
+
if (explain && result.rendered) {
|
|
66
|
+
ui.heading("TreeContext");
|
|
67
|
+
console.log(result.rendered);
|
|
68
|
+
}
|
|
46
69
|
return;
|
|
47
70
|
}
|
|
48
71
|
case "impact": {
|
|
@@ -161,6 +184,25 @@ export async function runQuery(cmd, flags) {
|
|
|
161
184
|
result.warnings.forEach((w) => ui.warn(w));
|
|
162
185
|
return;
|
|
163
186
|
}
|
|
187
|
+
case "diff-context": {
|
|
188
|
+
const files = list(flags.file);
|
|
189
|
+
const rev = typeof flags.rev === "string" ? flags.rev : undefined;
|
|
190
|
+
if (files.length === 0 && !rev && !flags.worktree) {
|
|
191
|
+
need(undefined, "diff-context [--file <path>...] [--rev <ref>] [--worktree]");
|
|
192
|
+
}
|
|
193
|
+
const result = diffContext({
|
|
194
|
+
projectPath: cwd,
|
|
195
|
+
rev: flags.worktree ? "WORKTREE" : rev,
|
|
196
|
+
paths: files.length ? files : undefined,
|
|
197
|
+
mode: flags.full ? "full" : "brief",
|
|
198
|
+
});
|
|
199
|
+
if (asJson) {
|
|
200
|
+
console.log(JSON.stringify(result, null, 2));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
console.log(formatDiffContext(result));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
164
206
|
case "trace": {
|
|
165
207
|
const r = trace(cwd, need(args[0], "trace <from> <to>"), need(args[1], "trace <from> <to>"));
|
|
166
208
|
ui.heading(`Trace ${r.from} → ${r.to}`);
|
|
@@ -136,11 +136,41 @@ const MIGRATIONS = [
|
|
|
136
136
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
137
137
|
},
|
|
138
138
|
{
|
|
139
|
-
version: "0.
|
|
140
|
-
describe: "
|
|
141
|
-
agentPrompt: "-
|
|
142
|
-
"
|
|
143
|
-
"
|
|
139
|
+
version: "0.4.0",
|
|
140
|
+
describe: "Tool surface — eight canonical MCP tools, aliases deprecated",
|
|
141
|
+
agentPrompt: "- MCP exposes eight canonical tools: `compass_explore`, `compass_find`, `compass_diff_context`, " +
|
|
142
|
+
"`compass_index`, `lawbook_change`, `lawbook_investigate`, `speclaw_setup`, `speclaw_check`. " +
|
|
143
|
+
"Retired names (e.g. `compass_search`, `lawbook_validate`, `init_project`) are aliases with " +
|
|
144
|
+
"`[deprecated]` responses — prefer the canonical names. `scaffold`, `doctor`, `compass_visualize`, " +
|
|
145
|
+
"and `law_verify` are CLI-only. Minimal profile omits setup/check/investigate/index.\n" +
|
|
146
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
version: "0.4.1",
|
|
150
|
+
describe: "Rule integrity — speclaw.lock digests + injection scan",
|
|
151
|
+
agentPrompt: "- Mention committed `speclaw.lock` at the repo root (never under `.speclaw/`), " +
|
|
152
|
+
"`speclaw laws lock` / `accept` / `scan`, and that digest acceptance is interactive TTY only " +
|
|
153
|
+
"(never via MCP). `speclaw verify` folds integrity findings with deps/graph. " +
|
|
154
|
+
"Strict paths include AGENTS.md / CLAUDE.md / compiled rules; standards docs are advisory.\n" +
|
|
155
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
version: "0.4.2",
|
|
159
|
+
describe: "Spec ownership — team.owners → CODEOWNERS",
|
|
160
|
+
agentPrompt: '- Optional `team.owners` in `lawbook/config.yaml` maps capability names (and `"*"`) to ' +
|
|
161
|
+
"`@user` / `@org/team` / email owners. Run `speclaw owners --write` to compile a managed " +
|
|
162
|
+
"block at the **end** of `.github/CODEOWNERS` (GitHub: last match wins). `speclaw doctor` " +
|
|
163
|
+
"errors if content appears after the end marker. No new MCP tool — CLI only. " +
|
|
164
|
+
"deriveFromTraceability is not enabled in this release.\n" +
|
|
165
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
version: "1.0.0",
|
|
169
|
+
describe: "speclaw 1.0 — official release (enforcement + graph + lawbook + owners)",
|
|
170
|
+
agentPrompt: "- speclaw **1.0** is the official release: Foundation (hooks + `speclaw.lock` integrity), " +
|
|
171
|
+
"Compass (schema 10, eight canonical MCP tools), Lawbook (ceremony 0–3, coverage, drift, " +
|
|
172
|
+
"bugfix), and Team (`team.owners` → `speclaw owners --write`). Install remains " +
|
|
173
|
+
"`npx @esneiderbravo/speclaw@latest init`. CI consumers use `esneiderbravo/speclaw@v1`.\n" +
|
|
144
174
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
145
175
|
},
|
|
146
176
|
];
|
|
@@ -8,6 +8,7 @@ import { toMarkdown } from "../../modules/foundation/report-md.js";
|
|
|
8
8
|
import { toSarif } from "../../modules/foundation/sarif.js";
|
|
9
9
|
import { loadManifestForVerify } from "../../modules/foundation/laws.js";
|
|
10
10
|
import { verifyLaws } from "../../modules/foundation/verify.js";
|
|
11
|
+
import { foldIntegrityIntoReport, verifyIntegrity } from "../../modules/foundation/integrity.js";
|
|
11
12
|
import { driftFindingsForVerify } from "../../modules/lawbook/drift.js";
|
|
12
13
|
const FORMATS = new Set(["text", "json", "sarif", "markdown"]);
|
|
13
14
|
/**
|
|
@@ -46,6 +47,13 @@ export async function runVerify(flags) {
|
|
|
46
47
|
engines: engines.length ? engines : undefined,
|
|
47
48
|
lawIds: list(flags.law).length ? list(flags.law) : undefined,
|
|
48
49
|
});
|
|
50
|
+
// Rule-file integrity (speclaw.lock digests + injection scan). Soft when no lock.
|
|
51
|
+
// Covers: req~integrity-verify~1
|
|
52
|
+
const integrity = verifyIntegrity({ projectPath: cwd });
|
|
53
|
+
foldIntegrityIntoReport(report, integrity);
|
|
54
|
+
if (integrity.guidance && format === "text" && flags.json !== true) {
|
|
55
|
+
ui.info(integrity.guidance);
|
|
56
|
+
}
|
|
49
57
|
// Structural spec↔code drift (when anchors exist) contributes semantic/deleted
|
|
50
58
|
// findings into the same report stream used by SARIF / exit codes.
|
|
51
59
|
for (const f of driftFindingsForVerify(cwd)) {
|
package/dist/cli/index.js
CHANGED
|
@@ -18,13 +18,14 @@ Setup
|
|
|
18
18
|
agent add <id> Configure another agent later (symlinks + MCP)
|
|
19
19
|
|
|
20
20
|
Compass (code intelligence — the same surface agents use via MCP)
|
|
21
|
-
index (Re)build the local code graph
|
|
21
|
+
index (Re)build the local code graph (--force / --prune)
|
|
22
22
|
watch Keep the index fresh on file changes
|
|
23
23
|
explore <node> A node's source + callers/callees
|
|
24
|
-
search <query>
|
|
25
|
-
recall "<query>"
|
|
24
|
+
search <query> Hybrid find (BM25+vector+name); --focus --max-tokens --explain
|
|
25
|
+
recall "<query>" Hybrid find with concept weights; same flags as search
|
|
26
26
|
impact <node> Blast radius (grouped by module; --flat / --json)
|
|
27
27
|
affected-tests Tests affected by a change (--file / --from-diff / --json)
|
|
28
|
+
diff-context Change context for a diff (--file / --rev / --worktree / --json)
|
|
28
29
|
hotspots Rank files by recent churn × AST complexity (--json / --sort)
|
|
29
30
|
coupling <file> Temporal co-change partners for a file (--json)
|
|
30
31
|
trace <from> <to> A call path between two nodes
|
|
@@ -46,10 +47,16 @@ Other
|
|
|
46
47
|
budget Measure always-on context cost (tools, skills, instructions)
|
|
47
48
|
coverage Requirement → impl → test coverage (--json, --tap, --adopt, --write)
|
|
48
49
|
drift Spec↔code drift (--json, --reseal, --reverse, --fail-on)
|
|
50
|
+
owners Compile team.owners → .github/CODEOWNERS (--write / --check / --diff)
|
|
49
51
|
telemetry status Confirm speclaw ships no telemetry
|
|
50
52
|
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
51
53
|
laws verify Verify the deterministic dependency/graph laws against the index
|
|
52
|
-
|
|
54
|
+
laws compile Compile laws into agent rule dialects (AGENTS / Claude / Cursor / …)
|
|
55
|
+
laws import Import third-party rules as draft laws (--from rulesync)
|
|
56
|
+
laws lock Create/refresh committed speclaw.lock digests for rule files
|
|
57
|
+
laws accept <path> Interactively accept a changed rule-file digest (TTY only)
|
|
58
|
+
laws scan Scan rule/skill files for prompt-injection patterns
|
|
59
|
+
verify Verify laws + integrity for CI: exit codes, --sarif, --json, --strict-engines
|
|
53
60
|
mcp Start the MCP server (used by your agent's config)
|
|
54
61
|
help Show this help
|
|
55
62
|
--version Print the installed speclaw version
|
|
@@ -73,6 +80,7 @@ const HEADER_COMMANDS = new Set([
|
|
|
73
80
|
"coverage",
|
|
74
81
|
"drift",
|
|
75
82
|
"telemetry",
|
|
83
|
+
"owners",
|
|
76
84
|
"index",
|
|
77
85
|
"watch",
|
|
78
86
|
"lawbook",
|
|
@@ -144,6 +152,7 @@ async function dispatch(cmd, flags) {
|
|
|
144
152
|
case "impact":
|
|
145
153
|
case "trace":
|
|
146
154
|
case "affected-tests":
|
|
155
|
+
case "diff-context":
|
|
147
156
|
case "hotspots":
|
|
148
157
|
case "coupling":
|
|
149
158
|
return (await import("./commands/query.js")).runQuery(cmd, flags);
|
|
@@ -163,6 +172,8 @@ async function dispatch(cmd, flags) {
|
|
|
163
172
|
return (await import("./commands/drift.js")).runDrift(flags);
|
|
164
173
|
case "telemetry":
|
|
165
174
|
return (await import("./commands/telemetry.js")).runTelemetry(flags);
|
|
175
|
+
case "owners":
|
|
176
|
+
return (await import("./commands/owners.js")).runOwners(flags);
|
|
166
177
|
case "check":
|
|
167
178
|
return (await import("./commands/check.js")).runCheck(flags);
|
|
168
179
|
case "laws":
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fit ranked retrieval hits into a token budget via binary search, and render
|
|
3
|
+
* a compact TreeContext with elision markers.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Rough token estimate: ~4 chars/token, sampling every 100th line on large text.
|
|
7
|
+
*
|
|
8
|
+
* @param s - Text to estimate.
|
|
9
|
+
*/
|
|
10
|
+
export function estimateTokens(s) {
|
|
11
|
+
if (s.length < 4000)
|
|
12
|
+
return Math.ceil(s.length / 4);
|
|
13
|
+
const lines = s.split("\n");
|
|
14
|
+
if (lines.length < 200)
|
|
15
|
+
return Math.ceil(s.length / 4);
|
|
16
|
+
let sampled = 0;
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (let i = 0; i < lines.length; i += 100) {
|
|
19
|
+
sampled += lines[i].length + 1;
|
|
20
|
+
count++;
|
|
21
|
+
}
|
|
22
|
+
const avg = sampled / Math.max(count, 1);
|
|
23
|
+
return Math.ceil((avg * lines.length) / 4);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Render hits as a TreeContext block with `⋮` elision between non-adjacent lines.
|
|
27
|
+
*
|
|
28
|
+
* @param hits - Ordered hits to include.
|
|
29
|
+
*/
|
|
30
|
+
export function renderTreeContext(hits) {
|
|
31
|
+
if (hits.length === 0)
|
|
32
|
+
return "";
|
|
33
|
+
const byFile = new Map();
|
|
34
|
+
for (const h of hits) {
|
|
35
|
+
const list = byFile.get(h.file) ?? [];
|
|
36
|
+
list.push(h);
|
|
37
|
+
byFile.set(h.file, list);
|
|
38
|
+
}
|
|
39
|
+
const parts = [];
|
|
40
|
+
for (const [file, list] of byFile) {
|
|
41
|
+
parts.push(`# ${file}`);
|
|
42
|
+
list.sort((a, b) => a.line - b.line);
|
|
43
|
+
let lastLine = -Infinity;
|
|
44
|
+
for (const h of list) {
|
|
45
|
+
if (h.line - lastLine > 3 && lastLine !== -Infinity)
|
|
46
|
+
parts.push("⋮");
|
|
47
|
+
const sig = h.signature ?? `${h.kind} ${h.name}`;
|
|
48
|
+
parts.push(`${h.line}| ${sig}`);
|
|
49
|
+
if (h.excerpt) {
|
|
50
|
+
const lines = h.excerpt.split("\n");
|
|
51
|
+
if (lines.length > 6) {
|
|
52
|
+
parts.push(...lines.slice(0, 3).map((l) => ` ${l}`));
|
|
53
|
+
parts.push(" ⋮");
|
|
54
|
+
parts.push(...lines.slice(-2).map((l) => ` ${l}`));
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
parts.push(...lines.map((l) => ` ${l}`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
lastLine = h.line;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return parts.join("\n");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Binary-search how many leading hits fit `maxTokens` within 15% tolerance.
|
|
67
|
+
* Never returns empty when hits is non-empty — truncates the first hit instead.
|
|
68
|
+
*
|
|
69
|
+
* @param ranked - Hits in final rank order.
|
|
70
|
+
* @param maxTokens - Token budget.
|
|
71
|
+
*/
|
|
72
|
+
export function fitToBudget(ranked, maxTokens) {
|
|
73
|
+
const budget = Math.max(1, maxTokens);
|
|
74
|
+
if (ranked.length === 0) {
|
|
75
|
+
return { rendered: "", tokens: 0, budget, hitCount: 0 };
|
|
76
|
+
}
|
|
77
|
+
let lower = 1;
|
|
78
|
+
let upper = ranked.length;
|
|
79
|
+
let best = renderTreeContext(ranked.slice(0, 1));
|
|
80
|
+
let bestCount = 1;
|
|
81
|
+
while (lower <= upper) {
|
|
82
|
+
const mid = (lower + upper) >> 1;
|
|
83
|
+
const tree = renderTreeContext(ranked.slice(0, mid));
|
|
84
|
+
const n = estimateTokens(tree);
|
|
85
|
+
if (Math.abs(n - budget) / budget < 0.15) {
|
|
86
|
+
return { rendered: tree, tokens: n, budget, hitCount: mid };
|
|
87
|
+
}
|
|
88
|
+
if (n <= budget) {
|
|
89
|
+
best = tree;
|
|
90
|
+
bestCount = mid;
|
|
91
|
+
lower = mid + 1;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
upper = mid - 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Single oversized hit: truncate excerpt.
|
|
98
|
+
if (bestCount === 1 && estimateTokens(best) > budget) {
|
|
99
|
+
const h = { ...ranked[0], excerpt: truncateExcerpt(ranked[0].excerpt, budget) };
|
|
100
|
+
const rendered = renderTreeContext([h]);
|
|
101
|
+
return { rendered, tokens: estimateTokens(rendered), budget, hitCount: 1 };
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
rendered: best,
|
|
105
|
+
tokens: estimateTokens(best),
|
|
106
|
+
budget,
|
|
107
|
+
hitCount: bestCount,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function truncateExcerpt(excerpt, budget) {
|
|
111
|
+
if (!excerpt)
|
|
112
|
+
return "";
|
|
113
|
+
const maxChars = Math.max(32, budget * 3);
|
|
114
|
+
if (excerpt.length <= maxChars)
|
|
115
|
+
return excerpt;
|
|
116
|
+
return `${excerpt.slice(0, maxChars)}\n⋮`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Default token budget inspired by aider: clamp between 1024 and 4096, ×8 when
|
|
120
|
+
* there is no focus set.
|
|
121
|
+
*
|
|
122
|
+
* @param hasFocus - Whether a non-empty focus file set is in use.
|
|
123
|
+
* @param maxInput - Optional model input window hint.
|
|
124
|
+
*/
|
|
125
|
+
export function defaultBudget(hasFocus, maxInput = 32_000) {
|
|
126
|
+
const base = Math.max(1024, Math.min(Math.floor(maxInput / 8), 4096));
|
|
127
|
+
return hasFocus ? base : base * 8;
|
|
128
|
+
}
|