pi-mega-compact 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +375 -0
  3. package/extensions/DASHBOARD.md +160 -0
  4. package/extensions/dashboard-server.test.ts +124 -0
  5. package/extensions/dashboard-server.ts +459 -0
  6. package/extensions/error-patterns.ts +175 -0
  7. package/extensions/mega-compact.test.ts +351 -0
  8. package/extensions/mega-compact.ts +846 -0
  9. package/extensions/openclaw-mega-compact.ts +370 -0
  10. package/package.json +61 -0
  11. package/src/adapt.ts +120 -0
  12. package/src/boundary.test.ts +61 -0
  13. package/src/boundary.ts +94 -0
  14. package/src/canary.ts +126 -0
  15. package/src/compact.test.ts +99 -0
  16. package/src/compact.ts +262 -0
  17. package/src/config/dedup.ts +120 -0
  18. package/src/config.ts +15 -0
  19. package/src/dedup/dedup.test.ts +46 -0
  20. package/src/dedup/digest.ts +40 -0
  21. package/src/dedup/l1-lsh.ts +67 -0
  22. package/src/dedup/l1-minhash.ts +90 -0
  23. package/src/dedup/l1-verify.ts +55 -0
  24. package/src/dedup/l1.test.ts +57 -0
  25. package/src/dedup/mmr.ts +54 -0
  26. package/src/dedup/normalize.ts +41 -0
  27. package/src/dedup/raptor/guardrails.ts +112 -0
  28. package/src/dedup/raptor/index.ts +118 -0
  29. package/src/dedup/raptor/kmeans.ts +156 -0
  30. package/src/dedup/raptor/raptor.test.ts +238 -0
  31. package/src/dedup/raptor/retrieval.ts +102 -0
  32. package/src/dedup/raptor/summarizer.ts +91 -0
  33. package/src/dedup/raptor/tree.ts +254 -0
  34. package/src/dedup/sprint12.test.ts +242 -0
  35. package/src/dedup/topk.ts +61 -0
  36. package/src/dedup-engine.test.ts +609 -0
  37. package/src/e2e.test.ts +843 -0
  38. package/src/embedder.ts +111 -0
  39. package/src/engine.test.ts +123 -0
  40. package/src/engine.ts +192 -0
  41. package/src/extractive.test.ts +156 -0
  42. package/src/extractive.ts +265 -0
  43. package/src/httpEmbedder.ts +154 -0
  44. package/src/log.test.ts +47 -0
  45. package/src/log.ts +60 -0
  46. package/src/monitoring.ts +171 -0
  47. package/src/ratio.bench.test.ts +1316 -0
  48. package/src/recall.integration.test.ts +96 -0
  49. package/src/recall.test.ts +59 -0
  50. package/src/recall.ts +100 -0
  51. package/src/sprint14.test.ts +245 -0
  52. package/src/store/backfill.ts +263 -0
  53. package/src/store/bloom.ts +122 -0
  54. package/src/store/compression.test.ts +83 -0
  55. package/src/store/compression.ts +203 -0
  56. package/src/store/integrity.ts +65 -0
  57. package/src/store/migrate.test.ts +158 -0
  58. package/src/store/migrate.ts +108 -0
  59. package/src/store/sprint10.test.ts +182 -0
  60. package/src/store/sqlite.ts +519 -0
  61. package/src/store.test.ts +169 -0
  62. package/src/store.ts +192 -0
  63. package/src/supersede.test.ts +42 -0
  64. package/src/supersede.ts +67 -0
  65. package/src/tokens.ts +35 -0
  66. package/src/types.test.ts +10 -0
  67. package/src/types.ts +49 -0
  68. package/src/vectorStore.test.ts +480 -0
  69. package/src/vectorStore.ts +544 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * boundary.ts — drop-boundary safety guards for the `context` hook.
3
+ *
4
+ * Two invariants the drop range must never violate (PREVENT-PI-001 / 002):
5
+ * 1. ANCHOR FLOOR: never drop the most recent N user messages.
6
+ * 2. TOOL-PAIR: never split an assistant(toolCall) from its following
7
+ * tool-result message — an orphaned `tool` role with no preceding
8
+ * assistant tool call causes a 400 on the OpenAI-compat path.
9
+ *
10
+ * The engine reasons over EngineMessage; the pi adapter maps role "tool" +
11
+ * toolName to the tool-result shape.
12
+ */
13
+
14
+ import type { EngineMessage } from "./types.js";
15
+
16
+ /** Is this message a tool result (pi `tool` role with a tool name)? */
17
+ function isToolResult(m: EngineMessage): boolean {
18
+ return m.role === "tool" && Boolean(m.toolName);
19
+ }
20
+
21
+ /** Does this assistant/tool message contain a tool call (toolName set)? */
22
+ function hasToolUse(m: EngineMessage): boolean {
23
+ return Boolean(m.toolName) && m.role !== "tool";
24
+ }
25
+
26
+ /**
27
+ * Compute the safe drop range [dropStart, dropEnd) within `messages`.
28
+ * `keepFrom` is the caller's desired first-preserved index. We then:
29
+ * 1. Walk it back (lower dropEnd = keep more) so the first preserved message
30
+ * is never an orphaned tool result (tool-pair invariant).
31
+ * 2. Raise it (lower dropEnd) to the anchor floor so the last N user messages
32
+ * are never dropped, when enough user messages exist.
33
+ *
34
+ * dropEnd is the first index KEPT. Returns [dropStart, dropEnd]; empty range
35
+ * if nothing should be dropped.
36
+ */
37
+ export function computeDropRange(
38
+ messages: EngineMessage[],
39
+ keepFrom: number,
40
+ anchorUserMessages: number,
41
+ ): [number, number] {
42
+ if (keepFrom <= 0 || keepFrom >= messages.length) return [0, 0];
43
+
44
+ const userIndexes: number[] = [];
45
+ messages.forEach((m, i) => { if (m.role === "user") userIndexes.push(i); });
46
+ const anchorActive = anchorUserMessages > 0 && userIndexes.length >= anchorUserMessages;
47
+ const anchorStart = anchorActive ? userIndexes[userIndexes.length - anchorUserMessages] : 0;
48
+ const floor = anchorActive ? anchorStart : 0;
49
+
50
+ // Walk back for the tool-pair invariant (keep more when needed).
51
+ let k = keepFrom;
52
+ while (k > floor) {
53
+ const firstPreserved = messages[k];
54
+ if (!firstPreserved || !isToolResult(firstPreserved)) break;
55
+ const preceding = messages[k - 1];
56
+ if (preceding && hasToolUse(preceding)) {
57
+ k -= 1; // pair intact across boundary — include the assistant turn
58
+ break;
59
+ }
60
+ k -= 1;
61
+ }
62
+ if (k < floor) k = floor;
63
+
64
+ // Anchor floor: never drop a must-keep user message. Raise dropEnd so we keep
65
+ // from anchorStart onward when the walk didn't already.
66
+ if (anchorActive && k > anchorStart) k = anchorStart;
67
+
68
+ if (k <= 0) return [0, 0];
69
+ return [0, k];
70
+ }
71
+
72
+ /**
73
+ * Validate that the intended split at `keepFrom` (drop [0, keepFrom), keep the
74
+ * rest) does not start the preserved run on an orphaned tool result. Checks
75
+ * messages[keepFrom] against messages[keepFrom-1] directly — independent of the
76
+ * walk-back that computeDropRange may apply.
77
+ */
78
+ export function isBoundarySafe(messages: EngineMessage[], keepFrom: number): boolean {
79
+ if (keepFrom <= 0 || keepFrom >= messages.length) return true;
80
+ const firstPreserved = messages[keepFrom];
81
+ if (!isToolResult(firstPreserved)) return true;
82
+ const preceding = messages[keepFrom - 1];
83
+ return Boolean(preceding && hasToolUse(preceding));
84
+ }
85
+
86
+ /**
87
+ * Drop everything before the safe keep-index, honoring both guards, returning
88
+ * the filtered message list.
89
+ */
90
+ export function dropBefore(messages: EngineMessage[], keepFrom: number, anchorUserMessages: number): EngineMessage[] {
91
+ const [dropStart, dropEnd] = computeDropRange(messages, keepFrom, anchorUserMessages);
92
+ if (dropStart === dropEnd) return messages;
93
+ return messages.slice(dropEnd);
94
+ }
package/src/canary.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * canary.ts — safe sequential tier rollout (Sprint 14, Phase 7).
3
+ *
4
+ * Enables tiers one at a time (L0 → L1 → L2 → RAPTOR), watching each tier's p95
5
+ * latency (from monitoring metrics) and AUTO-DISABLING a tier whose p95 breaches
6
+ * the budget. No human-in-the-loop: degradation is automatic and local (QA #19).
7
+ *
8
+ * The controller owns a MUTABLE working copy of the dedup config; callers read
9
+ * `controller.config` after each step. Tiers disabled via MARK_ONLY degrade
10
+ * gracefully rather than fully off.
11
+ */
12
+
13
+ import type { DedupConfigShape, DedupTier } from "./config/dedup.js";
14
+ import { loadDedupConfig } from "./config/dedup.js";
15
+ import type { DedupMetrics } from "./monitoring.js";
16
+ import { p95 } from "./monitoring.js";
17
+
18
+ /** Canonical enablement order. */
19
+ export const CANARY_ORDER: DedupTier[] = ["L0", "L1", "L2", "RAPTOR"];
20
+
21
+ export interface CanaryState {
22
+ /** Tiers currently enabled (and not auto-disabled). */
23
+ enabled: Set<DedupTier>;
24
+ /** Tiers auto-disabled by a p95 breach. */
25
+ disabled: Set<DedupTier>;
26
+ /** Steps taken so far. */
27
+ step: number;
28
+ }
29
+
30
+ export class CanaryController {
31
+ readonly config: DedupConfigShape;
32
+ private readonly state: CanaryState;
33
+
34
+ constructor(base: DedupConfigShape = loadDedupConfig()) {
35
+ // Start from the base config but begin with L0 only (sequential rollout).
36
+ this.config = { ...base };
37
+ this.config.L1_ENABLED = false;
38
+ this.config.L2_ENABLED = false;
39
+ this.config.RAPTOR_ENABLED = false;
40
+ this.state = {
41
+ enabled: new Set<DedupTier>(["L0"]),
42
+ disabled: new Set<DedupTier>(),
43
+ step: 1,
44
+ };
45
+ }
46
+
47
+ /** Enable the next tier in CANARY_ORDER (no-op if all enabled). */
48
+ stepForward(): DedupTier | null {
49
+ for (const tier of CANARY_ORDER) {
50
+ if (!this.state.enabled.has(tier) && !this.state.disabled.has(tier)) {
51
+ this.setEnabled(tier, true);
52
+ this.state.step++;
53
+ return tier;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ private setEnabled(tier: DedupTier, on: boolean): void {
60
+ switch (tier) {
61
+ case "L0": this.config.L0_ENABLED = on; break;
62
+ case "L1": this.config.L1_ENABLED = on; break;
63
+ case "L2": this.config.L2_ENABLED = on; break;
64
+ case "RAPTOR": this.config.RAPTOR_ENABLED = on; break;
65
+ }
66
+ if (on) { this.state.enabled.add(tier); this.state.disabled.delete(tier); }
67
+ else { this.state.enabled.delete(tier); }
68
+ }
69
+
70
+ private disableReason(tier: DedupTier): void {
71
+ this.setEnabled(tier, false);
72
+ this.state.disabled.add(tier);
73
+ }
74
+
75
+ /**
76
+ * Evaluate p95 latency for every enabled tier against the budget. Any tier
77
+ * whose p95 exceeds `config.P95_BUDGET_MS` is auto-disabled. Returns the tiers
78
+ * it disabled this pass (so the caller can log/alert).
79
+ */
80
+ evaluate(metrics: DedupMetrics): DedupTier[] {
81
+ const disabledNow: DedupTier[] = [];
82
+ for (const tier of CANARY_ORDER) {
83
+ if (!this.state.enabled.has(tier)) continue;
84
+ const lat = p95(metrics.latency[tier] ?? []);
85
+ if (lat > this.config.P95_BUDGET_MS) {
86
+ this.disableReason(tier);
87
+ disabledNow.push(tier);
88
+ }
89
+ }
90
+ return disabledNow;
91
+ }
92
+
93
+ getState(): CanaryState {
94
+ return {
95
+ enabled: new Set(this.state.enabled),
96
+ disabled: new Set(this.state.disabled),
97
+ step: this.state.step,
98
+ };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Run the full canary rollout against a metrics feed. `feed` is sampled at each
104
+ * step (after enabling a tier) to drive auto-disable. Deterministic + offline.
105
+ */
106
+ export function runCanary(
107
+ metricsFeed: (step: number, config: DedupConfigShape) => DedupMetrics,
108
+ base: DedupConfigShape = loadDedupConfig(),
109
+ ): { controller: CanaryController; disabled: DedupTier[] } {
110
+ const controller = new CanaryController(base);
111
+ const allDisabled: DedupTier[] = [];
112
+ let step = 0;
113
+ // Up to one step per tier + a final eval.
114
+ while (true) {
115
+ const tier = controller.stepForward();
116
+ if (tier === null) break;
117
+ const metrics = metricsFeed(controller.getState().step, controller.config);
118
+ const disabled = controller.evaluate(metrics);
119
+ allDisabled.push(...disabled);
120
+ if (++step > CANARY_ORDER.length + 1) break;
121
+ }
122
+ // Final eval pass on the last enabled set.
123
+ const finalMetrics = metricsFeed(controller.getState().step, controller.config);
124
+ allDisabled.push(...controller.evaluate(finalMetrics));
125
+ return { controller, disabled: [...new Set(allDisabled)] };
126
+ }
@@ -0,0 +1,99 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { EngineMessage } from "./types.js";
4
+ import {
5
+ formatCompactSummary,
6
+ summarizeMessages,
7
+ mergeCompactSummaries,
8
+ shouldCompact,
9
+ autoCompactCheck,
10
+ collectKeyFiles,
11
+ inferPendingWork,
12
+ extractFileCandidates,
13
+ } from "./compact.js";
14
+ import { estimateSessionTokens } from "./tokens.js";
15
+
16
+ function user(text: string): EngineMessage { return { role: "user", text }; }
17
+ function assistant(text: string): EngineMessage { return { role: "assistant", text }; }
18
+ function toolUse(name: string, input: string): EngineMessage { return { role: "assistant", text: "", toolName: name, input }; }
19
+ function toolResult(name: string, output: string): EngineMessage { return { role: "tool", text: "", toolName: name, output }; }
20
+
21
+ test("formatCompactSummary strips analysis and formats summary block (claw-code parity)", () => {
22
+ const summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
23
+ assert.equal(formatCompactSummary(summary), "Summary:\nKept work");
24
+ });
25
+
26
+ test("leaves small sessions unchanged (shouldCompact false)", () => {
27
+ const messages = [user("hello")];
28
+ assert.equal(shouldCompact(messages, 1, 4), false);
29
+ });
30
+
31
+ test("compacts older messages into a summary with Scope + timeline", () => {
32
+ const messages = [
33
+ user("one ".repeat(200)),
34
+ assistant("two ".repeat(200)),
35
+ toolResult("bash", "ok ".repeat(200)),
36
+ assistant("recent"),
37
+ ];
38
+ assert.equal(shouldCompact(messages, 1, 2), true);
39
+ const summary = summarizeMessages(messages.slice(0, 2));
40
+ const formatted = formatCompactSummary(summary);
41
+ assert.ok(formatted.includes("Scope:"));
42
+ assert.ok(formatted.includes("Key timeline:"));
43
+ });
44
+
45
+ test("merge keeps previous compacted context when compacting again", () => {
46
+ const first = summarizeMessages([
47
+ user("Investigate src/compact.ts"),
48
+ assistant("I will inspect the compact flow."),
49
+ ]);
50
+ const second = summarizeMessages([
51
+ user("Also update src/boundary.ts"),
52
+ assistant("Next: preserve prior summary context."),
53
+ ]);
54
+ const merged = mergeCompactSummaries(first, second);
55
+ assert.ok(merged.includes("Previously compacted context:"));
56
+ assert.ok(merged.includes("Newly compacted context:"));
57
+ assert.ok(merged.includes("src/boundary.ts"));
58
+ });
59
+
60
+ test("infers pending work from recent messages", () => {
61
+ const pending = inferPendingWork([
62
+ user("done"),
63
+ assistant("Next: update tests and follow up on remaining CLI polish."),
64
+ ]);
65
+ assert.equal(pending.length, 1);
66
+ assert.ok(pending[0].includes("Next: update tests"));
67
+ });
68
+
69
+ test("extracts key files from message content", () => {
70
+ const files = collectKeyFiles([
71
+ user("Update src/compact.ts and extensions/mega-compact.ts next."),
72
+ ]);
73
+ assert.ok(files.includes("src/compact.ts"));
74
+ assert.ok(files.includes("extensions/mega-compact.ts"));
75
+ });
76
+
77
+ test("extractFileCandidates ignores plain words and non-interesting extensions", () => {
78
+ const files = extractFileCandidates("look at foo/bar.png and src/x.ts and justaword");
79
+ assert.deepEqual(files, ["src/x.ts"]);
80
+ });
81
+
82
+ test("summarizeMessages lists tool names sorted + deduped", () => {
83
+ const summary = summarizeMessages([toolUse("search", "{}"), toolUse("bash", "{}"), toolResult("search", "ok")]);
84
+ assert.ok(summary.includes("Tools mentioned: bash, search."));
85
+ });
86
+
87
+ test("autoCompactCheck reports utilization and threshold gate", () => {
88
+ const under = autoCompactCheck(10000, 50000);
89
+ assert.equal(under.shouldCompact, false);
90
+ assert.equal(under.utilizationPct, 20);
91
+ const over = autoCompactCheck(60000, 50000);
92
+ assert.equal(over.shouldCompact, true);
93
+ });
94
+
95
+ test("token estimator counts text + tool payloads", () => {
96
+ const total = estimateSessionTokens([user("abcd"), toolResult("bash", "abcd")]);
97
+ // "abcd"/4+1 = 2 for user; tool: name "bash"/4+1=2 plus output "abcd"/4+1=2 => 4
98
+ assert.equal(total, 2 + 4);
99
+ });
package/src/compact.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * compact.ts — the COLLAPSE/summarize engine (Layer 2) + the compaction gate.
3
+ *
4
+ * Ported (conceptually) from claw-code rusty-claude-cli compact.rs
5
+ * (summarize_messages / merge_compact_summaries / format_compact_summary) and
6
+ * from memory-mcp session_context.py auto_compact_check / should_compact.
7
+ * Pure, pi-agnostic, deterministic, no LLM required.
8
+ */
9
+
10
+ import type { EngineMessage } from "./types.js";
11
+ import { estimateSessionTokens } from "./tokens.js";
12
+
13
+ const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
14
+ const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
15
+
16
+ const COMPACT_PREAMBLE =
17
+ "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n";
18
+ const RECENT_NOTE = "Recent messages are preserved verbatim.";
19
+ const DIRECT_RESUME = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
20
+
21
+ function truncate(s: string, max: number): string {
22
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
23
+ }
24
+
25
+ function firstText(m: EngineMessage): string | undefined {
26
+ const t = m.text.trim();
27
+ return t.length > 0 ? t : undefined;
28
+ }
29
+
30
+ /** Heuristic: does this text look like chatty filler we can collapse? */
31
+ export function isChatty(text: string): boolean {
32
+ const low = text.toLowerCase();
33
+ if (low.includes("hello") || low.includes("thanks") || low.includes("great") || low.includes("ok")) {
34
+ return true;
35
+ }
36
+ return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
37
+ }
38
+
39
+ /** Extract plausible file paths (contain '/' + an interesting extension). */
40
+ export function extractFileCandidates(content: string): string[] {
41
+ const out: string[] = [];
42
+ for (const raw of content.split(/\s+/)) {
43
+ // Trim surrounding punctuation only — do NOT strip internal dots, or we
44
+ // would erase the extension separator (src/server.ts -> src/server/ts).
45
+ const token = raw.replace(/^[^A-Za-z0-9/]+|[^A-Za-z0-9/]+$/g, "");
46
+ if (!token.includes("/") || !token.includes(".")) continue;
47
+ const ext = token.split(".").pop()?.toLowerCase() ?? "";
48
+ if (INTERESTING_EXT.has(ext)) out.push(token);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** Collect unique key files referenced across a set of messages. */
54
+ export function collectKeyFiles(messages: EngineMessage[]): string[] {
55
+ const files = new Set<string>();
56
+ for (const m of messages) {
57
+ for (const c of [m.text, m.input, m.output]) {
58
+ if (!c) continue;
59
+ for (const f of extractFileCandidates(c)) files.add(f);
60
+ }
61
+ }
62
+ return [...files].slice(0, 8);
63
+ }
64
+
65
+ /** Infer pending work from recent messages via keyword scan. */
66
+ export function inferPendingWork(messages: EngineMessage[]): string[] {
67
+ const out: string[] = [];
68
+ for (const m of [...messages].reverse()) {
69
+ const t = firstText(m);
70
+ if (!t) continue;
71
+ const low = t.toLowerCase();
72
+ if (PENDING_WORDS.some((w) => low.includes(w))) {
73
+ out.push(truncate(t, 160));
74
+ if (out.length >= 3) break;
75
+ }
76
+ }
77
+ return out.reverse();
78
+ }
79
+
80
+ /** Latest user request (for "current work" line). */
81
+ export function inferCurrentWork(messages: EngineMessage[]): string | undefined {
82
+ for (const m of [...messages].reverse()) {
83
+ const t = firstText(m);
84
+ if (t && m.role === "user") return truncate(t, 200);
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ /** Last N user requests, in original order. */
90
+ export function collectRecentUserRequests(messages: EngineMessage[], limit: number): string[] {
91
+ const reqs = messages
92
+ .filter((m) => m.role === "user")
93
+ .map((m) => firstText(m))
94
+ .filter((t): t is string => Boolean(t))
95
+ .map((t) => truncate(t, 160));
96
+ return reqs.slice(-limit);
97
+ }
98
+
99
+ /** Summarize a block to a one-line description. */
100
+ function summarizeBlock(m: EngineMessage): string {
101
+ if (m.role === "tool") return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
102
+ if (m.toolName) return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
103
+ return truncate(m.text, 160);
104
+ }
105
+
106
+ function stripTag(block: string, tag: string): string {
107
+ const start = `<${tag}>`;
108
+ const end = `</${tag}>`;
109
+ const s = block.indexOf(start);
110
+ const e = block.indexOf(end);
111
+ if (s === -1 || e === -1) return block;
112
+ return block.slice(0, s) + block.slice(e + end.length);
113
+ }
114
+
115
+ function extractTag(block: string, tag: string): string | undefined {
116
+ const s = block.indexOf(`<${tag}>`);
117
+ const e = block.indexOf(`</${tag}>`);
118
+ if (s === -1 || e === -1) return undefined;
119
+ return block.slice(s + `<${tag}>`.length, e);
120
+ }
121
+
122
+ /** Normalize a raw summary into user-facing "Summary: ..." text. */
123
+ export function formatCompactSummary(summary: string): string {
124
+ const withoutAnalysis = stripTag(summary, "analysis");
125
+ let formatted = withoutAnalysis;
126
+ const content = extractTag(withoutAnalysis, "summary");
127
+ if (content !== undefined) {
128
+ formatted = withoutAnalysis.replace(
129
+ `<summary>${content}</summary>`,
130
+ `Summary:\n${content.trim()}`,
131
+ );
132
+ }
133
+ return formatted.replace(/\n{3,}/g, "\n\n").trim();
134
+ }
135
+
136
+ /**
137
+ * Build a <summary> block from a slice of messages (the COLLAPSE output).
138
+ * Mirrors claw-code summarize_messages.
139
+ */
140
+ export function summarizeMessages(messages: EngineMessage[]): string {
141
+ const users = messages.filter((m) => m.role === "user").length;
142
+ const assistants = messages.filter((m) => m.role === "assistant").length;
143
+ const tools = messages.filter((m) => m.role === "tool").length;
144
+
145
+ const toolNames = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
146
+
147
+ const lines: string[] = [
148
+ "<summary>",
149
+ "Conversation summary:",
150
+ `- Scope: ${messages.length} earlier messages compacted (user=${users}, assistant=${assistants}, tool=${tools}).`,
151
+ ];
152
+ if (toolNames.length) lines.push(`- Tools mentioned: ${toolNames.join(", ")}.`);
153
+
154
+ const recent = collectRecentUserRequests(messages, 3);
155
+ if (recent.length) {
156
+ lines.push("- Recent user requests:");
157
+ recent.forEach((r) => lines.push(` - ${r}`));
158
+ }
159
+
160
+ const pending = inferPendingWork(messages);
161
+ if (pending.length) {
162
+ lines.push("- Pending work:");
163
+ pending.forEach((p) => lines.push(` - ${p}`));
164
+ }
165
+
166
+ const files = collectKeyFiles(messages);
167
+ if (files.length) lines.push(`- Key files referenced: ${files.join(", ")}.`);
168
+
169
+ const current = inferCurrentWork(messages);
170
+ if (current) lines.push(`- Current work: ${current}`);
171
+
172
+ lines.push("- Key timeline:");
173
+ for (const m of messages) {
174
+ const role = m.role;
175
+ lines.push(` - ${role}: ${summarizeBlock(m)}`);
176
+ }
177
+ lines.push("</summary>");
178
+ return lines.join("\n");
179
+ }
180
+
181
+ /** Extract the prior "highlights" + "timeline" sections from an existing summary. */
182
+ function extractSummaryHighlights(summary: string): string[] {
183
+ const lines = formatCompactSummary(summary).split("\n");
184
+ const out: string[] = [];
185
+ let inTimeline = false;
186
+ for (const line of lines) {
187
+ const t = line.trimEnd();
188
+ if (!t || t === "Summary:" || t === "Conversation summary:") continue;
189
+ if (t === "- Key timeline:") { inTimeline = true; continue; }
190
+ if (inTimeline) continue;
191
+ out.push(t);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ function extractSummaryTimeline(summary: string): string[] {
197
+ const lines = formatCompactSummary(summary).split("\n");
198
+ const out: string[] = [];
199
+ let inTimeline = false;
200
+ for (const line of lines) {
201
+ const t = line.trimEnd();
202
+ if (t === "- Key timeline:") { inTimeline = true; continue; }
203
+ if (!inTimeline) continue;
204
+ if (!t) break;
205
+ out.push(t);
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
211
+ export function mergeCompactSummaries(existing: string | undefined, newSummary: string): string {
212
+ if (!existing) return newSummary;
213
+ const prevHighlights = extractSummaryHighlights(existing);
214
+ const newHighlights = extractSummaryHighlights(formatCompactSummary(newSummary));
215
+ const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
216
+
217
+ const lines = ["<summary>", "Conversation summary:"];
218
+ if (prevHighlights.length) {
219
+ lines.push("- Previously compacted context:");
220
+ prevHighlights.forEach((l) => lines.push(` ${l}`));
221
+ }
222
+ if (newHighlights.length) {
223
+ lines.push("- Newly compacted context:");
224
+ newHighlights.forEach((l) => lines.push(` ${l}`));
225
+ }
226
+ if (newTimeline.length) {
227
+ lines.push("- Key timeline:");
228
+ newTimeline.forEach((l) => lines.push(` ${l}`));
229
+ }
230
+ lines.push("</summary>");
231
+ return lines.join("\n");
232
+ }
233
+
234
+ /** True when the compactable portion exceeds the budget. */
235
+ export function shouldCompact(messages: EngineMessage[], maxEstimatedTokens: number, preserveRecent: number): boolean {
236
+ if (messages.length <= preserveRecent) return false;
237
+ const compactable = messages.slice(0, messages.length - preserveRecent);
238
+ return estimateSessionTokens(compactable) >= maxEstimatedTokens;
239
+ }
240
+
241
+ /** Local reimplementation of memory-mcp auto_compact_check. */
242
+ export function autoCompactCheck(currentTokens: number, threshold = 50000): {
243
+ shouldCompact: boolean;
244
+ currentTokens: number;
245
+ threshold: number;
246
+ utilizationPct: number;
247
+ } {
248
+ return {
249
+ shouldCompact: currentTokens >= threshold,
250
+ currentTokens,
251
+ threshold,
252
+ utilizationPct: Math.round((currentTokens / threshold) * 1000) / 10,
253
+ };
254
+ }
255
+
256
+ /** Build the synthetic continuation message (system-prompt prepend form). */
257
+ export function getContinuationMessage(summary: string, suppressFollowUp: boolean, recentPreserved: boolean): string {
258
+ let base = COMPACT_PREAMBLE + formatCompactSummary(summary);
259
+ if (recentPreserved) base += `\n\n${RECENT_NOTE}`;
260
+ if (suppressFollowUp) base += `\n${DIRECT_RESUME}`;
261
+ return base;
262
+ }