@ngockhoale/ukit 2.2.7 → 2.2.9
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 +75 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/scripts/bench/outline-savings.mjs +92 -0
- package/src/cli/commands/indexArgs.js +5 -1
- package/src/cli/commands/indexTools.js +36 -1
- package/src/core/compact/contextBudget.js +57 -0
- package/src/core/compact/threshold.js +5 -1
- package/src/core/runtimeConfig.js +2 -1
- package/src/index/buildIndex.js +95 -14
- package/src/index/paths.js +1 -1
- package/src/index/queryIndex.js +57 -1
- package/src/render/buildVariables.js +10 -0
- package/templates/.claude/settings.json +1 -1
- package/templates/.claude/ukit/index/lib/index-core.mjs +212 -11
- package/templates/.claude/ukit/index/query-index.mjs +38 -1
- package/templates/.claude/ukit/index/resolve-context.mjs +37 -2
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +10 -8
- package/templates/.omp/config.yml +6 -3
- package/templates/AGENTS.md +5 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/docs/AI_HANDOFF/RULES.md +2 -2
- package/templates/ukit/storage/config.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.2.9 - 2026-08-28
|
|
6
|
+
|
|
7
|
+
Retuning UKit's context budget used to mean editing five scattered places and hand-syncing two
|
|
8
|
+
of them. It is now **three numbers in one file**, and everything else is derived at install time.
|
|
9
|
+
|
|
10
|
+
The shipped defaults also move up for a 1M context window: compaction is recommended at 150,000
|
|
11
|
+
estimated tokens (was 50,000) and the hard cap sits at 500,000 — half the window (was 220,000).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **`compact.tokenThreshold` / `compact.hardCapTokens` / `compact.autoCompactWindowRatio` in
|
|
16
|
+
`.ukit/storage/config.json` are now the only knobs.** New `src/core/compact/contextBudget.js`
|
|
17
|
+
reads them and derives the rest, so the defaults in `runtimeConfig.js` and
|
|
18
|
+
`compact/threshold.js` no longer carry their own copies of the number. Edit the three values,
|
|
19
|
+
rerun `ukit install`, and the whole budget follows. Documented in README →
|
|
20
|
+
*Retuning The Context Budget (maintainers)*, including a window→cap sizing table, because the
|
|
21
|
+
new 500,000 default sits **above** a 200k or 256k model's window, where the gate could never fire.
|
|
22
|
+
- **New defaults:** `tokenThreshold` 50,000 → **150,000**; `hardCapTokens` 220,000 → **500,000**;
|
|
23
|
+
auto-compact fires at **70%** of the cap → 350,000.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **omp auto-compact had silently drifted out of sync.** `.omp/config.yml`'s
|
|
28
|
+
`compaction.thresholdTokens` was still 180,000 while Claude Code's
|
|
29
|
+
`CLAUDE_CODE_AUTO_COMPACT_WINDOW` had moved on — the two were separate hand-maintained numbers
|
|
30
|
+
with nothing pinning them together. Both are now rendered from the same derived value at
|
|
31
|
+
install time, so **omp and Claude Code auto-compact at the same point** and cannot drift apart
|
|
32
|
+
again.
|
|
33
|
+
- **`ompDocsSync > historical records are untouched by this cycle` failed on every new plan doc.**
|
|
34
|
+
The guard compared *all* changes under `docs/plans/` and friends against an old cycle's base
|
|
35
|
+
commit, so the first plan added afterwards (2.2.8's) tripped it permanently. Narrowed to
|
|
36
|
+
`--diff-filter=MDR`: rewriting, deleting or renaming a historical record still fails; adding a
|
|
37
|
+
new plan does not.
|
|
38
|
+
|
|
39
|
+
## 2.2.8 - 2026-08-28
|
|
40
|
+
|
|
41
|
+
UKit already picked the right files to open, but the step *after* that — actually entering the
|
|
42
|
+
file — was unoptimised: the agent read the whole thing to find the one function it needed. This
|
|
43
|
+
release adds a signature outline so it can jump straight to a line instead.
|
|
44
|
+
|
|
45
|
+
The outline is a map for **locating** code, not a substitute for reading it. Any code that is
|
|
46
|
+
about to be changed must still be read; the saving comes from narrower reads, not skipped ones.
|
|
47
|
+
That rule is stated in the shipped `CLAUDE.md` / `AGENTS.md` guidance next to the feature itself.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Indexed symbols now carry `line` and `signature`.** `query-index` and `resolve-context` print
|
|
52
|
+
an `outline:` block (`line: signature`) for the top suspect files, so an agent can go straight
|
|
53
|
+
to `Read(file, offset=<line>)`. On this repo, locating the top-3 suspects for a representative
|
|
54
|
+
query costs 2,082 bytes / 25 lines instead of 131,702 bytes / 4,064 lines of whole-file reads.
|
|
55
|
+
On by default; `--no-outline` turns it off. Capped at the top 3 files and 12 symbols each.
|
|
56
|
+
- **`getFileOutline()`** in the index core, returning line-sorted, name-deduped rows. Missing
|
|
57
|
+
file, missing artifact, or a stale schema all resolve to `[]`, so callers can render
|
|
58
|
+
unconditionally.
|
|
59
|
+
- **First behavioural parity test for the hand-maintained index mirror**
|
|
60
|
+
(`tests/index/indexCoreParity.test.js`). `templates/.claude/ukit/index/lib/index-core.mjs`
|
|
61
|
+
duplicates `src/index/*` by hand and was flagged in `MIRROR_PAIRS` but never actually compared;
|
|
62
|
+
drift could only be caught by reading both files. It is now asserted.
|
|
63
|
+
- **Both harnesses covered.** omp runs the same Node helpers and reads `AGENTS.md`, so the
|
|
64
|
+
guidance ships identically in `templates/CLAUDE.md` and `templates/AGENTS.md` (byte-identical
|
|
65
|
+
section bodies, enforced by `tests/consistency/contextDocsParity.test.js`). No separate omp
|
|
66
|
+
code path was needed.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- **`.vue` line numbers would have been silently wrong.** `extractScriptContent` joined the
|
|
71
|
+
`<script>` blocks together, so a symbol in the second block reported a line number offset by the
|
|
72
|
+
whole `<template>` above it. Script regions are now masked in place, preserving byte offsets, so
|
|
73
|
+
reported lines match the file on disk.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- `INDEX_SCHEMA_VERSION` 6 → 7. Existing `.cache/index/` artifacts are invalidated and rebuilt
|
|
78
|
+
automatically on first use; no migration step and no user action.
|
|
79
|
+
|
|
5
80
|
## 2.2.7 - 2026-08-28
|
|
6
81
|
|
|
7
82
|
Follow-up to 2.2.6: that release only made the wrong-runtime failure message *explain* the
|
package/README.md
CHANGED
|
@@ -114,6 +114,36 @@ For maintainers, the runtime is inspectable with:
|
|
|
114
114
|
|
|
115
115
|
Normal teammates should still only need **`ukit install`**.
|
|
116
116
|
|
|
117
|
+
## Retuning The Context Budget (maintainers)
|
|
118
|
+
|
|
119
|
+
Switching to a model with a different context window is a **three-number edit in one file**:
|
|
120
|
+
`templates/ukit/storage/config.json` → `compact`.
|
|
121
|
+
|
|
122
|
+
| Key | Default | What it means |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `tokenThreshold` | `150000` | Soft advisory. Past this UKit starts recommending compaction. |
|
|
125
|
+
| `hardCapTokens` | `500000` | Absolute ceiling — 50% of a 1M window. `context-hardcap-gate` blocks Edit/Write/Bash here. |
|
|
126
|
+
| `autoCompactWindowRatio` | `0.7` | Auto-compact fires at this fraction of the cap (→ `350000`). Must be `< 1`. |
|
|
127
|
+
|
|
128
|
+
Everything else is derived — do **not** hand-edit these:
|
|
129
|
+
|
|
130
|
+
- `.claude/settings.json` → `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW`
|
|
131
|
+
- `.omp/config.yml` → `compaction.thresholdTokens`
|
|
132
|
+
- the code-side defaults in `src/core/runtimeConfig.js` and `src/core/compact/threshold.js`
|
|
133
|
+
|
|
134
|
+
They are all rendered/read from the three keys above via `src/core/compact/contextBudget.js`.
|
|
135
|
+
Edit the three numbers, rerun **`ukit install`**, and Claude Code and omp both follow.
|
|
136
|
+
|
|
137
|
+
`hardCapTokens` must stay **below the model's real context window**, otherwise the API rejects
|
|
138
|
+
the request before the gate can ever fire. Sizing at 50% of the window keeps room for the
|
|
139
|
+
response plus the estimator's own undercount:
|
|
140
|
+
|
|
141
|
+
| Model window | `hardCapTokens` | derived auto-compact |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| 1M | `500000` | `350000` |
|
|
144
|
+
| 256k | `128000` | `89600` |
|
|
145
|
+
| 200k | `100000` | `70000` |
|
|
146
|
+
|
|
117
147
|
## Installer Behavior
|
|
118
148
|
|
|
119
149
|
- idempotent install (safe to rerun)
|
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* outline-savings.mjs — Signature Outline Layer (S8 measurement).
|
|
4
|
+
*
|
|
5
|
+
* Reports actual whole-file vs outline byte/line counts for this repo's top-3 suspects on a
|
|
6
|
+
* representative query, so the CHANGELOG can quote a measured number instead of a guess.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node scripts/bench/outline-savings.mjs ["<query>"] [--root <dir>] [--limit 3]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { buildCodeIndex } from '../../src/index/buildIndex.js';
|
|
16
|
+
import { queryCodeIndex, getFileOutline } from '../../src/index/queryIndex.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_QUERY = 'resolveContext';
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const rootDir = readFlagValue(args, '--root') ?? process.cwd();
|
|
23
|
+
const limit = Number.parseInt(readFlagValue(args, '--limit') ?? '3', 10);
|
|
24
|
+
const query = collectPositionalArgs(args, ['--root', '--limit']) || DEFAULT_QUERY;
|
|
25
|
+
|
|
26
|
+
await buildCodeIndex({ rootDir });
|
|
27
|
+
const results = await queryCodeIndex({ rootDir, query, limit });
|
|
28
|
+
|
|
29
|
+
if (results.length === 0) {
|
|
30
|
+
console.log(`[bench:outline-savings] no matches for query "${query}"`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let wholeFileBytes = 0;
|
|
35
|
+
let wholeFileLines = 0;
|
|
36
|
+
let outlineBytes = 0;
|
|
37
|
+
let outlineLines = 0;
|
|
38
|
+
|
|
39
|
+
for (const item of results) {
|
|
40
|
+
const absolutePath = path.join(rootDir, item.filePath);
|
|
41
|
+
const content = await fs.readFile(absolutePath, 'utf8');
|
|
42
|
+
wholeFileBytes += Buffer.byteLength(content, 'utf8');
|
|
43
|
+
wholeFileLines += content.split('\n').length;
|
|
44
|
+
|
|
45
|
+
const outline = await getFileOutline({ rootDir, filePath: item.filePath, limit: Infinity });
|
|
46
|
+
const rendered = renderOutline(outline);
|
|
47
|
+
outlineBytes += Buffer.byteLength(rendered, 'utf8');
|
|
48
|
+
outlineLines += rendered.length === 0 ? 0 : rendered.split('\n').length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`[bench:outline-savings] query: "${query}", top-${results.length} suspects: ${results.map((r) => r.filePath).join(', ')}`);
|
|
52
|
+
console.log(`whole-file read of top-${results.length} suspects : ${wholeFileBytes} bytes / ${wholeFileLines} lines`);
|
|
53
|
+
console.log(`outline of top-${results.length} suspects : ${outlineBytes} bytes / ${outlineLines} lines`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderOutline(outline) {
|
|
57
|
+
if (outline.length === 0) {
|
|
58
|
+
return '';
|
|
59
|
+
}
|
|
60
|
+
const labels = outline.map((item) => String(item.line));
|
|
61
|
+
const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
|
|
62
|
+
const lines = [' outline:'];
|
|
63
|
+
for (const item of outline) {
|
|
64
|
+
lines.push(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
|
|
65
|
+
}
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readFlagValue(argv, flag) {
|
|
70
|
+
const exact = argv.indexOf(flag);
|
|
71
|
+
if (exact >= 0 && argv[exact + 1]) return argv[exact + 1];
|
|
72
|
+
const withEquals = argv.find((item) => item.startsWith(`${flag}=`));
|
|
73
|
+
return withEquals ? withEquals.slice(flag.length + 1) : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectPositionalArgs(argv, flagsWithValues = []) {
|
|
77
|
+
return argv
|
|
78
|
+
.filter((arg, index) => !isFlagOrValue(argv, index, flagsWithValues))
|
|
79
|
+
.join(' ')
|
|
80
|
+
.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isFlagOrValue(argv, index, flagsWithValues = []) {
|
|
84
|
+
const arg = argv[index];
|
|
85
|
+
if (!arg.startsWith('--')) {
|
|
86
|
+
const prev = argv[index - 1];
|
|
87
|
+
return Boolean(prev && flagsWithValues.includes(prev));
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await main();
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
const QUERY_FLAG_DEFINITIONS = new Map([
|
|
1
|
+
const QUERY_FLAG_DEFINITIONS = new Map([
|
|
2
|
+
['--limit', { requiresValue: true }],
|
|
3
|
+
['--no-outline', { requiresValue: false }],
|
|
4
|
+
]);
|
|
2
5
|
const TRIAGE_FLAG_DEFINITIONS = new Map();
|
|
3
6
|
const CONTEXT_FLAG_DEFINITIONS = new Map([
|
|
4
7
|
['--target', { requiresValue: true }],
|
|
5
8
|
['--type', { requiresValue: true }],
|
|
9
|
+
['--no-outline', { requiresValue: false }],
|
|
6
10
|
]);
|
|
7
11
|
const VERIFY_FLAG_DEFINITIONS = new Map([
|
|
8
12
|
['--target', { requiresValue: true }],
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { resolveContext } from '../../index/resolveContext.js';
|
|
8
8
|
import { deriveVerificationPlan } from '../../index/verificationPlan.js';
|
|
9
9
|
import { deriveTaskRoute } from '../../index/taskRouting.js';
|
|
10
|
-
import { queryCodeIndex } from '../../index/queryIndex.js';
|
|
10
|
+
import { queryCodeIndex, getFileOutline } from '../../index/queryIndex.js';
|
|
11
11
|
import { triageBug } from '../../bug/triageBug.js';
|
|
12
12
|
import { installIndexRefreshHooks, removeIndexRefreshHooks } from '../../index/gitHooks.js';
|
|
13
13
|
import {
|
|
@@ -25,6 +25,8 @@ const CONTEXT_REASON_LIMIT = 2;
|
|
|
25
25
|
const VERIFY_COMMAND_DISPLAY_LIMIT = 2;
|
|
26
26
|
const VERIFY_REASON_DISPLAY_LIMIT = 2;
|
|
27
27
|
const VERIFY_NOTE_DISPLAY_LIMIT = 1;
|
|
28
|
+
const OUTLINE_TOP_FILES_LIMIT = 3;
|
|
29
|
+
const OUTLINE_SYMBOLS_LIMIT = 12;
|
|
28
30
|
|
|
29
31
|
export async function runIndexTools({ projectRoot, argv = [] }) {
|
|
30
32
|
const [subcommandRaw, ...rest] = argv;
|
|
@@ -68,6 +70,7 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
|
|
|
68
70
|
return;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
const showOutline = !flags.get('--no-outline');
|
|
71
74
|
console.log(`[UKit] Index query top ${results.length} for: ${query}`);
|
|
72
75
|
for (const [idx, item] of results.entries()) {
|
|
73
76
|
console.log(`${idx + 1}. ${item.filePath} (score=${item.score})`);
|
|
@@ -77,6 +80,9 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
|
|
|
77
80
|
if ((item.reasons ?? []).length > 0) {
|
|
78
81
|
console.log(` reasons: ${item.reasons.join(', ')}`);
|
|
79
82
|
}
|
|
83
|
+
if (showOutline && idx < OUTLINE_TOP_FILES_LIMIT) {
|
|
84
|
+
await printOutlineForFile(projectRoot, item.filePath);
|
|
85
|
+
}
|
|
80
86
|
}
|
|
81
87
|
return;
|
|
82
88
|
}
|
|
@@ -118,10 +124,16 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
|
|
|
118
124
|
taskType,
|
|
119
125
|
});
|
|
120
126
|
|
|
127
|
+
const showOutline = !flags.get('--no-outline');
|
|
121
128
|
console.log('[UKit] Suggested context:');
|
|
122
129
|
console.log(`taskType: ${result.taskType}`);
|
|
123
130
|
console.log(`budget: ${result.contextBudget.minFiles}-${result.contextBudget.maxFiles} files`);
|
|
124
131
|
printContextGroup('primaryTargets', result.primaryTargets, result.explanations?.primaryTargets);
|
|
132
|
+
if (showOutline) {
|
|
133
|
+
for (const filePath of (result.primaryTargets ?? []).slice(0, OUTLINE_TOP_FILES_LIMIT)) {
|
|
134
|
+
await printOutlineForFile(projectRoot, filePath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
125
137
|
printContextGroup('sharedAbstractions', result.sharedAbstractions, result.explanations?.sharedAbstractions);
|
|
126
138
|
printContextGroup('analogFiles', result.analogFiles, result.explanations?.analogFiles);
|
|
127
139
|
printContextGroup('relatedTests', result.relatedTests, result.explanations?.relatedTests);
|
|
@@ -286,6 +298,29 @@ async function refreshIndexIfStale(rootDir) {
|
|
|
286
298
|
return true;
|
|
287
299
|
}
|
|
288
300
|
|
|
301
|
+
async function printOutlineForFile(rootDir, filePath) {
|
|
302
|
+
const outline = await getFileOutline({ rootDir, filePath, limit: Infinity });
|
|
303
|
+
if (outline.length === 0) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const shown = outline.slice(0, OUTLINE_SYMBOLS_LIMIT);
|
|
308
|
+
const remaining = outline.length - shown.length;
|
|
309
|
+
const labels = shown.map((item) => String(item.line));
|
|
310
|
+
if (remaining > 0) {
|
|
311
|
+
labels.push(`+${remaining}`);
|
|
312
|
+
}
|
|
313
|
+
const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
|
|
314
|
+
|
|
315
|
+
console.log(' outline:');
|
|
316
|
+
for (const item of shown) {
|
|
317
|
+
console.log(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
|
|
318
|
+
}
|
|
319
|
+
if (remaining > 0) {
|
|
320
|
+
console.log(` ${`+${remaining}`.padStart(labelWidth)} more symbols`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
289
324
|
function printContextGroup(label, filePaths = [], explanations = []) {
|
|
290
325
|
console.log(`${label}: ${formatCompactDisplayList(filePaths)}`);
|
|
291
326
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
// The shipped compact.tokenThreshold / compact.hardCapTokens / compact.autoCompactWindowRatio
|
|
5
|
+
// trio is the ONLY place the context budget is written down. Every other number — the
|
|
6
|
+
// code-side defaults, CLAUDE_CODE_AUTO_COMPACT_WINDOW in .claude/settings.json, and
|
|
7
|
+
// compaction.thresholdTokens in .omp/config.yml — is read or derived from here, so retuning
|
|
8
|
+
// UKit for a different model context window is a three-number edit in
|
|
9
|
+
// templates/ukit/storage/config.json and nothing else.
|
|
10
|
+
const SHIPPED_CONFIG_URL = new URL('../../../templates/ukit/storage/config.json', import.meta.url);
|
|
11
|
+
|
|
12
|
+
// Only reachable if the package ships without its own templates. Deliberately conservative:
|
|
13
|
+
// a 200k model survives these, a 1M model merely compacts earlier than it had to.
|
|
14
|
+
const EMERGENCY_BUDGET = {
|
|
15
|
+
tokenThreshold: 50_000,
|
|
16
|
+
hardCapTokens: 160_000,
|
|
17
|
+
autoCompactWindowRatio: 0.7,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Auto-compact must land strictly below hardCapTokens so the client heals itself before
|
|
21
|
+
// context-hardcap-gate starts refusing Edit/Write/Bash; a ratio >= 1 would invert that and is
|
|
22
|
+
// treated as a typo rather than honoured.
|
|
23
|
+
export function deriveAutoCompactWindow(hardCapTokens, ratio) {
|
|
24
|
+
const safeRatio = Number.isFinite(ratio) && ratio > 0 && ratio < 1
|
|
25
|
+
? ratio
|
|
26
|
+
: EMERGENCY_BUDGET.autoCompactWindowRatio;
|
|
27
|
+
return Math.round(hardCapTokens * safeRatio);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cachedBudget = null;
|
|
31
|
+
|
|
32
|
+
export function loadShippedCompactBudget() {
|
|
33
|
+
if (cachedBudget) {
|
|
34
|
+
return cachedBudget;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let compact = EMERGENCY_BUDGET;
|
|
38
|
+
try {
|
|
39
|
+
compact = JSON.parse(readFileSync(fileURLToPath(SHIPPED_CONFIG_URL), 'utf8')).compact ?? {};
|
|
40
|
+
} catch {
|
|
41
|
+
compact = EMERGENCY_BUDGET;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tokenThreshold = Number.isFinite(compact.tokenThreshold)
|
|
45
|
+
? compact.tokenThreshold
|
|
46
|
+
: EMERGENCY_BUDGET.tokenThreshold;
|
|
47
|
+
const hardCapTokens = Number.isFinite(compact.hardCapTokens)
|
|
48
|
+
? compact.hardCapTokens
|
|
49
|
+
: EMERGENCY_BUDGET.hardCapTokens;
|
|
50
|
+
|
|
51
|
+
cachedBudget = {
|
|
52
|
+
tokenThreshold,
|
|
53
|
+
hardCapTokens,
|
|
54
|
+
autoCompactWindow: deriveAutoCompactWindow(hardCapTokens, compact.autoCompactWindowRatio),
|
|
55
|
+
};
|
|
56
|
+
return cachedBudget;
|
|
57
|
+
}
|
|
@@ -2,6 +2,7 @@ import { readJsonIfExists, writeJson } from '../fileOps.js';
|
|
|
2
2
|
import { buildRuntimePaths } from '../runtimePaths.js';
|
|
3
3
|
import { buildCompactMachineKey, compressLine, estimateTokenCount } from '../token/index.js';
|
|
4
4
|
import { compactContextBlock } from './index.js';
|
|
5
|
+
import { loadShippedCompactBudget } from './contextBudget.js';
|
|
5
6
|
|
|
6
7
|
const DEFAULT_MAX_PROMPT_ENTRIES = 12;
|
|
7
8
|
const DEFAULT_MAX_OUTPUT_ENTRIES = 12;
|
|
@@ -427,7 +428,10 @@ function computeEstimatedTotalTokens({
|
|
|
427
428
|
}
|
|
428
429
|
|
|
429
430
|
export function buildCompactThresholds(config = {}) {
|
|
430
|
-
const softThreshold = Math.max(
|
|
431
|
+
const softThreshold = Math.max(
|
|
432
|
+
1,
|
|
433
|
+
finiteNumber(config?.compact?.tokenThreshold, loadShippedCompactBudget().tokenThreshold),
|
|
434
|
+
);
|
|
431
435
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
432
436
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
433
437
|
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { buildRuntimePaths } from './runtimePaths.js';
|
|
5
|
+
import { loadShippedCompactBudget } from './compact/contextBudget.js';
|
|
5
6
|
|
|
6
7
|
const require = createRequire(import.meta.url);
|
|
7
8
|
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
@@ -70,7 +71,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
70
71
|
},
|
|
71
72
|
compact: {
|
|
72
73
|
enabled: true,
|
|
73
|
-
tokenThreshold:
|
|
74
|
+
tokenThreshold: loadShippedCompactBudget().tokenThreshold,
|
|
74
75
|
contextRotDetection: true,
|
|
75
76
|
askBeforeDrop: true,
|
|
76
77
|
agentContext: {
|
package/src/index/buildIndex.js
CHANGED
|
@@ -607,36 +607,117 @@ function findScriptClose(content, startIndex) {
|
|
|
607
607
|
return -1;
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
-
function extractScriptContent(filePath, content) {
|
|
610
|
+
export function extractScriptContent(filePath, content) {
|
|
611
611
|
if (!filePath.endsWith('.vue')) {
|
|
612
612
|
return content;
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
|
|
615
|
+
// Mask instead of extract: the returned string is the same length as `content`,
|
|
616
|
+
// with every character outside a <script> body replaced by a space (newlines kept).
|
|
617
|
+
// That keeps offsets mapped 1:1 to the original file, so line numbers computed
|
|
618
|
+
// against this string are correct for the file on disk. Extracting and `join('\n')`ing
|
|
619
|
+
// the bodies (the old approach) destroys that positional relationship.
|
|
620
|
+
const masked = content.replace(/[^\n]/g, ' ').split('');
|
|
621
|
+
let foundAny = false;
|
|
616
622
|
const startTagPattern = /<script[^>]*>/gi;
|
|
617
623
|
for (const match of content.matchAll(startTagPattern)) {
|
|
618
624
|
const bodyStart = match.index + match[0].length;
|
|
619
625
|
const bodyEnd = findScriptClose(content, bodyStart);
|
|
620
626
|
if (bodyEnd !== -1) {
|
|
621
|
-
|
|
627
|
+
foundAny = true;
|
|
628
|
+
for (let i = bodyStart; i < bodyEnd; i += 1) {
|
|
629
|
+
masked[i] = content[i];
|
|
630
|
+
}
|
|
622
631
|
}
|
|
623
632
|
}
|
|
624
|
-
if (
|
|
625
|
-
return
|
|
633
|
+
if (!foundAny) return '';
|
|
634
|
+
return masked.join('');
|
|
626
635
|
}
|
|
627
636
|
|
|
628
637
|
|
|
629
|
-
|
|
638
|
+
// Precompute the start offset of every line once per file, so `line` for a match
|
|
639
|
+
// can be found with a binary search instead of `content.slice(0, i).split('\n').length`
|
|
640
|
+
// (which is O(n) per symbol, O(n^2) across a large file).
|
|
641
|
+
function computeLineStarts(content) {
|
|
642
|
+
const starts = [0];
|
|
643
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
644
|
+
if (content[index] === '\n') {
|
|
645
|
+
starts.push(index + 1);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return starts;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function lineNumberForIndex(lineStarts, index) {
|
|
652
|
+
let low = 0;
|
|
653
|
+
let high = lineStarts.length - 1;
|
|
654
|
+
while (low < high) {
|
|
655
|
+
const mid = Math.ceil((low + high) / 2);
|
|
656
|
+
if (lineStarts[mid] <= index) {
|
|
657
|
+
low = mid;
|
|
658
|
+
} else {
|
|
659
|
+
high = mid - 1;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return low + 1;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Display string only, not a parse: scan forward from the start of the matched line to the
|
|
666
|
+
// `)` that closes the parameter list, or to end-of-line when the line opens no paren. Depth
|
|
667
|
+
// is tracked rather than stopping at the first `)`, otherwise a default-valued parameter
|
|
668
|
+
// truncates mid-expression (`function f({ root = process.cwd() } = {})` would cut at the
|
|
669
|
+
// `)` of `process.cwd()`). The scan is capped at 400 chars so a malformed file cannot walk
|
|
670
|
+
// the whole buffer.
|
|
671
|
+
function computeSignature(content, lineStartIndex) {
|
|
672
|
+
const boundedEnd = Math.min(content.length, lineStartIndex + 400);
|
|
673
|
+
let sliceEnd = boundedEnd;
|
|
674
|
+
let depth = 0;
|
|
675
|
+
let sawOpenParen = false;
|
|
676
|
+
for (let index = lineStartIndex; index < boundedEnd; index += 1) {
|
|
677
|
+
const char = content[index];
|
|
678
|
+
if (char === '(') {
|
|
679
|
+
depth += 1;
|
|
680
|
+
sawOpenParen = true;
|
|
681
|
+
} else if (char === ')') {
|
|
682
|
+
depth -= 1;
|
|
683
|
+
if (sawOpenParen && depth <= 0) {
|
|
684
|
+
sliceEnd = index + 1;
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
} else if (char === '\n' && !sawOpenParen) {
|
|
688
|
+
sliceEnd = index;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Keep a trailing `=>` so an arrow function stays distinguishable from an ordinary
|
|
694
|
+
// parenthesised expression. getFileOutline relies on that to filter outline noise.
|
|
695
|
+
const arrowMatch = /^\s*=>/.exec(content.slice(sliceEnd, Math.min(content.length, sliceEnd + 8)));
|
|
696
|
+
if (arrowMatch) {
|
|
697
|
+
sliceEnd += arrowMatch[0].length;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const collapsed = content.slice(lineStartIndex, sliceEnd).replace(/\s+/g, ' ').trim();
|
|
701
|
+
if (collapsed.length <= 200) {
|
|
702
|
+
return collapsed;
|
|
703
|
+
}
|
|
704
|
+
return `${collapsed.slice(0, 199)}…`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export function extractSymbols(filePath, content) {
|
|
630
708
|
const symbols = [];
|
|
631
|
-
const
|
|
709
|
+
const lineStarts = computeLineStarts(content);
|
|
710
|
+
const addSymbol = (name, type, matchIndex = null) => {
|
|
632
711
|
if (!name) {
|
|
633
712
|
return;
|
|
634
713
|
}
|
|
635
|
-
|
|
636
|
-
name,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
714
|
+
if (matchIndex === null) {
|
|
715
|
+
symbols.push({ name, type, filePath, line: null, signature: null });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const line = lineNumberForIndex(lineStarts, matchIndex);
|
|
719
|
+
const signature = computeSignature(content, lineStarts[line - 1]);
|
|
720
|
+
symbols.push({ name, type, filePath, line, signature });
|
|
640
721
|
};
|
|
641
722
|
const addMatches = (regex, type, groupIndex = 1) => {
|
|
642
723
|
for (const match of content.matchAll(regex)) {
|
|
@@ -644,7 +725,7 @@ function extractSymbols(filePath, content) {
|
|
|
644
725
|
if (!symbolName) {
|
|
645
726
|
continue;
|
|
646
727
|
}
|
|
647
|
-
addSymbol(symbolName, type);
|
|
728
|
+
addSymbol(symbolName, type, match.index);
|
|
648
729
|
}
|
|
649
730
|
};
|
|
650
731
|
|
|
@@ -671,7 +752,7 @@ function extractSymbols(filePath, content) {
|
|
|
671
752
|
if (!aliasMatch) {
|
|
672
753
|
continue;
|
|
673
754
|
}
|
|
674
|
-
addSymbol(aliasMatch[2] ?? aliasMatch[1], 'named-export');
|
|
755
|
+
addSymbol(aliasMatch[2] ?? aliasMatch[1], 'named-export', match.index);
|
|
675
756
|
}
|
|
676
757
|
}
|
|
677
758
|
|
package/src/index/paths.js
CHANGED
package/src/index/queryIndex.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, isLikelyTestFilePath } from './paths.js';
|
|
4
|
+
import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath } from './paths.js';
|
|
5
5
|
import { importsNeedAliasContext, loadImportAliasContext, resolveImportSpecifier } from './importResolution.js';
|
|
6
6
|
import { buildSearchDescriptor } from './languageTools.js';
|
|
7
7
|
|
|
@@ -130,6 +130,62 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
|
|
|
130
130
|
return queryPromise;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// The `callable` symbol regex matches any `const x = (`, so it also captures plain
|
|
134
|
+
// parenthesised expressions such as `const rows = (items ?? [])`. Harmless for fuzzy
|
|
135
|
+
// ranking, misleading in an outline that is read as a map of the file's API. Those rows
|
|
136
|
+
// stay in symbols.json and are dropped here instead.
|
|
137
|
+
function isFunctionLikeSignature(signature) {
|
|
138
|
+
const normalized = String(signature ?? '');
|
|
139
|
+
// computeSignature only appends `=>` directly after a balanced parameter list, so a
|
|
140
|
+
// trailing arrow is a reliable marker and body text that merely contains one is not.
|
|
141
|
+
if (normalized.endsWith('=>')) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
// `function`/`class` must belong to the declaration head, not to body text the bounded
|
|
145
|
+
// scan happened to include.
|
|
146
|
+
return /\b(?:function|class)\b/.test(normalized.split('(')[0]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Reads symbols.json through the existing readArtifact cache (no new file reads, no new
|
|
150
|
+
// cache). Never throws: an absent file, a missing artifact, or a stale schema all resolve
|
|
151
|
+
// to [] so callers can render an outline block unconditionally.
|
|
152
|
+
export async function getFileOutline({ rootDir = process.cwd(), filePath, limit = 12 } = {}) {
|
|
153
|
+
if (!filePath) {
|
|
154
|
+
return Object.freeze([]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
158
|
+
const symbolsArtifact = await readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols);
|
|
159
|
+
const safeSymbolsArtifact = ensureArtifact(symbolsArtifact);
|
|
160
|
+
if (safeSymbolsArtifact.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
161
|
+
return Object.freeze([]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rows = (safeSymbolsArtifact.items ?? [])
|
|
165
|
+
.filter((item) => item?.filePath === filePath && item?.line !== null && item?.line !== undefined)
|
|
166
|
+
.filter((item) => item?.type !== 'callable' || isFunctionLikeSignature(item?.signature))
|
|
167
|
+
.sort((a, b) => a.line - b.line);
|
|
168
|
+
|
|
169
|
+
const seenNames = new Set();
|
|
170
|
+
const deduped = [];
|
|
171
|
+
for (const item of rows) {
|
|
172
|
+
if (seenNames.has(item.name)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
seenNames.add(item.name);
|
|
176
|
+
deduped.push(item);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return Object.freeze(
|
|
180
|
+
deduped.slice(0, limit).map((item) => Object.freeze({
|
|
181
|
+
line: item.line,
|
|
182
|
+
name: item.name,
|
|
183
|
+
type: item.type,
|
|
184
|
+
signature: item.signature ?? null,
|
|
185
|
+
})),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
133
189
|
async function readArtifact(rootDir, artifactName) {
|
|
134
190
|
const artifactPath = getArtifactPath(path.resolve(rootDir), artifactName);
|
|
135
191
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadShippedCompactBudget } from '../core/compact/contextBudget.js';
|
|
2
|
+
|
|
1
3
|
const CODEGRAPH_SECTION = `
|
|
2
4
|
## CodeGraph Integration (active)
|
|
3
5
|
|
|
@@ -12,7 +14,15 @@ To disable: rerun \`ukit install\` without \`--with-codegraph\`.
|
|
|
12
14
|
`;
|
|
13
15
|
|
|
14
16
|
export function buildTemplateVariables({ projectContext, stackContext, packageVersion, providerContext, withCodegraph = false }) {
|
|
17
|
+
// Derived, never hand-written: .claude/settings.json is read by Claude Code itself and so
|
|
18
|
+
// cannot consult .ukit/storage/config.json at runtime. Baking the window in here keeps
|
|
19
|
+
// compact.hardCapTokens the single number that retunes both.
|
|
20
|
+
const { autoCompactWindow } = loadShippedCompactBudget();
|
|
21
|
+
|
|
15
22
|
return {
|
|
23
|
+
compact: {
|
|
24
|
+
autoCompactWindow: String(autoCompactWindow),
|
|
25
|
+
},
|
|
16
26
|
project: {
|
|
17
27
|
name: projectContext.project.name,
|
|
18
28
|
root: projectContext.project.root,
|
|
@@ -28,7 +28,7 @@ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '
|
|
|
28
28
|
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
29
29
|
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
30
30
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
31
|
-
const INDEX_SCHEMA_VERSION =
|
|
31
|
+
export const INDEX_SCHEMA_VERSION = 7;
|
|
32
32
|
export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
33
33
|
const INDEX_PARSE_BATCH_SIZE = 8;
|
|
34
34
|
const MAX_IMPORTER_HOPS = 2;
|
|
@@ -395,6 +395,62 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
395
395
|
|
|
396
396
|
// ── Query Index ──
|
|
397
397
|
|
|
398
|
+
// The `callable` symbol regex matches any `const x = (`, so it also captures plain
|
|
399
|
+
// parenthesised expressions such as `const rows = (items ?? [])`. Harmless for fuzzy
|
|
400
|
+
// ranking, misleading in an outline that is read as a map of the file's API. Those rows
|
|
401
|
+
// stay in symbols.json and are dropped here instead.
|
|
402
|
+
function isFunctionLikeSignature(signature) {
|
|
403
|
+
const normalized = String(signature ?? '');
|
|
404
|
+
// computeSignature only appends `=>` directly after a balanced parameter list, so a
|
|
405
|
+
// trailing arrow is a reliable marker and body text that merely contains one is not.
|
|
406
|
+
if (normalized.endsWith('=>')) {
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
// `function`/`class` must belong to the declaration head, not to body text the bounded
|
|
410
|
+
// scan happened to include.
|
|
411
|
+
return /\b(?:function|class)\b/.test(normalized.split('(')[0]);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Reads symbols.json through the existing readArtifact cache (no new file reads, no new
|
|
415
|
+
// cache). Never throws: an absent file, a missing artifact, or a stale schema all resolve
|
|
416
|
+
// to [] so callers can render an outline block unconditionally.
|
|
417
|
+
export async function getFileOutline({ rootDir = process.cwd(), filePath, limit = 12 } = {}) {
|
|
418
|
+
if (!filePath) {
|
|
419
|
+
return Object.freeze([]);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const absoluteRoot = path.resolve(rootDir);
|
|
423
|
+
const symbolsArtifact = await readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols);
|
|
424
|
+
const safeSymbolsArtifact = ensureArtifact(symbolsArtifact);
|
|
425
|
+
if (safeSymbolsArtifact.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
426
|
+
return Object.freeze([]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const rows = (safeSymbolsArtifact.items ?? [])
|
|
430
|
+
.filter((item) => item?.filePath === filePath && item?.line !== null && item?.line !== undefined)
|
|
431
|
+
.filter((item) => item?.type !== 'callable' || isFunctionLikeSignature(item?.signature))
|
|
432
|
+
.sort((a, b) => a.line - b.line);
|
|
433
|
+
|
|
434
|
+
const seenNames = new Set();
|
|
435
|
+
const deduped = [];
|
|
436
|
+
for (const item of rows) {
|
|
437
|
+
if (seenNames.has(item.name)) {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
seenNames.add(item.name);
|
|
441
|
+
deduped.push(item);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return Object.freeze(
|
|
445
|
+
deduped.slice(0, limit).map((item) => Object.freeze({
|
|
446
|
+
line: item.line,
|
|
447
|
+
name: item.name,
|
|
448
|
+
type: item.type,
|
|
449
|
+
signature: item.signature ?? null,
|
|
450
|
+
})),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
398
454
|
export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5 } = {}) {
|
|
399
455
|
if (!query || !query.trim()) return [];
|
|
400
456
|
if (limit <= 0) return Object.freeze([]);
|
|
@@ -1588,24 +1644,169 @@ async function collectFiles(scanRoots) {
|
|
|
1588
1644
|
return result;
|
|
1589
1645
|
}
|
|
1590
1646
|
|
|
1591
|
-
function
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1647
|
+
function findScriptClose(content, startIndex) {
|
|
1648
|
+
const lower = content.toLowerCase();
|
|
1649
|
+
let state = 'normal';
|
|
1650
|
+
let escaped = false;
|
|
1651
|
+
let templateExpressionDepth = 0;
|
|
1652
|
+
|
|
1653
|
+
for (let index = startIndex; index < content.length; index += 1) {
|
|
1654
|
+
const char = content[index];
|
|
1655
|
+
const next = content[index + 1];
|
|
1656
|
+
|
|
1657
|
+
if (state === 'line-comment') {
|
|
1658
|
+
if (char === '\n') state = 'normal';
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
if (state === 'block-comment') {
|
|
1662
|
+
if (char === '*' && next === '/') {
|
|
1663
|
+
index += 1;
|
|
1664
|
+
state = 'normal';
|
|
1665
|
+
}
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
if (state === 'single' || state === 'double' || state === 'template') {
|
|
1669
|
+
if (escaped) {
|
|
1670
|
+
escaped = false;
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
if (char === '\\') {
|
|
1674
|
+
escaped = true;
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
if (state === 'single' && char === "'") state = 'normal';
|
|
1678
|
+
else if (state === 'double' && char === '"') state = 'normal';
|
|
1679
|
+
else if (state === 'template') {
|
|
1680
|
+
if (char === '`' && templateExpressionDepth === 0) state = 'normal';
|
|
1681
|
+
else if (char === '{' && content[index - 1] === '$') templateExpressionDepth += 1;
|
|
1682
|
+
else if (char === '}' && templateExpressionDepth > 0) templateExpressionDepth -= 1;
|
|
1683
|
+
}
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
if (lower.startsWith('</script>', index)) return index;
|
|
1688
|
+
if (char === '/' && next === '/') {
|
|
1689
|
+
state = 'line-comment';
|
|
1690
|
+
index += 1;
|
|
1691
|
+
} else if (char === '/' && next === '*') {
|
|
1692
|
+
state = 'block-comment';
|
|
1693
|
+
index += 1;
|
|
1694
|
+
} else if (char === "'") state = 'single';
|
|
1695
|
+
else if (char === '"') state = 'double';
|
|
1696
|
+
else if (char === '`') state = 'template';
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
return -1;
|
|
1596
1700
|
}
|
|
1597
1701
|
|
|
1598
|
-
function
|
|
1702
|
+
export function extractScriptContent(filePath, content) {
|
|
1703
|
+
if (!filePath.endsWith('.vue')) {
|
|
1704
|
+
return content;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Mask instead of extract: same length as `content`, non-script chars replaced by a
|
|
1708
|
+
// space (newlines kept), so offsets stay 1:1 with the file on disk. See buildIndex.js.
|
|
1709
|
+
const masked = content.replace(/[^\n]/g, ' ').split('');
|
|
1710
|
+
let foundAny = false;
|
|
1711
|
+
const startTagPattern = /<script[^>]*>/gi;
|
|
1712
|
+
for (const match of content.matchAll(startTagPattern)) {
|
|
1713
|
+
const bodyStart = match.index + match[0].length;
|
|
1714
|
+
const bodyEnd = findScriptClose(content, bodyStart);
|
|
1715
|
+
if (bodyEnd !== -1) {
|
|
1716
|
+
foundAny = true;
|
|
1717
|
+
for (let i = bodyStart; i < bodyEnd; i += 1) {
|
|
1718
|
+
masked[i] = content[i];
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
if (!foundAny) return '';
|
|
1723
|
+
return masked.join('');
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function computeLineStarts(content) {
|
|
1727
|
+
const starts = [0];
|
|
1728
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
1729
|
+
if (content[index] === '\n') {
|
|
1730
|
+
starts.push(index + 1);
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
return starts;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function lineNumberForIndex(lineStarts, index) {
|
|
1737
|
+
let low = 0;
|
|
1738
|
+
let high = lineStarts.length - 1;
|
|
1739
|
+
while (low < high) {
|
|
1740
|
+
const mid = Math.ceil((low + high) / 2);
|
|
1741
|
+
if (lineStarts[mid] <= index) {
|
|
1742
|
+
low = mid;
|
|
1743
|
+
} else {
|
|
1744
|
+
high = mid - 1;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
return low + 1;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Display string only, not a parse: scan forward from the start of the matched line to the
|
|
1751
|
+
// `)` that closes the parameter list, or to end-of-line when the line opens no paren. Depth
|
|
1752
|
+
// is tracked rather than stopping at the first `)`, otherwise a default-valued parameter
|
|
1753
|
+
// truncates mid-expression (`function f({ root = process.cwd() } = {})` would cut at the
|
|
1754
|
+
// `)` of `process.cwd()`). The scan is capped at 400 chars so a malformed file cannot walk
|
|
1755
|
+
// the whole buffer.
|
|
1756
|
+
function computeSignature(content, lineStartIndex) {
|
|
1757
|
+
const boundedEnd = Math.min(content.length, lineStartIndex + 400);
|
|
1758
|
+
let sliceEnd = boundedEnd;
|
|
1759
|
+
let depth = 0;
|
|
1760
|
+
let sawOpenParen = false;
|
|
1761
|
+
for (let index = lineStartIndex; index < boundedEnd; index += 1) {
|
|
1762
|
+
const char = content[index];
|
|
1763
|
+
if (char === '(') {
|
|
1764
|
+
depth += 1;
|
|
1765
|
+
sawOpenParen = true;
|
|
1766
|
+
} else if (char === ')') {
|
|
1767
|
+
depth -= 1;
|
|
1768
|
+
if (sawOpenParen && depth <= 0) {
|
|
1769
|
+
sliceEnd = index + 1;
|
|
1770
|
+
break;
|
|
1771
|
+
}
|
|
1772
|
+
} else if (char === '\n' && !sawOpenParen) {
|
|
1773
|
+
sliceEnd = index;
|
|
1774
|
+
break;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Keep a trailing `=>` so an arrow function stays distinguishable from an ordinary
|
|
1779
|
+
// parenthesised expression. getFileOutline relies on that to filter outline noise.
|
|
1780
|
+
const arrowMatch = /^\s*=>/.exec(content.slice(sliceEnd, Math.min(content.length, sliceEnd + 8)));
|
|
1781
|
+
if (arrowMatch) {
|
|
1782
|
+
sliceEnd += arrowMatch[0].length;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
const collapsed = content.slice(lineStartIndex, sliceEnd).replace(/\s+/g, ' ').trim();
|
|
1786
|
+
if (collapsed.length <= 200) {
|
|
1787
|
+
return collapsed;
|
|
1788
|
+
}
|
|
1789
|
+
return `${collapsed.slice(0, 199)}…`;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
export function extractSymbols(filePath, content) {
|
|
1599
1793
|
const out = [];
|
|
1600
|
-
const
|
|
1794
|
+
const lineStarts = computeLineStarts(content);
|
|
1795
|
+
const addSymbol = (name, type, matchIndex = null) => {
|
|
1601
1796
|
if (!name) return;
|
|
1602
|
-
|
|
1797
|
+
if (matchIndex === null) {
|
|
1798
|
+
out.push({ name, type, filePath, line: null, signature: null });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const line = lineNumberForIndex(lineStarts, matchIndex);
|
|
1802
|
+
const signature = computeSignature(content, lineStarts[line - 1]);
|
|
1803
|
+
out.push({ name, type, filePath, line, signature });
|
|
1603
1804
|
};
|
|
1604
1805
|
const addMatches = (regex, type, idx = 1) => {
|
|
1605
1806
|
for (const match of content.matchAll(regex)) {
|
|
1606
1807
|
const name = match[idx];
|
|
1607
1808
|
if (!name) continue;
|
|
1608
|
-
addSymbol(name, type);
|
|
1809
|
+
addSymbol(name, type, match.index);
|
|
1609
1810
|
}
|
|
1610
1811
|
};
|
|
1611
1812
|
|
|
@@ -1628,7 +1829,7 @@ function extractSymbols(filePath, content) {
|
|
|
1628
1829
|
for (const part of (match[1] ?? '').split(',').map((x) => x.trim()).filter(Boolean)) {
|
|
1629
1830
|
const parsed = part.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
|
|
1630
1831
|
if (!parsed) continue;
|
|
1631
|
-
addSymbol(parsed[2] ?? parsed[1], 'named-export');
|
|
1832
|
+
addSymbol(parsed[2] ?? parsed[1], 'named-export', match.index);
|
|
1632
1833
|
}
|
|
1633
1834
|
}
|
|
1634
1835
|
|
|
@@ -11,14 +11,19 @@ import {
|
|
|
11
11
|
const {
|
|
12
12
|
queryCodeIndex,
|
|
13
13
|
buildCodeIndex,
|
|
14
|
+
getFileOutline,
|
|
14
15
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
15
16
|
getIndexArtifactGeneratedAt,
|
|
16
17
|
} = indexCore;
|
|
17
18
|
|
|
19
|
+
const OUTLINE_TOP_FILES_LIMIT = 3;
|
|
20
|
+
const OUTLINE_SYMBOLS_LIMIT = 12;
|
|
21
|
+
|
|
18
22
|
const args = process.argv.slice(2);
|
|
19
23
|
const rootDir = getRootDir(args);
|
|
20
24
|
const limitArg = readFlagValue(args, '--limit');
|
|
21
25
|
const limit = Number.parseInt(limitArg ?? '5', 10);
|
|
26
|
+
const showOutline = !args.includes('--no-outline');
|
|
22
27
|
const query = collectPositionalArgs(args, ['--root', '--limit']);
|
|
23
28
|
|
|
24
29
|
if (!query) {
|
|
@@ -32,6 +37,7 @@ if (!query) {
|
|
|
32
37
|
indexGeneratedAtMs,
|
|
33
38
|
query,
|
|
34
39
|
limit: effectiveLimit,
|
|
40
|
+
showOutline,
|
|
35
41
|
});
|
|
36
42
|
const cached = await readRecentCacheEntry(cachePath, requestKey, {
|
|
37
43
|
maxEntries: DEFAULT_RECENT_CACHE_MAX_ENTRIES,
|
|
@@ -66,10 +72,39 @@ if (!query) {
|
|
|
66
72
|
if ((item.reasons ?? []).length > 0) {
|
|
67
73
|
console.log(` reasons: ${item.reasons.join(', ')}`);
|
|
68
74
|
}
|
|
75
|
+
if (showOutline && idx < OUTLINE_TOP_FILES_LIMIT) {
|
|
76
|
+
await printOutlineForFile(rootDir, item.filePath);
|
|
77
|
+
}
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
async function printOutlineForFile(root, filePath) {
|
|
83
|
+
if (typeof getFileOutline !== 'function') {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const outline = await getFileOutline({ rootDir: root, filePath, limit: Infinity });
|
|
87
|
+
if (outline.length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const shown = outline.slice(0, OUTLINE_SYMBOLS_LIMIT);
|
|
92
|
+
const remaining = outline.length - shown.length;
|
|
93
|
+
const labels = shown.map((item) => String(item.line));
|
|
94
|
+
if (remaining > 0) {
|
|
95
|
+
labels.push(`+${remaining}`);
|
|
96
|
+
}
|
|
97
|
+
const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
|
|
98
|
+
|
|
99
|
+
console.log(' outline:');
|
|
100
|
+
for (const item of shown) {
|
|
101
|
+
console.log(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
|
|
102
|
+
}
|
|
103
|
+
if (remaining > 0) {
|
|
104
|
+
console.log(` ${`+${remaining}`.padStart(labelWidth)} more symbols`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
73
108
|
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
74
109
|
const lastRefreshMs = await getIndexArtifactGeneratedAt({ rootDir });
|
|
75
110
|
const stale = lastRefreshMs === null
|
|
@@ -102,11 +137,13 @@ function buildQueryRequestKey({
|
|
|
102
137
|
indexGeneratedAtMs = null,
|
|
103
138
|
query = '',
|
|
104
139
|
limit = 5,
|
|
140
|
+
showOutline = true,
|
|
105
141
|
} = {}) {
|
|
106
|
-
return buildCompactMachineKey('query-
|
|
142
|
+
return buildCompactMachineKey('query-v2', {
|
|
107
143
|
indexGeneratedAtMs,
|
|
108
144
|
query: String(query || '').trim(),
|
|
109
145
|
limit: Number(limit),
|
|
146
|
+
showOutline: Boolean(showOutline),
|
|
110
147
|
});
|
|
111
148
|
}
|
|
112
149
|
|
|
@@ -11,16 +11,20 @@ import {
|
|
|
11
11
|
const {
|
|
12
12
|
resolveContext,
|
|
13
13
|
buildCodeIndex,
|
|
14
|
+
getFileOutline,
|
|
14
15
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
15
16
|
getIndexArtifactGeneratedAt,
|
|
16
17
|
} = indexCore;
|
|
17
18
|
const CONTEXT_DISPLAY_LIMIT = 2;
|
|
18
19
|
const CONTEXT_REASON_LIMIT = 2;
|
|
20
|
+
const OUTLINE_TOP_FILES_LIMIT = 3;
|
|
21
|
+
const OUTLINE_SYMBOLS_LIMIT = 12;
|
|
19
22
|
|
|
20
23
|
const args = process.argv.slice(2);
|
|
21
24
|
const rootDir = getRootDir(args);
|
|
22
25
|
const targetFile = readFlagValue(args, '--target');
|
|
23
26
|
const taskType = readFlagValue(args, '--type');
|
|
27
|
+
const showOutline = !args.includes('--no-outline');
|
|
24
28
|
const intent = collectPositionalArgs(args, ['--root', '--target', '--type']);
|
|
25
29
|
const effectiveIntent = deriveHelperIntent({ intent, targetFile });
|
|
26
30
|
|
|
@@ -57,7 +61,7 @@ if (!intent && !targetFile) {
|
|
|
57
61
|
});
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
printContextResult(result);
|
|
64
|
+
await printContextResult(result, { rootDir, showOutline });
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
async function ensureFreshIndex({ rootDir, logPrefix }) {
|
|
@@ -217,17 +221,48 @@ function unique(values = []) {
|
|
|
217
221
|
return [...new Set(values.filter(Boolean))];
|
|
218
222
|
}
|
|
219
223
|
|
|
220
|
-
function printContextResult(result) {
|
|
224
|
+
async function printContextResult(result, { rootDir, showOutline } = {}) {
|
|
221
225
|
console.log('[ukit:context]');
|
|
222
226
|
console.log(`taskType: ${result.taskType}`);
|
|
223
227
|
console.log(`budget: ${result.contextBudget.minFiles}-${result.contextBudget.maxFiles} files`);
|
|
224
228
|
printContextGroup('primaryTargets', result.primaryTargets, result.explanations?.primaryTargets);
|
|
229
|
+
if (showOutline) {
|
|
230
|
+
for (const filePath of (result.primaryTargets ?? []).slice(0, OUTLINE_TOP_FILES_LIMIT)) {
|
|
231
|
+
await printOutlineForFile(rootDir, filePath);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
225
234
|
printContextGroup('sharedAbstractions', result.sharedAbstractions, result.explanations?.sharedAbstractions);
|
|
226
235
|
printContextGroup('analogFiles', result.analogFiles, result.explanations?.analogFiles);
|
|
227
236
|
printContextGroup('relatedTests', result.relatedTests, result.explanations?.relatedTests);
|
|
228
237
|
printContextGroup('styleFiles', result.styleFiles, result.explanations?.styleFiles);
|
|
229
238
|
}
|
|
230
239
|
|
|
240
|
+
async function printOutlineForFile(root, filePath) {
|
|
241
|
+
if (typeof getFileOutline !== 'function') {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const outline = await getFileOutline({ rootDir: root, filePath, limit: Infinity });
|
|
245
|
+
if (outline.length === 0) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const shown = outline.slice(0, OUTLINE_SYMBOLS_LIMIT);
|
|
250
|
+
const remaining = outline.length - shown.length;
|
|
251
|
+
const labels = shown.map((item) => String(item.line));
|
|
252
|
+
if (remaining > 0) {
|
|
253
|
+
labels.push(`+${remaining}`);
|
|
254
|
+
}
|
|
255
|
+
const labelWidth = Math.max(...labels.map((label) => label.length)) + 1;
|
|
256
|
+
|
|
257
|
+
console.log(' outline:');
|
|
258
|
+
for (const item of shown) {
|
|
259
|
+
console.log(` ${String(item.line).padStart(labelWidth)}: ${item.signature ?? item.name}`);
|
|
260
|
+
}
|
|
261
|
+
if (remaining > 0) {
|
|
262
|
+
console.log(` ${`+${remaining}`.padStart(labelWidth)} more symbols`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
231
266
|
function printContextGroup(label, filePaths = [], explanations = []) {
|
|
232
267
|
console.log(`${label}: ${formatCompactDisplayList(filePaths)}`);
|
|
233
268
|
|
|
@@ -434,7 +434,7 @@ function computeEstimatedTotalTokens({
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
export function buildCompactThresholds(config = {}) {
|
|
437
|
-
const softThreshold = Math.max(1, finiteNumber(config?.compact?.tokenThreshold,
|
|
437
|
+
const softThreshold = Math.max(1, finiteNumber(config?.compact?.tokenThreshold, 150_000));
|
|
438
438
|
const hardThreshold = Math.max(softThreshold + 1, Math.round(softThreshold * 1.6));
|
|
439
439
|
const baselineTokens = Math.max(120, Math.min(18_000, Math.round(softThreshold * 0.18)));
|
|
440
440
|
// Deliberately NOT coupled to hardThreshold: this is an absolute ceiling, so raising
|
|
@@ -442,13 +442,15 @@ export function buildCompactThresholds(config = {}) {
|
|
|
442
442
|
//
|
|
443
443
|
// Must stay BELOW the model's real context window or the cap is unreachable: the API
|
|
444
444
|
// rejects the request with "input exceeds the context window" long before an estimate
|
|
445
|
-
// climbing toward a higher number ever trips this.
|
|
446
|
-
//
|
|
447
|
-
// model this default sits ABOVE the window, so the gate can never fire and is
|
|
448
|
-
// in exactly the situation it exists to prevent — set compact.hardCapTokens to
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
|
|
445
|
+
// climbing toward a higher number ever trips this. 500_000 is half of a 1M window, which
|
|
446
|
+
// leaves ample headroom for the response plus the estimator's own undercount. On any
|
|
447
|
+
// smaller model this default sits ABOVE the window, so the gate can never fire and is
|
|
448
|
+
// dead code in exactly the situation it exists to prevent — set compact.hardCapTokens to
|
|
449
|
+
// 100_000 (200k model) or 128_000 (256k model) for those. Both
|
|
450
|
+
// env.CLAUDE_CODE_AUTO_COMPACT_WINDOW and omp's compaction.thresholdTokens are rendered
|
|
451
|
+
// from this number at install time (70% of it), so the client auto-compacts before the
|
|
452
|
+
// gate blocks tools without anyone hand-syncing a second value.
|
|
453
|
+
const hardCapTokens = Math.max(1, finiteNumber(config?.compact?.hardCapTokens, 500_000));
|
|
452
454
|
|
|
453
455
|
return {
|
|
454
456
|
softThreshold,
|
|
@@ -78,8 +78,11 @@ memory:
|
|
|
78
78
|
backend: off
|
|
79
79
|
|
|
80
80
|
# Auto-compact must trigger BEFORE context-hardcap-gate.sh starts blocking tool
|
|
81
|
-
# calls (compact.hardCapTokens, default
|
|
82
|
-
# env.CLAUDE_CODE_AUTO_COMPACT_WINDOW
|
|
81
|
+
# calls (compact.hardCapTokens, default 500000). Rendered at install time from the
|
|
82
|
+
# same derived number as env.CLAUDE_CODE_AUTO_COMPACT_WINDOW in
|
|
83
|
+
# templates/.claude/settings.json (70% of hardCapTokens), so omp and Claude Code
|
|
84
|
+
# cannot drift apart and retuning both is a single edit to
|
|
85
|
+
# templates/ukit/storage/config.json.
|
|
83
86
|
#
|
|
84
87
|
# Key VERIFIED against omp v17.4.2 (2026-08-22): `compaction.thresholdTokens` is a
|
|
85
88
|
# fixed token limit for context maintenance and overrides the percentage threshold
|
|
@@ -88,4 +91,4 @@ memory:
|
|
|
88
91
|
# omp rejects it outright (`omp config get compact.autoCompactWindow` → "Unknown
|
|
89
92
|
# setting"), so auto-compact never moved off omp's default. See PLAN.md §3 D13.
|
|
90
93
|
compaction:
|
|
91
|
-
thresholdTokens:
|
|
94
|
+
thresholdTokens: {{compact.autoCompactWindow}}
|
package/templates/AGENTS.md
CHANGED
|
@@ -37,6 +37,11 @@ For any task that needs code context:
|
|
|
37
37
|
3. For bug signatures:
|
|
38
38
|
- `node .claude/ukit/index/triage.mjs "<error signature>"`
|
|
39
39
|
4. Open only the **top 1-3 suspect files first**, then widen if needed.
|
|
40
|
+
`query-index` and `resolve-context` print an `outline:` block (`line: signature`) for the
|
|
41
|
+
top suspects. Use it to jump straight to the relevant region with
|
|
42
|
+
`Read(file, offset=<line>)` instead of reading the whole file.
|
|
43
|
+
The outline locates code; it does not describe behaviour. **Any code you are about to
|
|
44
|
+
change must still be read.**
|
|
40
45
|
5. For analog/reuse patterns, check if `resolve-context` returns related existing patterns.
|
|
41
46
|
|
|
42
47
|
For clearly non-code specialist lanes (docs-only, status, task queue), skip the source-code index.
|
package/templates/CLAUDE.md
CHANGED
|
@@ -37,6 +37,11 @@ For any task that needs code context:
|
|
|
37
37
|
3. For bug signatures:
|
|
38
38
|
- `node .claude/ukit/index/triage.mjs "<error signature>"`
|
|
39
39
|
4. Open only the **top 1-3 suspect files first**, then widen if needed.
|
|
40
|
+
`query-index` and `resolve-context` print an `outline:` block (`line: signature`) for the
|
|
41
|
+
top suspects. Use it to jump straight to the relevant region with
|
|
42
|
+
`Read(file, offset=<line>)` instead of reading the whole file.
|
|
43
|
+
The outline locates code; it does not describe behaviour. **Any code you are about to
|
|
44
|
+
change must still be read.**
|
|
40
45
|
5. For analog/reuse patterns, check if `resolve-context` returns related existing patterns.
|
|
41
46
|
|
|
42
47
|
For clearly non-code specialist lanes (docs-only, status, task queue), skip the source-code index.
|
|
@@ -79,8 +79,8 @@ Next: <bước kế tiếp chính xác>
|
|
|
79
79
|
- Subagent ghi **full log vào task file trên đĩa**, chỉ trả về orchestrator ≤10 dòng (executor) / ≤6 dòng (reviewer). Paste log ngược lại orchestrator là nguyên nhân số 1 làm run chết vì hết context.
|
|
80
80
|
- Hết mỗi wave: commit, ghi cursor, **collapse** wave đó còn 1 dòng/task trong bộ nhớ làm việc, rồi chạy tiếp.
|
|
81
81
|
- Yêu cầu `/compact` **chỉ** được đặt ở cuối command, giữa 2 cycle. Giữa cycle thì tuyệt đối không — state đã nằm hết ở git + `INDEX.md` + `RUN.md` nên compact ở ranh giới cycle không mất gì.
|
|
82
|
-
- Vượt `compact.hardCapTokens` (mặc định
|
|
83
|
-
- Không hook nào gọi được `/compact` — đó là lệnh client-only. Nhưng từ 2.1.3, settings mặc định đặt `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW =
|
|
82
|
+
- Vượt `compact.hardCapTokens` (mặc định 500k = 50% của context window 1M) mà `RUN.md` còn run dở: `context-hardcap-gate` cho thêm `compact.hardCapGraceCalls` (mặc định 10) tool call rồi mới chặn cứng. **Grace đó chỉ để hạ cánh** — hoàn tất edit đang dở, commit, ghi cursor, push. Không mở task mới, không đọc thêm file, không spawn agent. Hết grace là chặn thật; budget chỉ reset khi ước lượng token thực sự giảm (có compact thật), không reset theo wave.
|
|
83
|
+
- Không hook nào gọi được `/compact` — đó là lệnh client-only. Nhưng từ 2.1.3, settings mặc định đặt `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW` = 70% của `hardCapTokens` (mặc định 350000 < 500k), render lúc install, nên **client tự auto-compact trước khi gate chặn**. Đường thường: auto-compact chạy → `handoff-resume.sh` replay cursor → chạy tiếp, không cần người gõ gì. Grace window ở trên chỉ còn là lưới an toàn.
|
|
84
84
|
- Sửa một trong hai số đó thì phải giữ `autoCompactWindow < hardCapTokens`. Đảo thứ tự là deadlock: gate chặn tool trước → transcript ngừng lớn → ngưỡng auto-compact không bao giờ tới. `tests/core/autoCompactWindow.test.js` khóa bất biến này.
|
|
85
85
|
|
|
86
86
|
### Git
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
},
|
|
9
9
|
"compact": {
|
|
10
10
|
"enabled": true,
|
|
11
|
-
"tokenThreshold":
|
|
12
|
-
"hardCapTokens":
|
|
11
|
+
"tokenThreshold": 150000,
|
|
12
|
+
"hardCapTokens": 500000,
|
|
13
|
+
"autoCompactWindowRatio": 0.7,
|
|
13
14
|
"hardCapBlock": true,
|
|
14
15
|
"hardCapGraceCalls": 10,
|
|
15
16
|
"contextRotDetection": true,
|
|
@@ -394,7 +395,8 @@
|
|
|
394
395
|
"compact": {
|
|
395
396
|
"enabled": "Bật/tắt toàn bộ helper compact của UKit.",
|
|
396
397
|
"tokenThreshold": "Ngưỡng token chung cho runtime compact dùng chung.",
|
|
397
|
-
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định
|
|
398
|
+
"hardCapTokens": "Ngưỡng cứng tuyệt đối (mặc định 500000 token ước lượng = 50% của context window 1M). Chạm/vượt ngưỡng này thì context coi như quá dài — không phải gợi ý nữa, là bắt buộc. PHẢI thấp hơn context window thật của model, nếu không API sẽ báo lỗi vượt context trước khi gate kịp chặn — model 200k thì hạ xuống 100000, model 256k thì 128000. Cả env.CLAUDE_CODE_AUTO_COMPACT_WINDOW (.claude/settings.json) lẫn compaction.thresholdTokens (.omp/config.yml) đều được SUY RA từ số này khi chạy ukit install, nên chỉ cần sửa ở đây rồi cài lại là auto-compact của cả Claude Code và omp đổi theo.",
|
|
399
|
+
"autoCompactWindowRatio": "Auto-compact chạy ở bao nhiêu phần của hardCapTokens (mặc định 0.7 → 350000). Phải < 1 để client tự compact TRƯỚC khi context-hardcap-gate chặn tool; số càng nhỏ thì compact càng sớm và càng nhiều đệm an toàn.",
|
|
398
400
|
"hardCapBlock": "Nếu true, hook context-hardcap-gate chặn cứng Edit/Write/Bash (exit 2) khi vượt hardCapTokens, cho tới khi có compact thật (PreCompact) reset lại bộ đếm.",
|
|
399
401
|
"hardCapGraceCalls": "Số tool call được phép chạy tiếp sau khi vượt hardCapTokens KHI docs/AI_HANDOFF/RUN.md còn run dở (mặc định 10). Dùng để run kịp commit + ghi cursor + push rồi mới bị chặn, thay vì chết giữa lúc đang Edit. Hết grace là chặn cứng như cũ. Budget tính theo mỗi đợt vượt cap, chỉ reset khi ước lượng token thật sự giảm (có compact thật) — không reset theo wave.",
|
|
400
402
|
"contextRotDetection": "Phát hiện context quá dài/dễ mục để giữ lại state quan trọng trước khi AI nhớ sai.",
|