peaks-loop 4.0.35 → 4.0.36
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 +16 -0
- package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
- package/dist/cli/commands/code-runtime-commands.js +57 -2
- package/dist/cli/commands/core/doctor-command.d.ts +8 -0
- package/dist/cli/commands/core/doctor-command.js +44 -2
- package/dist/cli/commands/core/memory-command.js +5 -1
- package/dist/cli/commands/dispatch-commands.js +15 -3
- package/dist/cli/commands/dispatch-from-dag.js +17 -0
- package/dist/cli/commands/memory-commands.d.ts +24 -0
- package/dist/cli/commands/memory-commands.js +77 -10
- package/dist/cli/commands/request-commands.d.ts +8 -0
- package/dist/cli/commands/request-commands.js +23 -2
- package/dist/cli/commands/sub-agent-commands.js +2 -0
- package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
- package/dist/cli/commands/wave-plan-commands.js +93 -0
- package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
- package/dist/services/context/build-dispatch-system-prompt.js +132 -17
- package/dist/services/context/context-audit.d.ts +100 -0
- package/dist/services/context/context-audit.js +322 -0
- package/dist/services/context/summary-view.d.ts +54 -0
- package/dist/services/context/summary-view.js +114 -0
- package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
- package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
- package/dist/services/dispatch/session-capsule.d.ts +23 -0
- package/dist/services/dispatch/session-capsule.js +56 -0
- package/dist/services/dispatch/slice-dag.d.ts +9 -0
- package/dist/services/dispatch/slice-dag.js +9 -1
- package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
- package/dist/services/dispatch/test-tool-detection.js +14 -13
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
- package/dist/services/ide/ide-types.d.ts +15 -0
- package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +5 -0
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +55 -5
- package/package.json +5 -5
- package/skills/bee/peaks-qa/SKILL.md +2 -0
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
- package/skills/bee/peaks-rd/SKILL.md +2 -0
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
- package/skills/bee/peaks-txt/SKILL.md +2 -0
- package/skills/bee/peaks-ui/SKILL.md +2 -0
- package/skills/peaks-code/SKILL.md +8 -0
- package/skills/peaks-code/references/context-governance.md +29 -0
- package/skills/peaks-doctor/SKILL.md +2 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `peaks code context-audit` — what actually fills the orchestrator's window.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-09-10-context-audit-and-discipline (Slice A).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: `peaks code context-now` reports a RATIO only. Nothing
|
|
7
|
+
* reported WHAT occupies the window, so the same 40K-token mistake (dumping a
|
|
8
|
+
* full `peaks memory reindex --json` array four times in one session) was
|
|
9
|
+
* invisible until the window was 68% gone. The IDE transcript already holds
|
|
10
|
+
* per-message tool results, so the breakdown is derivable locally, with zero
|
|
11
|
+
* tokens spent asking a model.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
* - READ-ONLY. The transcript is never modified.
|
|
15
|
+
* - FAIL-SOFT. A missing / oversized / corrupt transcript yields
|
|
16
|
+
* `available: false` plus a machine-readable `reason`. Never throws,
|
|
17
|
+
* never blocks a workflow, never exits non-zero on its own.
|
|
18
|
+
* - NO CONTENT. The envelope carries tool names, short command/path keys
|
|
19
|
+
* and byte counts — never the tool result text itself (dumping it would
|
|
20
|
+
* re-create the very problem this command measures).
|
|
21
|
+
* - BOUNDED MEMORY. The transcript can be tens of MB; it is streamed in
|
|
22
|
+
* fixed-size chunks with a carried partial line, never read whole.
|
|
23
|
+
*
|
|
24
|
+
* Grouping key = `(tool name, short input key)`. The key is a *stable
|
|
25
|
+
* summary* of the tool input — the Bash command line, the file path tail, the
|
|
26
|
+
* grep pattern — so "4 × the same 40KB reindex dump" collapses into ONE row
|
|
27
|
+
* with `count: 4` instead of four anonymous entries.
|
|
28
|
+
*/
|
|
29
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
30
|
+
import { getAdapter } from '../ide/ide-registry.js';
|
|
31
|
+
import { detectIdeFromEnv } from './ide-detect.js';
|
|
32
|
+
/** Default number of top entries emitted. */
|
|
33
|
+
export const CONTEXT_AUDIT_DEFAULT_TOP = 15;
|
|
34
|
+
/** Hard ceiling for `--top` — the envelope must stay small by construction. */
|
|
35
|
+
export const CONTEXT_AUDIT_MAX_TOP = 100;
|
|
36
|
+
/** Transcripts larger than this are reported `available:false` (fail-soft). */
|
|
37
|
+
export const CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
|
|
38
|
+
/** Streaming chunk size (bounded memory on multi-MB transcripts). */
|
|
39
|
+
const SCAN_CHUNK_BYTES = 1024 * 1024;
|
|
40
|
+
/** Max characters kept in a group key — keys are labels, not payloads. */
|
|
41
|
+
const KEY_MAX_CHARS = 100;
|
|
42
|
+
/** Clamp a caller-supplied `--top` into the documented range. */
|
|
43
|
+
export function normalizeTopN(value) {
|
|
44
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : CONTEXT_AUDIT_DEFAULT_TOP;
|
|
45
|
+
if (n < 1)
|
|
46
|
+
return CONTEXT_AUDIT_DEFAULT_TOP;
|
|
47
|
+
return Math.min(n, CONTEXT_AUDIT_MAX_TOP);
|
|
48
|
+
}
|
|
49
|
+
function clip(text, max) {
|
|
50
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
51
|
+
return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`;
|
|
52
|
+
}
|
|
53
|
+
/** Last `n` path segments — keeps `Read` / `Edit` keys short but identifiable. */
|
|
54
|
+
function tailPath(value, n) {
|
|
55
|
+
const parts = value.split(/[\\/]/).filter((p) => p.length > 0);
|
|
56
|
+
return parts.slice(Math.max(0, parts.length - n)).join('/');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build the stable group key for one tool call. Unknown tools fall back to a
|
|
60
|
+
* clipped JSON rendering of their input so the group is still recognizable.
|
|
61
|
+
*/
|
|
62
|
+
export function contextAuditKey(tool, input) {
|
|
63
|
+
const read = (v) => (typeof v === 'string' ? v : '');
|
|
64
|
+
if (typeof input !== 'object' || input === null)
|
|
65
|
+
return '';
|
|
66
|
+
const i = input;
|
|
67
|
+
switch (tool) {
|
|
68
|
+
case 'Bash':
|
|
69
|
+
return clip(read(i.command), KEY_MAX_CHARS);
|
|
70
|
+
case 'Read':
|
|
71
|
+
case 'Write':
|
|
72
|
+
case 'Edit':
|
|
73
|
+
case 'NotebookEdit':
|
|
74
|
+
return clip(tailPath(read(i.file_path), 2), KEY_MAX_CHARS);
|
|
75
|
+
case 'Grep':
|
|
76
|
+
case 'Glob':
|
|
77
|
+
return clip(`${read(i.pattern)} @ ${read(i.path) || '.'}`, KEY_MAX_CHARS);
|
|
78
|
+
case 'Task':
|
|
79
|
+
case 'Agent':
|
|
80
|
+
return clip(read(i.description) || read(i.subagent_type), 60);
|
|
81
|
+
default: {
|
|
82
|
+
let json;
|
|
83
|
+
try {
|
|
84
|
+
json = JSON.stringify(input) ?? '';
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
json = '';
|
|
88
|
+
}
|
|
89
|
+
return clip(json, 80);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** UTF-8 size of a `tool_result.content` payload (string | array | object). */
|
|
94
|
+
function toolResultBytes(content) {
|
|
95
|
+
if (typeof content === 'string')
|
|
96
|
+
return Buffer.byteLength(content, 'utf8');
|
|
97
|
+
if (Array.isArray(content)) {
|
|
98
|
+
let total = 0;
|
|
99
|
+
for (const part of content) {
|
|
100
|
+
if (typeof part === 'string') {
|
|
101
|
+
total += Buffer.byteLength(part, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
else if (typeof part === 'object' && part !== null) {
|
|
104
|
+
const text = part.text;
|
|
105
|
+
total += typeof text === 'string'
|
|
106
|
+
? Buffer.byteLength(text, 'utf8')
|
|
107
|
+
: Buffer.byteLength(JSON.stringify(part) ?? '', 'utf8');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return total;
|
|
111
|
+
}
|
|
112
|
+
if (content === undefined || content === null)
|
|
113
|
+
return 0;
|
|
114
|
+
try {
|
|
115
|
+
return Buffer.byteLength(JSON.stringify(content) ?? '', 'utf8');
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function emptyResult(partial) {
|
|
122
|
+
return {
|
|
123
|
+
available: false,
|
|
124
|
+
reason: null,
|
|
125
|
+
transcriptPath: null,
|
|
126
|
+
totalBytes: 0,
|
|
127
|
+
entryCount: 0,
|
|
128
|
+
groupCount: 0,
|
|
129
|
+
topN: CONTEXT_AUDIT_DEFAULT_TOP,
|
|
130
|
+
entries: [],
|
|
131
|
+
...partial,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Stream the transcript once, folding every tool result into its
|
|
136
|
+
* `(tool, key)` group. Pure bookkeeping — no content is retained.
|
|
137
|
+
*/
|
|
138
|
+
function scanTranscript(filePath, topN) {
|
|
139
|
+
const toolUses = new Map();
|
|
140
|
+
const groups = new Map();
|
|
141
|
+
let totalBytes = 0;
|
|
142
|
+
let entryCount = 0;
|
|
143
|
+
const fd = openSync(filePath, 'r');
|
|
144
|
+
try {
|
|
145
|
+
const size = statSync(filePath).size;
|
|
146
|
+
let position = 0;
|
|
147
|
+
let carry = '';
|
|
148
|
+
while (position < size) {
|
|
149
|
+
const readLen = Math.min(SCAN_CHUNK_BYTES, size - position);
|
|
150
|
+
const buf = Buffer.alloc(readLen);
|
|
151
|
+
const bytesRead = readSync(fd, buf, 0, readLen, position);
|
|
152
|
+
if (bytesRead <= 0)
|
|
153
|
+
break;
|
|
154
|
+
position += bytesRead;
|
|
155
|
+
const lines = (carry + buf.toString('utf8', 0, bytesRead)).split('\n');
|
|
156
|
+
// The last element is a partial line (or the trailing empty string).
|
|
157
|
+
carry = lines.pop() ?? '';
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
if (line.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
const folded = foldLine(line, toolUses, groups);
|
|
162
|
+
totalBytes += folded.bytes;
|
|
163
|
+
entryCount += folded.count;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (carry.length > 0) {
|
|
167
|
+
const folded = foldLine(carry, toolUses, groups);
|
|
168
|
+
totalBytes += folded.bytes;
|
|
169
|
+
entryCount += folded.count;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
try {
|
|
174
|
+
closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* best-effort close */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const entries = [...groups.values()]
|
|
181
|
+
.map((g) => ({
|
|
182
|
+
tool: g.tool,
|
|
183
|
+
key: g.key,
|
|
184
|
+
bytes: g.bytes,
|
|
185
|
+
// Percentage in [0, 100], one decimal — see ContextAuditEntry.pctOfTotal.
|
|
186
|
+
pctOfTotal: totalBytes > 0 ? Math.round((g.bytes / totalBytes) * 1000) / 10 : 0,
|
|
187
|
+
count: g.count,
|
|
188
|
+
}))
|
|
189
|
+
.sort((a, b) => b.bytes - a.bytes || a.tool.localeCompare(b.tool) || a.key.localeCompare(b.key));
|
|
190
|
+
return {
|
|
191
|
+
available: true,
|
|
192
|
+
reason: null,
|
|
193
|
+
transcriptPath: filePath,
|
|
194
|
+
totalBytes,
|
|
195
|
+
entryCount,
|
|
196
|
+
groupCount: entries.length,
|
|
197
|
+
topN,
|
|
198
|
+
entries: entries.slice(0, topN),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Fold ONE jsonl line into the running state. Returns the byte/count delta
|
|
203
|
+
* contributed by this line. Corrupt / non-JSON lines are skipped silently —
|
|
204
|
+
* a truncated tail must not fail the audit.
|
|
205
|
+
*/
|
|
206
|
+
function foldLine(line, toolUses, groups) {
|
|
207
|
+
let parsed;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(line);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { bytes: 0, count: 0 };
|
|
213
|
+
}
|
|
214
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
215
|
+
return { bytes: 0, count: 0 };
|
|
216
|
+
const record = parsed;
|
|
217
|
+
const message = record.message;
|
|
218
|
+
if (typeof message !== 'object' || message === null)
|
|
219
|
+
return { bytes: 0, count: 0 };
|
|
220
|
+
const content = message.content;
|
|
221
|
+
if (!Array.isArray(content))
|
|
222
|
+
return { bytes: 0, count: 0 };
|
|
223
|
+
let bytes = 0;
|
|
224
|
+
let count = 0;
|
|
225
|
+
for (const part of content) {
|
|
226
|
+
if (typeof part !== 'object' || part === null)
|
|
227
|
+
continue;
|
|
228
|
+
const item = part;
|
|
229
|
+
if (item.type === 'tool_use') {
|
|
230
|
+
const id = item.id;
|
|
231
|
+
const name = item.name;
|
|
232
|
+
if (typeof id === 'string' && typeof name === 'string') {
|
|
233
|
+
toolUses.set(id, { name, input: item.input });
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (item.type !== 'tool_result')
|
|
238
|
+
continue;
|
|
239
|
+
const ref = typeof item.tool_use_id === 'string' ? toolUses.get(item.tool_use_id) : undefined;
|
|
240
|
+
const tool = ref?.name ?? 'unknown';
|
|
241
|
+
const key = ref === undefined ? '' : contextAuditKey(tool, ref.input);
|
|
242
|
+
const size = toolResultBytes(item.content);
|
|
243
|
+
const groupKey = `${tool}${key}`;
|
|
244
|
+
const group = groups.get(groupKey);
|
|
245
|
+
if (group === undefined) {
|
|
246
|
+
groups.set(groupKey, { tool, key, bytes: size, count: 1 });
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
group.bytes += size;
|
|
250
|
+
group.count += 1;
|
|
251
|
+
}
|
|
252
|
+
bytes += size;
|
|
253
|
+
count += 1;
|
|
254
|
+
}
|
|
255
|
+
return { bytes, count };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Audit the CURRENT session's transcript. Never throws.
|
|
259
|
+
*
|
|
260
|
+
* Unavailability reasons (all return `available: false`, exit code stays 0):
|
|
261
|
+
* - `no-outer-session-id` — the peaks session has no bound outer id
|
|
262
|
+
* - `transcript-locator-unavailable` — the active IDE adapter does not
|
|
263
|
+
* declare `compact.resolveTranscriptPath`
|
|
264
|
+
* - `transcript-not-found` — the adapter locator returned null
|
|
265
|
+
* - `transcript-too-large` — above `CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES`
|
|
266
|
+
* - `transcript-unreadable`— stat/open failed
|
|
267
|
+
* - `audit-failed` — any unexpected internal error
|
|
268
|
+
*/
|
|
269
|
+
export function auditContext(input = {}) {
|
|
270
|
+
const topN = normalizeTopN(input.topN);
|
|
271
|
+
const explicit = input.transcriptPath;
|
|
272
|
+
let transcriptPath = null;
|
|
273
|
+
if (typeof explicit === 'string' && explicit.length > 0) {
|
|
274
|
+
transcriptPath = explicit;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
const outerSessionId = input.outerSessionId;
|
|
278
|
+
if (typeof outerSessionId !== 'string' || outerSessionId.length === 0) {
|
|
279
|
+
return emptyResult({ reason: 'no-outer-session-id', topN });
|
|
280
|
+
}
|
|
281
|
+
// Vendor-neutral: the adapter owns the on-disk layout. Mirrors
|
|
282
|
+
// `readContextPercent`'s narrowing of the detected kind to a
|
|
283
|
+
// registered adapter id ('unknown' → claude-code default). Both the
|
|
284
|
+
// registry lookup and the locator call are guarded — an unregistered
|
|
285
|
+
// detected id (e.g. an IDE without a peaks adapter yet) or an adapter
|
|
286
|
+
// bug must degrade, never throw.
|
|
287
|
+
let transcriptPathOrNull;
|
|
288
|
+
try {
|
|
289
|
+
const detected = detectIdeFromEnv(input.env ?? process.env);
|
|
290
|
+
const ideId = (detected === 'unknown' ? 'claude-code' : detected);
|
|
291
|
+
const locate = getAdapter(ideId).compact?.resolveTranscriptPath;
|
|
292
|
+
if (locate === undefined) {
|
|
293
|
+
return emptyResult({ reason: 'transcript-locator-unavailable', topN });
|
|
294
|
+
}
|
|
295
|
+
transcriptPathOrNull = locate(outerSessionId);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return emptyResult({ reason: 'transcript-locator-unavailable', topN });
|
|
299
|
+
}
|
|
300
|
+
if (transcriptPathOrNull === null) {
|
|
301
|
+
return emptyResult({ reason: 'transcript-not-found', topN });
|
|
302
|
+
}
|
|
303
|
+
transcriptPath = transcriptPathOrNull;
|
|
304
|
+
}
|
|
305
|
+
const maxBytes = typeof input.maxTranscriptBytes === 'number' && Number.isFinite(input.maxTranscriptBytes) && input.maxTranscriptBytes >= 0
|
|
306
|
+
? input.maxTranscriptBytes
|
|
307
|
+
: CONTEXT_AUDIT_MAX_TRANSCRIPT_BYTES;
|
|
308
|
+
try {
|
|
309
|
+
const size = statSync(transcriptPath).size;
|
|
310
|
+
if (size > maxBytes) {
|
|
311
|
+
return emptyResult({ reason: 'transcript-too-large', transcriptPath, topN });
|
|
312
|
+
}
|
|
313
|
+
return scanTranscript(transcriptPath, topN);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
const code = err.code;
|
|
317
|
+
const reason = code === 'ENOENT' ? 'transcript-not-found'
|
|
318
|
+
: code === 'EACCES' || code === 'EPERM' ? 'transcript-unreadable'
|
|
319
|
+
: 'audit-failed';
|
|
320
|
+
return emptyResult({ reason, transcriptPath, topN });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--summary` — bounded, additive views of large CLI envelopes.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-09-10-context-audit-and-discipline (Slice B, part 1).
|
|
5
|
+
*
|
|
6
|
+
* Rationale (measured, session 2026-09-07-session-245530): dumping a full
|
|
7
|
+
* `peaks memory reindex --json` array four times cost ≈ 160 KB ≈ 40K tokens
|
|
8
|
+
* of orchestrator context — roughly 4% of a 1M window for ONE command
|
|
9
|
+
* repeated. The default envelopes stay exactly as they are (back-compat);
|
|
10
|
+
* `--summary` is an OPT-IN view that keeps counts + names-of-first-N and
|
|
11
|
+
* drops the per-entry bodies, which remain on disk and are re-readable with
|
|
12
|
+
* the full command. No information is destroyed — only the in-context copy
|
|
13
|
+
* shrinks.
|
|
14
|
+
*
|
|
15
|
+
* Byte bound: every summary object is passed through `fitSummaryToBytes`,
|
|
16
|
+
* which shrinks string arrays (longest first) until the JSON serialization is
|
|
17
|
+
* ≤ `SUMMARY_DATA_MAX_BYTES` (2 KB minus a small envelope reserve, so the
|
|
18
|
+
* PRINTED envelope stays ≤ `SUMMARY_MAX_BYTES`). Scalars are never touched,
|
|
19
|
+
* so counts and paths stay exact.
|
|
20
|
+
*/
|
|
21
|
+
/** Hard ceiling for the PRINTED `--summary` envelope, in UTF-8 bytes. */
|
|
22
|
+
export declare const SUMMARY_MAX_BYTES = 2048;
|
|
23
|
+
/**
|
|
24
|
+
* Reserve for the envelope wrapper (`ok`/`command`/`warnings`/`nextActions`/
|
|
25
|
+
* error fields) that the CLI adds around `data`. The builders cap `data` at
|
|
26
|
+
* `SUMMARY_DATA_MAX_BYTES` so the whole printed envelope stays ≤ 2 KB.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SUMMARY_ENVELOPE_RESERVE_BYTES = 512;
|
|
29
|
+
/** Cap applied to the summary `data` object itself (pretty-printed size). */
|
|
30
|
+
export declare const SUMMARY_DATA_MAX_BYTES: number;
|
|
31
|
+
/** Per-name character cap — a name is a label, not a document. */
|
|
32
|
+
export declare const SUMMARY_NAME_MAX_CHARS = 120;
|
|
33
|
+
/** How many names each command asks for before the byte fitter trims. */
|
|
34
|
+
export declare const SUMMARY_INITIAL_NAMES = 40;
|
|
35
|
+
/** A bounded `{count, names}` view: `count` is the true total, `names` a prefix. */
|
|
36
|
+
export interface BoundedNames {
|
|
37
|
+
readonly count: number;
|
|
38
|
+
readonly names: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build a `{count, names}` view. `count` is always the full length; `names`
|
|
42
|
+
* carries the first `SUMMARY_INITIAL_NAMES` (clipped) entries — the byte
|
|
43
|
+
* fitter may trim further.
|
|
44
|
+
*/
|
|
45
|
+
export declare function boundedNames(names: readonly string[]): BoundedNames;
|
|
46
|
+
/**
|
|
47
|
+
* Shrink `data` (in place on a clone) until its JSON form fits `maxBytes`.
|
|
48
|
+
*
|
|
49
|
+
* Algorithm: repeatedly find the LONGEST nested array and drop its last
|
|
50
|
+
* element. Scalars are never modified, so `count` fields stay truthful; the
|
|
51
|
+
* `names` arrays simply show fewer names. Returns the input unchanged when it
|
|
52
|
+
* already fits or when there is no array left to trim.
|
|
53
|
+
*/
|
|
54
|
+
export declare function fitSummaryToBytes<T extends Record<string, unknown>>(data: T, maxBytes?: number): T;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--summary` — bounded, additive views of large CLI envelopes.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-09-10-context-audit-and-discipline (Slice B, part 1).
|
|
5
|
+
*
|
|
6
|
+
* Rationale (measured, session 2026-09-07-session-245530): dumping a full
|
|
7
|
+
* `peaks memory reindex --json` array four times cost ≈ 160 KB ≈ 40K tokens
|
|
8
|
+
* of orchestrator context — roughly 4% of a 1M window for ONE command
|
|
9
|
+
* repeated. The default envelopes stay exactly as they are (back-compat);
|
|
10
|
+
* `--summary` is an OPT-IN view that keeps counts + names-of-first-N and
|
|
11
|
+
* drops the per-entry bodies, which remain on disk and are re-readable with
|
|
12
|
+
* the full command. No information is destroyed — only the in-context copy
|
|
13
|
+
* shrinks.
|
|
14
|
+
*
|
|
15
|
+
* Byte bound: every summary object is passed through `fitSummaryToBytes`,
|
|
16
|
+
* which shrinks string arrays (longest first) until the JSON serialization is
|
|
17
|
+
* ≤ `SUMMARY_DATA_MAX_BYTES` (2 KB minus a small envelope reserve, so the
|
|
18
|
+
* PRINTED envelope stays ≤ `SUMMARY_MAX_BYTES`). Scalars are never touched,
|
|
19
|
+
* so counts and paths stay exact.
|
|
20
|
+
*/
|
|
21
|
+
/** Hard ceiling for the PRINTED `--summary` envelope, in UTF-8 bytes. */
|
|
22
|
+
export const SUMMARY_MAX_BYTES = 2048;
|
|
23
|
+
/**
|
|
24
|
+
* Reserve for the envelope wrapper (`ok`/`command`/`warnings`/`nextActions`/
|
|
25
|
+
* error fields) that the CLI adds around `data`. The builders cap `data` at
|
|
26
|
+
* `SUMMARY_DATA_MAX_BYTES` so the whole printed envelope stays ≤ 2 KB.
|
|
27
|
+
*/
|
|
28
|
+
export const SUMMARY_ENVELOPE_RESERVE_BYTES = 512;
|
|
29
|
+
/** Cap applied to the summary `data` object itself (pretty-printed size). */
|
|
30
|
+
export const SUMMARY_DATA_MAX_BYTES = SUMMARY_MAX_BYTES - SUMMARY_ENVELOPE_RESERVE_BYTES;
|
|
31
|
+
/** Per-name character cap — a name is a label, not a document. */
|
|
32
|
+
export const SUMMARY_NAME_MAX_CHARS = 120;
|
|
33
|
+
/** How many names each command asks for before the byte fitter trims. */
|
|
34
|
+
export const SUMMARY_INITIAL_NAMES = 40;
|
|
35
|
+
function clipName(name, maxChars) {
|
|
36
|
+
const collapsed = name.replace(/\s+/g, ' ').trim();
|
|
37
|
+
return collapsed.length <= maxChars ? collapsed : `${collapsed.slice(0, maxChars - 1)}…`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build a `{count, names}` view. `count` is always the full length; `names`
|
|
41
|
+
* carries the first `SUMMARY_INITIAL_NAMES` (clipped) entries — the byte
|
|
42
|
+
* fitter may trim further.
|
|
43
|
+
*/
|
|
44
|
+
export function boundedNames(names) {
|
|
45
|
+
return {
|
|
46
|
+
count: names.length,
|
|
47
|
+
names: names.slice(0, SUMMARY_INITIAL_NAMES).map((n) => clipName(n, SUMMARY_NAME_MAX_CHARS)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Size of the value AS PRINTED — `printResult` serializes with `null, 2`, so
|
|
52
|
+
* the bound must be measured on the pretty form, not the compact one.
|
|
53
|
+
*/
|
|
54
|
+
function byteLength(value) {
|
|
55
|
+
try {
|
|
56
|
+
return Buffer.byteLength(JSON.stringify(value, null, 2) ?? '', 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return Number.POSITIVE_INFINITY;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Collect every array (with its key path) nested in `node`. */
|
|
63
|
+
function collectArrays(node, path, out) {
|
|
64
|
+
if (Array.isArray(node)) {
|
|
65
|
+
out.push({ path, array: node });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (typeof node !== 'object' || node === null)
|
|
69
|
+
return;
|
|
70
|
+
for (const [key, value] of Object.entries(node)) {
|
|
71
|
+
collectArrays(value, path === '' ? key : `${path}.${key}`, out);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Shrink `data` (in place on a clone) until its JSON form fits `maxBytes`.
|
|
76
|
+
*
|
|
77
|
+
* Algorithm: repeatedly find the LONGEST nested array and drop its last
|
|
78
|
+
* element. Scalars are never modified, so `count` fields stay truthful; the
|
|
79
|
+
* `names` arrays simply show fewer names. Returns the input unchanged when it
|
|
80
|
+
* already fits or when there is no array left to trim.
|
|
81
|
+
*/
|
|
82
|
+
export function fitSummaryToBytes(data, maxBytes = SUMMARY_DATA_MAX_BYTES) {
|
|
83
|
+
let out;
|
|
84
|
+
try {
|
|
85
|
+
out = structuredClone(data);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
try {
|
|
89
|
+
out = JSON.parse(JSON.stringify(data));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return data; // not serializable — leave the caller's object alone
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Each iteration removes one element, so the bound is the total element
|
|
96
|
+
// count — a cheap upper limit that can never spin forever.
|
|
97
|
+
for (let guard = 0; guard < 100_000; guard++) {
|
|
98
|
+
if (byteLength(out) <= maxBytes)
|
|
99
|
+
return out;
|
|
100
|
+
const arrays = [];
|
|
101
|
+
collectArrays(out, '', arrays);
|
|
102
|
+
let longest = null;
|
|
103
|
+
for (const candidate of arrays) {
|
|
104
|
+
if (candidate.array.length === 0)
|
|
105
|
+
continue;
|
|
106
|
+
if (longest === null || candidate.array.length > longest.array.length)
|
|
107
|
+
longest = candidate;
|
|
108
|
+
}
|
|
109
|
+
if (longest === null)
|
|
110
|
+
return out; // nothing left to shrink
|
|
111
|
+
longest.array.pop();
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-09-10-dispatch-token-and-swarm §3 — file-overlap-aware
|
|
3
|
+
* parallel scheduling.
|
|
4
|
+
*
|
|
5
|
+
* Problem: fan-out is mandatory, but the orchestrator serializes whenever
|
|
6
|
+
* two slices touch the same file (observed 2026-09-07: two slices both
|
|
7
|
+
* edited `src/cli/commands/code-runtime-commands.ts`, forcing a wait).
|
|
8
|
+
* Topological DAG levels do not know about files, so a "parallel" level can
|
|
9
|
+
* still contain a write/write conflict.
|
|
10
|
+
*
|
|
11
|
+
* Solution: a pure planner over slice descriptors `{ id, files[] }` that
|
|
12
|
+
* returns WAVES. Every slice in a wave has a file set pairwise disjoint
|
|
13
|
+
* from every other slice in that wave. A slice whose files collide with an
|
|
14
|
+
* earlier wave is deferred to a later wave, and the plan records WHICH file
|
|
15
|
+
* collided with WHICH already-scheduled slice — so the caller can explain
|
|
16
|
+
* the serialization instead of silently waiting.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no I/O, no clock, deterministic for a given input.
|
|
19
|
+
*
|
|
20
|
+
* Relationship to `planDispatchWaves` (dag-orchestrator.ts): that planner
|
|
21
|
+
* chunks a topological level by `maxConcurrency` only. This planner is
|
|
22
|
+
* orthogonal — it refines a level (or any slice set) by file overlap. The
|
|
23
|
+
* `--from-dag` path uses this one additively (see `firstLevelWaves` in the
|
|
24
|
+
* dispatch envelope); the topological scheduler itself is unchanged.
|
|
25
|
+
*/
|
|
26
|
+
/** One unit of work and the files it is expected to touch. */
|
|
27
|
+
export interface SliceFileDescriptor {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly files: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
/** Why a slice did not land in the first wave it was considered for. */
|
|
32
|
+
export interface WaveDeferral {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
/** Wave the slice was placed in. */
|
|
35
|
+
readonly waveIndex: number;
|
|
36
|
+
/** The file that collided. */
|
|
37
|
+
readonly collidingFile: string;
|
|
38
|
+
/** The slice already scheduled in an earlier wave that holds that file. */
|
|
39
|
+
readonly collidedWith: string;
|
|
40
|
+
/** Human-readable one-liner (stable wording). */
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
}
|
|
43
|
+
export interface FileOverlapWave {
|
|
44
|
+
readonly waveIndex: number;
|
|
45
|
+
/** Slice ids in input order. */
|
|
46
|
+
readonly slices: readonly string[];
|
|
47
|
+
/** Union of the wave's files, sorted. */
|
|
48
|
+
readonly files: readonly string[];
|
|
49
|
+
/** Deferrals resolved INTO this wave. */
|
|
50
|
+
readonly deferred: readonly WaveDeferral[];
|
|
51
|
+
}
|
|
52
|
+
export interface FileOverlapWavePlan {
|
|
53
|
+
readonly waves: readonly FileOverlapWave[];
|
|
54
|
+
/** Slice ids that appeared more than once; only the first is scheduled. */
|
|
55
|
+
readonly duplicateIds: readonly string[];
|
|
56
|
+
/** Number of distinct slices scheduled. */
|
|
57
|
+
readonly sliceCount: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Plan waves such that no two slices in the same wave share a file.
|
|
61
|
+
*
|
|
62
|
+
* Greedy, input-order stable: each slice goes into the earliest existing
|
|
63
|
+
* wave whose files are disjoint from its own; otherwise a new wave is
|
|
64
|
+
* opened. A slice with no files never collides.
|
|
65
|
+
*
|
|
66
|
+
* Duplicate ids: the FIRST descriptor wins; later ones are reported in
|
|
67
|
+
* `duplicateIds` and not scheduled (scheduling the same slice twice would
|
|
68
|
+
* double-dispatch it).
|
|
69
|
+
*/
|
|
70
|
+
export declare function planFileOverlapWaves(slices: readonly SliceFileDescriptor[]): FileOverlapWavePlan;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-09-10-dispatch-token-and-swarm §3 — file-overlap-aware
|
|
3
|
+
* parallel scheduling.
|
|
4
|
+
*
|
|
5
|
+
* Problem: fan-out is mandatory, but the orchestrator serializes whenever
|
|
6
|
+
* two slices touch the same file (observed 2026-09-07: two slices both
|
|
7
|
+
* edited `src/cli/commands/code-runtime-commands.ts`, forcing a wait).
|
|
8
|
+
* Topological DAG levels do not know about files, so a "parallel" level can
|
|
9
|
+
* still contain a write/write conflict.
|
|
10
|
+
*
|
|
11
|
+
* Solution: a pure planner over slice descriptors `{ id, files[] }` that
|
|
12
|
+
* returns WAVES. Every slice in a wave has a file set pairwise disjoint
|
|
13
|
+
* from every other slice in that wave. A slice whose files collide with an
|
|
14
|
+
* earlier wave is deferred to a later wave, and the plan records WHICH file
|
|
15
|
+
* collided with WHICH already-scheduled slice — so the caller can explain
|
|
16
|
+
* the serialization instead of silently waiting.
|
|
17
|
+
*
|
|
18
|
+
* Pure: no I/O, no clock, deterministic for a given input.
|
|
19
|
+
*
|
|
20
|
+
* Relationship to `planDispatchWaves` (dag-orchestrator.ts): that planner
|
|
21
|
+
* chunks a topological level by `maxConcurrency` only. This planner is
|
|
22
|
+
* orthogonal — it refines a level (or any slice set) by file overlap. The
|
|
23
|
+
* `--from-dag` path uses this one additively (see `firstLevelWaves` in the
|
|
24
|
+
* dispatch envelope); the topological scheduler itself is unchanged.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Plan waves such that no two slices in the same wave share a file.
|
|
28
|
+
*
|
|
29
|
+
* Greedy, input-order stable: each slice goes into the earliest existing
|
|
30
|
+
* wave whose files are disjoint from its own; otherwise a new wave is
|
|
31
|
+
* opened. A slice with no files never collides.
|
|
32
|
+
*
|
|
33
|
+
* Duplicate ids: the FIRST descriptor wins; later ones are reported in
|
|
34
|
+
* `duplicateIds` and not scheduled (scheduling the same slice twice would
|
|
35
|
+
* double-dispatch it).
|
|
36
|
+
*/
|
|
37
|
+
export function planFileOverlapWaves(slices) {
|
|
38
|
+
const duplicateIds = [];
|
|
39
|
+
const seenIds = new Set();
|
|
40
|
+
const ordered = [];
|
|
41
|
+
for (const slice of slices) {
|
|
42
|
+
if (typeof slice?.id !== 'string' || slice.id.length === 0)
|
|
43
|
+
continue;
|
|
44
|
+
if (seenIds.has(slice.id)) {
|
|
45
|
+
duplicateIds.push(slice.id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
seenIds.add(slice.id);
|
|
49
|
+
ordered.push({ id: slice.id, files: normalizeFiles(slice.files) });
|
|
50
|
+
}
|
|
51
|
+
const waves = [];
|
|
52
|
+
for (const slice of ordered) {
|
|
53
|
+
// First wave whose file set is disjoint from this slice's.
|
|
54
|
+
let placedAt = -1;
|
|
55
|
+
let collision = null;
|
|
56
|
+
for (let w = 0; w < waves.length; w += 1) {
|
|
57
|
+
const wave = waves[w];
|
|
58
|
+
if (wave === undefined)
|
|
59
|
+
continue;
|
|
60
|
+
let hit = null;
|
|
61
|
+
for (const file of slice.files) {
|
|
62
|
+
const owner = wave.owner.get(file);
|
|
63
|
+
if (owner !== undefined) {
|
|
64
|
+
hit = { file, withSlice: owner };
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (hit === null) {
|
|
69
|
+
placedAt = w;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
// Remember the FIRST collision (earliest wave) for the reason string.
|
|
73
|
+
if (collision === null) {
|
|
74
|
+
collision = { file: hit.file, withSlice: hit.withSlice, waveIndex: w };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (placedAt === -1) {
|
|
78
|
+
placedAt = waves.length;
|
|
79
|
+
waves.push({ slices: [], files: new Set(), owner: new Map(), deferred: [] });
|
|
80
|
+
}
|
|
81
|
+
const target = waves[placedAt];
|
|
82
|
+
if (target === undefined)
|
|
83
|
+
continue; // unreachable; satisfies strict TS
|
|
84
|
+
target.slices.push(slice.id);
|
|
85
|
+
for (const file of slice.files) {
|
|
86
|
+
target.files.add(file);
|
|
87
|
+
target.owner.set(file, slice.id);
|
|
88
|
+
}
|
|
89
|
+
if (collision !== null && placedAt > 0) {
|
|
90
|
+
target.deferred.push({
|
|
91
|
+
id: slice.id,
|
|
92
|
+
waveIndex: placedAt,
|
|
93
|
+
collidingFile: collision.file,
|
|
94
|
+
collidedWith: collision.withSlice,
|
|
95
|
+
reason: `deferred to wave ${placedAt}: file "${collision.file}" already scheduled in wave ${collision.waveIndex} by slice "${collision.withSlice}"`
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
waves: waves.map((w, index) => ({
|
|
101
|
+
waveIndex: index,
|
|
102
|
+
slices: w.slices,
|
|
103
|
+
files: [...w.files].sort(),
|
|
104
|
+
deferred: w.deferred
|
|
105
|
+
})),
|
|
106
|
+
duplicateIds,
|
|
107
|
+
sliceCount: ordered.length
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function normalizeFiles(files) {
|
|
111
|
+
if (!Array.isArray(files))
|
|
112
|
+
return [];
|
|
113
|
+
const set = new Set();
|
|
114
|
+
for (const f of files) {
|
|
115
|
+
if (typeof f === 'string' && f.length > 0)
|
|
116
|
+
set.add(f);
|
|
117
|
+
}
|
|
118
|
+
return [...set].sort();
|
|
119
|
+
}
|