@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.
Files changed (44) hide show
  1. package/README.md +20 -0
  2. package/dist/commands/jobs.js +245 -0
  3. package/dist/core/agents/registry.js +69 -0
  4. package/dist/core/bash-classifier.js +1001 -0
  5. package/dist/core/context/builder.js +114 -0
  6. package/dist/core/context/compaction-events.js +99 -0
  7. package/dist/core/context/compaction.js +602 -0
  8. package/dist/core/context/invariants.js +250 -0
  9. package/dist/core/context/markdown-loader.js +270 -0
  10. package/dist/core/engine/compaction-hook.js +154 -0
  11. package/dist/core/engine/index.js +5 -0
  12. package/dist/core/engine/prompts.js +42 -0
  13. package/dist/core/engine/tool-bridge.js +159 -61
  14. package/dist/core/hooks.js +415 -0
  15. package/dist/core/jobs/registry.js +462 -0
  16. package/dist/core/mcp/client.js +316 -0
  17. package/dist/core/mcp/registry.js +171 -0
  18. package/dist/core/mcp/trust.js +91 -0
  19. package/dist/core/permission.js +221 -116
  20. package/dist/core/repl/cap-warning.js +91 -0
  21. package/dist/core/repl/session.js +399 -0
  22. package/dist/core/repl/slash-commands.js +116 -0
  23. package/dist/core/session.js +168 -0
  24. package/dist/core/subagents/dispatcher.js +258 -0
  25. package/dist/core/subagents/index.js +26 -0
  26. package/dist/core/subagents/spawn.js +86 -0
  27. package/dist/core/trust.js +109 -0
  28. package/dist/runtime/cli.js +158 -46
  29. package/dist/runtime/commands/budget.js +192 -0
  30. package/dist/runtime/commands/config.js +231 -0
  31. package/dist/runtime/commands/privacy.js +107 -0
  32. package/dist/runtime/commands/undo.js +329 -0
  33. package/dist/tools/bash.js +660 -0
  34. package/dist/tui/agent-tree.js +66 -0
  35. package/dist/tui/conversation-pane.js +45 -0
  36. package/dist/tui/input-box.js +91 -0
  37. package/dist/tui/login-picker.js +69 -0
  38. package/dist/tui/render.js +68 -0
  39. package/dist/tui/repl-render.js +218 -0
  40. package/dist/tui/repl.js +152 -0
  41. package/dist/tui/splash-data.js +61 -0
  42. package/dist/tui/splash.js +31 -0
  43. package/dist/tui/status-bar.js +58 -0
  44. 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