@pugi/cli 0.1.0-alpha.3 → 0.1.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +5 -0
- package/dist/core/engine/prompts.js +42 -0
- package/dist/core/engine/tool-bridge.js +159 -61
- package/dist/core/hooks.js +415 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/permission.js +221 -116
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/session.js +399 -0
- package/dist/core/repl/slash-commands.js +116 -0
- package/dist/core/session.js +168 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/runtime/cli.js +158 -46
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/input-box.js +91 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +68 -0
- package/dist/tui/repl-render.js +218 -0
- package/dist/tui/repl.js +152 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +58 -0
- package/package.json +11 -5
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Six-tier context compaction engine for the Pugi CLI agent loop.
|
|
3
|
+
*
|
|
4
|
+
* Spec: `docs/research/pugi-cli-corpus/patterns/context-compaction.md`,
|
|
5
|
+
* sprint slot: ADR-0056 §α5.5.
|
|
6
|
+
*
|
|
7
|
+
* Tiers and triggers (selectTier rules):
|
|
8
|
+
*
|
|
9
|
+
* pressure | tier
|
|
10
|
+
* --------------+----------------------------------------------------
|
|
11
|
+
* < 0.5 | microcompact (only if redundant tool outputs)
|
|
12
|
+
* 0.5 .. 0.7 | cached_microcompact
|
|
13
|
+
* 0.7 .. 0.85 | reactive_summary
|
|
14
|
+
* 0.85 .. 0.95 | full_compaction
|
|
15
|
+
* > 0.95 | reset (with checkpoint)
|
|
16
|
+
*
|
|
17
|
+
* Tier behaviours (per pattern card §3):
|
|
18
|
+
*
|
|
19
|
+
* 1. microcompact — sync; strip redundant token deltas,
|
|
20
|
+
* collapse repeated status lines, dedupe
|
|
21
|
+
* identical tool argument echoes; keep
|
|
22
|
+
* tool RESULTS verbatim; target 10-20%
|
|
23
|
+
* 2. cached_microcompact — sync; replace inline tool output with
|
|
24
|
+
* { artifactRef, size } when an artifact
|
|
25
|
+
* with matching sha256 already exists;
|
|
26
|
+
* target 30-50% on repetitive sessions
|
|
27
|
+
* 3. reactive_summary — async-shaped; summarize the last N=10
|
|
28
|
+
* turns into a structured turn-summary
|
|
29
|
+
* artifact; replace those turns with a
|
|
30
|
+
* single turn_summary event
|
|
31
|
+
* 4. session_memory — async-shaped; distill long-running build
|
|
32
|
+
* state into .pugi/session.db (or jsonl
|
|
33
|
+
* fallback if SQLite not yet present)
|
|
34
|
+
* 5. full_compaction — sync, slow; rebuild from event log +
|
|
35
|
+
* artifacts + session_memory + PUGI.md;
|
|
36
|
+
* keep open decisions, FSM state, active
|
|
37
|
+
* tool calls, last 3 turns verbatim
|
|
38
|
+
* 6. reset — manual or >0.95; save full state to
|
|
39
|
+
* .pugi/checkpoints/<name>/ and start
|
|
40
|
+
* fresh with only PUGI.md + session_memory
|
|
41
|
+
*
|
|
42
|
+
* The compaction NEVER touches static blocks. Invariants enforce that
|
|
43
|
+
* (static-hash-unchanged) plus secrets-never-summarize and
|
|
44
|
+
* open-decisions-preserved. Caller is expected to run `checkInvariants`
|
|
45
|
+
* before committing the result.
|
|
46
|
+
*
|
|
47
|
+
* All tier functions are pure with respect to the in-memory transcript;
|
|
48
|
+
* disk writes (artifacts, checkpoints, session_memory) go through the
|
|
49
|
+
* caller-supplied `ArtifactWriter` so tests can stub them.
|
|
50
|
+
*/
|
|
51
|
+
import { createHash } from 'node:crypto';
|
|
52
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { resolve } from 'node:path';
|
|
54
|
+
const REACTIVE_SUMMARY_TURNS_DEFAULT = 10;
|
|
55
|
+
const FULL_COMPACTION_KEEP_TURNS = 3;
|
|
56
|
+
const PATTERN_BUDGET_NO_OP_THRESHOLD = 0.5;
|
|
57
|
+
const PATTERN_BUDGET_CACHED_THRESHOLD = 0.7;
|
|
58
|
+
const PATTERN_BUDGET_REACTIVE_THRESHOLD = 0.85;
|
|
59
|
+
const PATTERN_BUDGET_FULL_THRESHOLD = 0.95;
|
|
60
|
+
/**
|
|
61
|
+
* Decide which tier should run for the given pressure. Pure function.
|
|
62
|
+
* The engine loop calls this every tool turn and dispatches to
|
|
63
|
+
* `runCompaction` with the returned tier.
|
|
64
|
+
*/
|
|
65
|
+
export function selectTier(input) {
|
|
66
|
+
const pressure = input.contextBudgetMax > 0
|
|
67
|
+
? input.contextBudgetUsed / input.contextBudgetMax
|
|
68
|
+
: 0;
|
|
69
|
+
if (pressure > PATTERN_BUDGET_FULL_THRESHOLD)
|
|
70
|
+
return 'reset';
|
|
71
|
+
if (pressure > PATTERN_BUDGET_REACTIVE_THRESHOLD)
|
|
72
|
+
return 'full_compaction';
|
|
73
|
+
if (pressure > PATTERN_BUDGET_CACHED_THRESHOLD)
|
|
74
|
+
return 'reactive_summary';
|
|
75
|
+
if (pressure > PATTERN_BUDGET_NO_OP_THRESHOLD)
|
|
76
|
+
return 'cached_microcompact';
|
|
77
|
+
// Sub-50% pressure: only microcompact if there's actually duplication
|
|
78
|
+
// to reclaim. The caller may treat the resulting `skipped: true`
|
|
79
|
+
// result as a no-op.
|
|
80
|
+
return 'microcompact';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Run the requested compaction tier. The engine should `checkInvariants`
|
|
84
|
+
* on the returned `CompactionResult` before committing the new
|
|
85
|
+
* transcript; on violation, the engine discards the result and proceeds
|
|
86
|
+
* with the pre-compaction state.
|
|
87
|
+
*/
|
|
88
|
+
export async function runCompaction(input, tier) {
|
|
89
|
+
switch (tier) {
|
|
90
|
+
case 'microcompact':
|
|
91
|
+
return tierMicrocompact(input);
|
|
92
|
+
case 'cached_microcompact':
|
|
93
|
+
return tierCachedMicrocompact(input);
|
|
94
|
+
case 'reactive_summary':
|
|
95
|
+
return tierReactiveSummary(input);
|
|
96
|
+
case 'session_memory':
|
|
97
|
+
return tierSessionMemory(input);
|
|
98
|
+
case 'full_compaction':
|
|
99
|
+
return tierFullCompaction(input);
|
|
100
|
+
case 'reset':
|
|
101
|
+
return tierReset(input);
|
|
102
|
+
default: {
|
|
103
|
+
const exhaustive = tier;
|
|
104
|
+
throw new Error(`unknown compaction tier: ${String(exhaustive)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------
|
|
109
|
+
// Tier 1: microcompact
|
|
110
|
+
// ---------------------------------------------------------------------
|
|
111
|
+
function tierMicrocompact(input) {
|
|
112
|
+
const beforeBytes = transcriptBytes(input.transcript);
|
|
113
|
+
const newTranscript = [];
|
|
114
|
+
let lastStatusLine = '';
|
|
115
|
+
const seenToolEchoes = new Set();
|
|
116
|
+
for (const turn of input.transcript) {
|
|
117
|
+
// Tool RESULTS are kept verbatim (pattern card §3 tier 1 explicit).
|
|
118
|
+
if (turn.kind === 'tool_result') {
|
|
119
|
+
newTranscript.push(turn);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// Tool CALL echoes: dedupe by serialized content. Same args sent
|
|
123
|
+
// twice is bookkeeping noise (e.g. a retry); keep first.
|
|
124
|
+
if (turn.kind === 'tool_call') {
|
|
125
|
+
const key = `${turn.role}::${turn.content}`;
|
|
126
|
+
if (seenToolEchoes.has(key))
|
|
127
|
+
continue;
|
|
128
|
+
seenToolEchoes.add(key);
|
|
129
|
+
newTranscript.push(turn);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Status lines: collapse exact repeats (typical of streamed progress
|
|
133
|
+
// dots / "thinking..." stubs). We only collapse the immediate
|
|
134
|
+
// previous status; non-adjacent repeats survive.
|
|
135
|
+
const collapsed = collapseRepeatedStatusLines(turn.content);
|
|
136
|
+
if (turn.role === 'assistant' && collapsed === lastStatusLine && isStatusLike(collapsed)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
lastStatusLine = collapsed;
|
|
140
|
+
newTranscript.push({ ...turn, content: collapsed });
|
|
141
|
+
}
|
|
142
|
+
const afterBytes = transcriptBytes(newTranscript);
|
|
143
|
+
const reclaimed = Math.max(0, beforeBytes - afterBytes);
|
|
144
|
+
return {
|
|
145
|
+
tier: 'microcompact',
|
|
146
|
+
bytesReclaimed: reclaimed,
|
|
147
|
+
artifactsCreated: [],
|
|
148
|
+
newContextSize: afterBytes,
|
|
149
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
150
|
+
newTranscript,
|
|
151
|
+
summaryText: '',
|
|
152
|
+
skipped: reclaimed === 0,
|
|
153
|
+
skipReason: reclaimed === 0 ? 'no redundant lines detected' : '',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------
|
|
157
|
+
// Tier 2: cached_microcompact
|
|
158
|
+
// ---------------------------------------------------------------------
|
|
159
|
+
function tierCachedMicrocompact(input) {
|
|
160
|
+
const beforeBytes = transcriptBytes(input.transcript);
|
|
161
|
+
// Build a map from (tool, arguments) -> existing artifact ref. A repeat
|
|
162
|
+
// call with identical arguments is replaced with the inline ref.
|
|
163
|
+
const existingByKey = new Map();
|
|
164
|
+
const outputBytesByKey = new Map();
|
|
165
|
+
for (const out of input.toolOutputs) {
|
|
166
|
+
const key = `${out.tool}::${out.arguments}`;
|
|
167
|
+
if (out.existingArtifactRef) {
|
|
168
|
+
existingByKey.set(key, out.existingArtifactRef);
|
|
169
|
+
}
|
|
170
|
+
outputBytesByKey.set(key, out.bytes);
|
|
171
|
+
}
|
|
172
|
+
// Also fold large repeated outputs into artifact refs on the fly. If
|
|
173
|
+
// the same tool+args combo appears twice in the recent transcript and
|
|
174
|
+
// the output is >2 KB, hash and inline-replace.
|
|
175
|
+
const seenLargeOutputs = new Map();
|
|
176
|
+
const newTranscript = [];
|
|
177
|
+
const artifactsCreated = [];
|
|
178
|
+
for (const turn of input.transcript) {
|
|
179
|
+
if (turn.kind !== 'tool_result') {
|
|
180
|
+
newTranscript.push(turn);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// Try parse the tool result as { tool, arguments, output } for keying.
|
|
184
|
+
const parsed = tryParseToolResult(turn.content);
|
|
185
|
+
if (!parsed) {
|
|
186
|
+
newTranscript.push(turn);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const key = `${parsed.tool}::${parsed.arguments}`;
|
|
190
|
+
const sizeBytes = Buffer.byteLength(parsed.output, 'utf8');
|
|
191
|
+
// Already have an artifact ref for this exact call? Inline it.
|
|
192
|
+
const existing = existingByKey.get(key);
|
|
193
|
+
if (existing && sizeBytes > 256) {
|
|
194
|
+
newTranscript.push({
|
|
195
|
+
...turn,
|
|
196
|
+
content: JSON.stringify({
|
|
197
|
+
tool: parsed.tool,
|
|
198
|
+
arguments: parsed.arguments,
|
|
199
|
+
artifactRef: existing,
|
|
200
|
+
size: sizeBytes,
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Otherwise, large + repeated: hash, write, replace. Only act on
|
|
206
|
+
// the SECOND-and-later occurrence so the first response stays inline
|
|
207
|
+
// for the operator.
|
|
208
|
+
if (sizeBytes > 2 * 1024) {
|
|
209
|
+
const prev = seenLargeOutputs.get(key);
|
|
210
|
+
if (prev) {
|
|
211
|
+
// Replace this occurrence with the ref. We do NOT write a new
|
|
212
|
+
// artifact here unless the workspace root is supplied; tests
|
|
213
|
+
// that omit it will see size shrinkage without a disk artifact.
|
|
214
|
+
const ref = input.workspaceRoot
|
|
215
|
+
? maybeWriteArtifact(input, parsed.output, 'cached_microcompact', artifactsCreated)
|
|
216
|
+
: { sha: prev.sha };
|
|
217
|
+
newTranscript.push({
|
|
218
|
+
...turn,
|
|
219
|
+
content: JSON.stringify({
|
|
220
|
+
tool: parsed.tool,
|
|
221
|
+
arguments: parsed.arguments,
|
|
222
|
+
artifactRef: ref.sha,
|
|
223
|
+
size: sizeBytes,
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// First sighting: keep inline, remember sha for next time.
|
|
229
|
+
const sha = `sha256:${sha256(parsed.output)}`;
|
|
230
|
+
seenLargeOutputs.set(key, { sha, bytes: sizeBytes });
|
|
231
|
+
}
|
|
232
|
+
newTranscript.push(turn);
|
|
233
|
+
}
|
|
234
|
+
const afterBytes = transcriptBytes(newTranscript);
|
|
235
|
+
const reclaimed = Math.max(0, beforeBytes - afterBytes);
|
|
236
|
+
return {
|
|
237
|
+
tier: 'cached_microcompact',
|
|
238
|
+
bytesReclaimed: reclaimed,
|
|
239
|
+
artifactsCreated,
|
|
240
|
+
newContextSize: afterBytes,
|
|
241
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
242
|
+
newTranscript,
|
|
243
|
+
summaryText: '',
|
|
244
|
+
skipped: reclaimed === 0 && artifactsCreated.length === 0,
|
|
245
|
+
skipReason: reclaimed === 0 && artifactsCreated.length === 0
|
|
246
|
+
? 'no repeated tool outputs eligible for ref-replacement'
|
|
247
|
+
: '',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// ---------------------------------------------------------------------
|
|
251
|
+
// Tier 3: reactive_summary
|
|
252
|
+
// ---------------------------------------------------------------------
|
|
253
|
+
function tierReactiveSummary(input) {
|
|
254
|
+
const N = REACTIVE_SUMMARY_TURNS_DEFAULT;
|
|
255
|
+
if (input.transcript.length <= N) {
|
|
256
|
+
return skipResult('reactive_summary', input, `transcript has ${input.transcript.length} turns, threshold is ${N}`);
|
|
257
|
+
}
|
|
258
|
+
const beforeBytes = transcriptBytes(input.transcript);
|
|
259
|
+
const head = input.transcript.slice(0, input.transcript.length - N);
|
|
260
|
+
const tail = input.transcript.slice(input.transcript.length - N);
|
|
261
|
+
const summary = summarizeTurns(tail);
|
|
262
|
+
const summaryText = JSON.stringify(summary, null, 2);
|
|
263
|
+
// The summary turn carries the structured ref as its content. Engine
|
|
264
|
+
// consumers MAY hydrate the artifact ref into a full read when needed.
|
|
265
|
+
const summaryTurn = {
|
|
266
|
+
role: 'assistant',
|
|
267
|
+
content: summaryText,
|
|
268
|
+
kind: 'turn_summary',
|
|
269
|
+
};
|
|
270
|
+
const newTranscript = [...head, summaryTurn];
|
|
271
|
+
const afterBytes = transcriptBytes(newTranscript);
|
|
272
|
+
const reclaimed = Math.max(0, beforeBytes - afterBytes);
|
|
273
|
+
const artifactsCreated = [];
|
|
274
|
+
if (input.workspaceRoot) {
|
|
275
|
+
const ref = writeArtifact(input.workspaceRoot, summaryText, 'reactive_summary');
|
|
276
|
+
artifactsCreated.push(ref);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
tier: 'reactive_summary',
|
|
280
|
+
bytesReclaimed: reclaimed,
|
|
281
|
+
artifactsCreated,
|
|
282
|
+
newContextSize: afterBytes,
|
|
283
|
+
// The head turns are kept verbatim in newTranscript, so any
|
|
284
|
+
// DECISION/OPEN/BLOCKED/REJECTED lines in the head are physically
|
|
285
|
+
// preserved. But `summary.decisions` only scans the tail (and only
|
|
286
|
+
// captures DECISION/OPEN/REJECTED, never BLOCKED) — so the
|
|
287
|
+
// invariant cross-check, which walks before.transcript looking for
|
|
288
|
+
// every marker line, would fire on every head decision and every
|
|
289
|
+
// BLOCKED line. Use extractFourMarkers across the whole input to
|
|
290
|
+
// report the union; the lines are actually preserved (head verbatim,
|
|
291
|
+
// tail in summary), so reporting them is honest.
|
|
292
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
293
|
+
newTranscript,
|
|
294
|
+
summaryText,
|
|
295
|
+
skipped: false,
|
|
296
|
+
skipReason: '',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// ---------------------------------------------------------------------
|
|
300
|
+
// Tier 4: session_memory
|
|
301
|
+
// ---------------------------------------------------------------------
|
|
302
|
+
function tierSessionMemory(input) {
|
|
303
|
+
const beforeBytes = transcriptBytes(input.transcript);
|
|
304
|
+
// Distill into a session-memory record. The shape matches the spec:
|
|
305
|
+
// open task graph state, completed nodes, blocked nodes, key
|
|
306
|
+
// decisions, rejected approaches.
|
|
307
|
+
const memory = {
|
|
308
|
+
sessionId: input.sessionId,
|
|
309
|
+
timestamp: new Date().toISOString(),
|
|
310
|
+
openTasks: extractMarked(input.transcript, /^\s*OPEN:\s*(.+)$/),
|
|
311
|
+
completed: extractMarked(input.transcript, /^\s*DONE:\s*(.+)$/),
|
|
312
|
+
blocked: extractMarked(input.transcript, /^\s*BLOCKED:\s*(.+)$/),
|
|
313
|
+
decisions: extractMarked(input.transcript, /^\s*DECISION:\s*(.+)$/),
|
|
314
|
+
rejected: extractMarked(input.transcript, /^\s*REJECTED:\s*(.+)$/),
|
|
315
|
+
};
|
|
316
|
+
const summaryText = JSON.stringify(memory, null, 2);
|
|
317
|
+
const artifactsCreated = [];
|
|
318
|
+
if (input.workspaceRoot) {
|
|
319
|
+
// SQLite migration arrives in α6.4 (per spec). Until then we append
|
|
320
|
+
// a JSONL line to .pugi/session-memory.jsonl, which the next session
|
|
321
|
+
// bootstraps into context.
|
|
322
|
+
const path = resolve(input.workspaceRoot, '.pugi', 'session-memory.jsonl');
|
|
323
|
+
try {
|
|
324
|
+
mkdirSync(resolve(input.workspaceRoot, '.pugi'), { recursive: true });
|
|
325
|
+
writeFileSync(path, `${JSON.stringify(memory)}\n`, { flag: 'a', mode: 0o600 });
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// best effort — fall through with no artifact record
|
|
329
|
+
}
|
|
330
|
+
const ref = writeArtifact(input.workspaceRoot, summaryText, 'session_memory');
|
|
331
|
+
artifactsCreated.push(ref);
|
|
332
|
+
}
|
|
333
|
+
// Session memory does not modify the active transcript on its own —
|
|
334
|
+
// it's a side-channel that survives across reactive_summary cycles.
|
|
335
|
+
return {
|
|
336
|
+
tier: 'session_memory',
|
|
337
|
+
bytesReclaimed: 0,
|
|
338
|
+
artifactsCreated,
|
|
339
|
+
newContextSize: beforeBytes,
|
|
340
|
+
// The structured `memory.decisions` field above strips the marker
|
|
341
|
+
// prefix for the session-memory.jsonl record. The invariant check
|
|
342
|
+
// compares full lines (with `DECISION:` prefix) against
|
|
343
|
+
// `before.transcript`, so we must surface the full-line shape here.
|
|
344
|
+
// Using `memory.decisions` directly would fail the invariant on
|
|
345
|
+
// every run.
|
|
346
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
347
|
+
newTranscript: input.transcript,
|
|
348
|
+
summaryText,
|
|
349
|
+
skipped: false,
|
|
350
|
+
skipReason: '',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// ---------------------------------------------------------------------
|
|
354
|
+
// Tier 5: full_compaction
|
|
355
|
+
// ---------------------------------------------------------------------
|
|
356
|
+
function tierFullCompaction(input) {
|
|
357
|
+
const beforeBytes = transcriptBytes(input.transcript);
|
|
358
|
+
if (input.transcript.length <= FULL_COMPACTION_KEEP_TURNS) {
|
|
359
|
+
return skipResult('full_compaction', input, `transcript only ${input.transcript.length} turns, keep window is ${FULL_COMPACTION_KEEP_TURNS}`);
|
|
360
|
+
}
|
|
361
|
+
const keep = input.transcript.slice(-FULL_COMPACTION_KEEP_TURNS);
|
|
362
|
+
const distilled = summarizeTurns(input.transcript.slice(0, -FULL_COMPACTION_KEEP_TURNS));
|
|
363
|
+
const summaryText = JSON.stringify(distilled, null, 2);
|
|
364
|
+
const summaryTurn = {
|
|
365
|
+
role: 'assistant',
|
|
366
|
+
content: summaryText,
|
|
367
|
+
kind: 'turn_summary',
|
|
368
|
+
};
|
|
369
|
+
const newTranscript = [summaryTurn, ...keep];
|
|
370
|
+
const afterBytes = transcriptBytes(newTranscript);
|
|
371
|
+
const reclaimed = Math.max(0, beforeBytes - afterBytes);
|
|
372
|
+
const artifactsCreated = [];
|
|
373
|
+
if (input.workspaceRoot) {
|
|
374
|
+
const ref = writeArtifact(input.workspaceRoot, summaryText, 'full_compaction');
|
|
375
|
+
artifactsCreated.push(ref);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
tier: 'full_compaction',
|
|
379
|
+
bytesReclaimed: reclaimed,
|
|
380
|
+
artifactsCreated,
|
|
381
|
+
newContextSize: afterBytes,
|
|
382
|
+
// `distilled.decisions` only carries DECISION/OPEN/REJECTED. The
|
|
383
|
+
// invariant compares against all four markers in the pre-compaction
|
|
384
|
+
// transcript, so we union with extractFourMarkers to include any
|
|
385
|
+
// BLOCKED lines that lived in the head and would otherwise trip
|
|
386
|
+
// open-decisions-preserved. The keep window also has decisions; both
|
|
387
|
+
// are scanned because the invariant walks the FULL before.transcript.
|
|
388
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
389
|
+
newTranscript,
|
|
390
|
+
summaryText,
|
|
391
|
+
skipped: false,
|
|
392
|
+
skipReason: '',
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// ---------------------------------------------------------------------
|
|
396
|
+
// Tier 6: reset
|
|
397
|
+
// ---------------------------------------------------------------------
|
|
398
|
+
function tierReset(input) {
|
|
399
|
+
const beforeBytes = transcriptBytes(input.transcript);
|
|
400
|
+
const distilled = summarizeTurns(input.transcript);
|
|
401
|
+
const summaryText = JSON.stringify({ ...distilled, reason: 'context budget >95%, full reset triggered' }, null, 2);
|
|
402
|
+
const artifactsCreated = [];
|
|
403
|
+
if (input.workspaceRoot) {
|
|
404
|
+
// Checkpoint dump for replay; named after the session + timestamp.
|
|
405
|
+
const checkpointName = `${input.sessionId}-${Date.now()}`;
|
|
406
|
+
const checkpointDir = resolve(input.workspaceRoot, '.pugi', 'checkpoints', checkpointName);
|
|
407
|
+
try {
|
|
408
|
+
mkdirSync(checkpointDir, { recursive: true });
|
|
409
|
+
writeFileSync(resolve(checkpointDir, 'transcript.json'), JSON.stringify(input.transcript, null, 2), { mode: 0o600 });
|
|
410
|
+
writeFileSync(resolve(checkpointDir, 'summary.json'), summaryText, { mode: 0o600 });
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// best effort — the caller still gets the in-memory result
|
|
414
|
+
}
|
|
415
|
+
const ref = writeArtifact(input.workspaceRoot, summaryText, 'reset');
|
|
416
|
+
artifactsCreated.push(ref);
|
|
417
|
+
}
|
|
418
|
+
// Post-reset transcript is a single seed message describing the reset.
|
|
419
|
+
const seed = {
|
|
420
|
+
role: 'assistant',
|
|
421
|
+
content: summaryText,
|
|
422
|
+
kind: 'turn_summary',
|
|
423
|
+
};
|
|
424
|
+
return {
|
|
425
|
+
tier: 'reset',
|
|
426
|
+
bytesReclaimed: Math.max(0, beforeBytes - Buffer.byteLength(seed.content, 'utf8')),
|
|
427
|
+
artifactsCreated,
|
|
428
|
+
newContextSize: Buffer.byteLength(seed.content, 'utf8'),
|
|
429
|
+
// Same rationale as full_compaction: use the four-marker helper so
|
|
430
|
+
// BLOCKED lines are preserved alongside DECISION/OPEN/REJECTED.
|
|
431
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
432
|
+
newTranscript: [seed],
|
|
433
|
+
summaryText,
|
|
434
|
+
skipped: false,
|
|
435
|
+
skipReason: '',
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
// ---------------------------------------------------------------------
|
|
439
|
+
// Helpers
|
|
440
|
+
// ---------------------------------------------------------------------
|
|
441
|
+
function transcriptBytes(transcript) {
|
|
442
|
+
return transcript.reduce((sum, t) => sum + Buffer.byteLength(t.content, 'utf8'), 0);
|
|
443
|
+
}
|
|
444
|
+
function isStatusLike(line) {
|
|
445
|
+
// Treat short, lowercase, no-period lines as status. Real prose
|
|
446
|
+
// usually has punctuation or capital letters.
|
|
447
|
+
if (line.length === 0 || line.length > 80)
|
|
448
|
+
return false;
|
|
449
|
+
if (/[.!?]\s*$/.test(line))
|
|
450
|
+
return false;
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
function collapseRepeatedStatusLines(content) {
|
|
454
|
+
// Within a single turn, collapse any two adjacent identical lines
|
|
455
|
+
// into one. Streaming "thinking..." style status emits these.
|
|
456
|
+
const lines = content.split('\n');
|
|
457
|
+
if (lines.length < 2)
|
|
458
|
+
return content;
|
|
459
|
+
const out = [];
|
|
460
|
+
let prev = '';
|
|
461
|
+
for (const line of lines) {
|
|
462
|
+
if (line === prev && isStatusLike(line))
|
|
463
|
+
continue;
|
|
464
|
+
out.push(line);
|
|
465
|
+
prev = line;
|
|
466
|
+
}
|
|
467
|
+
return out.join('\n');
|
|
468
|
+
}
|
|
469
|
+
function tryParseToolResult(content) {
|
|
470
|
+
try {
|
|
471
|
+
const parsed = JSON.parse(content);
|
|
472
|
+
if (parsed &&
|
|
473
|
+
typeof parsed === 'object' &&
|
|
474
|
+
typeof parsed.tool === 'string' &&
|
|
475
|
+
typeof parsed.output === 'string') {
|
|
476
|
+
const p = parsed;
|
|
477
|
+
return {
|
|
478
|
+
tool: String(p.tool),
|
|
479
|
+
arguments: String(p.arguments ?? ''),
|
|
480
|
+
output: String(p.output),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// not JSON — skip
|
|
486
|
+
}
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
function summarizeTurns(turns) {
|
|
490
|
+
const decisions = [];
|
|
491
|
+
const blockers = [];
|
|
492
|
+
const filesTouched = new Set();
|
|
493
|
+
let goal = '';
|
|
494
|
+
let nextStep = '';
|
|
495
|
+
for (const turn of turns) {
|
|
496
|
+
if (turn.role === 'user' && !goal) {
|
|
497
|
+
goal = firstSentence(turn.content);
|
|
498
|
+
}
|
|
499
|
+
for (const line of turn.content.split('\n')) {
|
|
500
|
+
const t = line.trim();
|
|
501
|
+
if (/^DECISION:/.test(t) || /^OPEN:/.test(t) || /^REJECTED:/.test(t))
|
|
502
|
+
decisions.push(t);
|
|
503
|
+
if (/^BLOCKED:/.test(t))
|
|
504
|
+
blockers.push(t);
|
|
505
|
+
// Crude file capture: paths look like a/b/c.ts or apps/x/src/y.ts.
|
|
506
|
+
const pathMatches = line.match(/\b[a-z0-9_-]+(?:\/[A-Za-z0-9._-]+){1,}\.[a-z]{1,5}\b/g);
|
|
507
|
+
if (pathMatches)
|
|
508
|
+
for (const p of pathMatches)
|
|
509
|
+
filesTouched.add(p);
|
|
510
|
+
// Last imperative line of the assistant becomes next_step.
|
|
511
|
+
if (turn.role === 'assistant' && /^(?:next|todo|run|build|test|ship|deploy)\b/i.test(t)) {
|
|
512
|
+
nextStep = t;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
goal: goal || '(no explicit user goal in window)',
|
|
518
|
+
decisions,
|
|
519
|
+
blockers,
|
|
520
|
+
files_touched: [...filesTouched].sort(),
|
|
521
|
+
next_step: nextStep || '(no explicit next step recorded)',
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Extract every DECISION/OPEN/BLOCKED/REJECTED line from the transcript,
|
|
526
|
+
* preserving the marker prefix (`DECISION: ...`). This is the shape
|
|
527
|
+
* `checkInvariants` expects in `decisionsPreserved`: it walks the
|
|
528
|
+
* pre-compaction transcript collecting the same shape and compares
|
|
529
|
+
* line-for-line. Any tier that drops the prefix (or omits a marker
|
|
530
|
+
* type) will trip `open-decisions-preserved` on every run.
|
|
531
|
+
*
|
|
532
|
+
* Used by all six tiers when they populate `decisionsPreserved`. The
|
|
533
|
+
* structured-memory variant (`extractMarked`, suffix-only) is for the
|
|
534
|
+
* separate session-memory record format and is not invariant-relevant.
|
|
535
|
+
*/
|
|
536
|
+
function extractFourMarkers(turns) {
|
|
537
|
+
const out = [];
|
|
538
|
+
for (const turn of turns) {
|
|
539
|
+
for (const line of turn.content.split('\n')) {
|
|
540
|
+
const t = line.trim();
|
|
541
|
+
if (/^(?:DECISION|OPEN|BLOCKED|REJECTED):/.test(t))
|
|
542
|
+
out.push(t);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
function extractMarked(turns, rx) {
|
|
548
|
+
const out = [];
|
|
549
|
+
for (const turn of turns) {
|
|
550
|
+
for (const line of turn.content.split('\n')) {
|
|
551
|
+
const m = rx.exec(line);
|
|
552
|
+
if (m && m[1])
|
|
553
|
+
out.push(m[1].trim());
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return out;
|
|
557
|
+
}
|
|
558
|
+
function firstSentence(content) {
|
|
559
|
+
const trimmed = content.trim();
|
|
560
|
+
const m = /^[^.!?\n]{1,200}/.exec(trimmed);
|
|
561
|
+
return m ? m[0].trim() : trimmed.slice(0, 200);
|
|
562
|
+
}
|
|
563
|
+
function sha256(input) {
|
|
564
|
+
return createHash('sha256').update(input, 'utf8').digest('hex');
|
|
565
|
+
}
|
|
566
|
+
function writeArtifact(workspaceRoot, content, producedBy) {
|
|
567
|
+
const sha = sha256(content);
|
|
568
|
+
const dir = resolve(workspaceRoot, '.pugi', 'artifacts');
|
|
569
|
+
mkdirSync(dir, { recursive: true });
|
|
570
|
+
const path = resolve(dir, `${sha}.json`);
|
|
571
|
+
writeFileSync(path, content, { mode: 0o600 });
|
|
572
|
+
return {
|
|
573
|
+
sha256: `sha256:${sha}`,
|
|
574
|
+
path,
|
|
575
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
576
|
+
producedBy,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function maybeWriteArtifact(input, content, producedBy, collector) {
|
|
580
|
+
if (input.workspaceRoot) {
|
|
581
|
+
const ref = writeArtifact(input.workspaceRoot, content, producedBy);
|
|
582
|
+
collector.push(ref);
|
|
583
|
+
return { sha: ref.sha256 };
|
|
584
|
+
}
|
|
585
|
+
// Test/no-disk fallback: return the in-memory sha without persisting.
|
|
586
|
+
return { sha: `sha256:${sha256(content)}` };
|
|
587
|
+
}
|
|
588
|
+
function skipResult(tier, input, reason) {
|
|
589
|
+
const bytes = transcriptBytes(input.transcript);
|
|
590
|
+
return {
|
|
591
|
+
tier,
|
|
592
|
+
bytesReclaimed: 0,
|
|
593
|
+
artifactsCreated: [],
|
|
594
|
+
newContextSize: bytes,
|
|
595
|
+
decisionsPreserved: extractFourMarkers(input.transcript),
|
|
596
|
+
newTranscript: input.transcript,
|
|
597
|
+
summaryText: '',
|
|
598
|
+
skipped: true,
|
|
599
|
+
skipReason: reason,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
//# sourceMappingURL=compaction.js.map
|