pi-mega-compact 0.4.5 → 0.4.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 (68) hide show
  1. package/dist/extensions/dashboard-server.js +450 -0
  2. package/dist/extensions/dashboard-server.test.js +111 -0
  3. package/dist/extensions/error-patterns.js +115 -0
  4. package/dist/extensions/mega-compact.js +782 -0
  5. package/dist/extensions/mega-compact.test.js +328 -0
  6. package/dist/extensions/openclaw-mega-compact.js +291 -0
  7. package/dist/src/adapt.js +106 -0
  8. package/dist/src/boundary.js +88 -0
  9. package/dist/src/boundary.test.js +53 -0
  10. package/dist/src/canary.js +118 -0
  11. package/dist/src/compact.js +250 -0
  12. package/dist/src/compact.test.js +78 -0
  13. package/dist/src/config/dedup.js +81 -0
  14. package/dist/src/config.js +12 -0
  15. package/dist/src/dedup/dedup.test.js +41 -0
  16. package/dist/src/dedup/digest.js +30 -0
  17. package/dist/src/dedup/l1-lsh.js +52 -0
  18. package/dist/src/dedup/l1-minhash.js +91 -0
  19. package/dist/src/dedup/l1-verify.js +54 -0
  20. package/dist/src/dedup/l1.test.js +50 -0
  21. package/dist/src/dedup/mmr.js +45 -0
  22. package/dist/src/dedup/normalize.js +39 -0
  23. package/dist/src/dedup/raptor/guardrails.js +83 -0
  24. package/dist/src/dedup/raptor/index.js +94 -0
  25. package/dist/src/dedup/raptor/kmeans.js +152 -0
  26. package/dist/src/dedup/raptor/raptor.test.js +205 -0
  27. package/dist/src/dedup/raptor/retrieval.js +81 -0
  28. package/dist/src/dedup/raptor/summarizer.js +85 -0
  29. package/dist/src/dedup/raptor/tree.js +177 -0
  30. package/dist/src/dedup/sprint12.test.js +219 -0
  31. package/dist/src/dedup/topk.js +60 -0
  32. package/dist/src/dedup-engine.test.js +447 -0
  33. package/dist/src/e2e.test.js +698 -0
  34. package/dist/src/embedder.js +102 -0
  35. package/dist/src/engine.js +137 -0
  36. package/dist/src/engine.test.js +111 -0
  37. package/dist/src/extractive.js +209 -0
  38. package/dist/src/extractive.test.js +130 -0
  39. package/dist/src/httpEmbedder.js +143 -0
  40. package/dist/src/log.js +47 -0
  41. package/dist/src/log.test.js +42 -0
  42. package/dist/src/minilm.js +92 -0
  43. package/dist/src/monitoring.js +131 -0
  44. package/dist/src/ratio.bench.test.js +897 -0
  45. package/dist/src/recall.integration.test.js +77 -0
  46. package/dist/src/recall.js +60 -0
  47. package/dist/src/recall.test.js +50 -0
  48. package/dist/src/sprint14.test.js +219 -0
  49. package/dist/src/store/backfill.js +189 -0
  50. package/dist/src/store/bloom.js +114 -0
  51. package/dist/src/store/compression.js +177 -0
  52. package/dist/src/store/compression.test.js +67 -0
  53. package/dist/src/store/integrity.js +44 -0
  54. package/dist/src/store/migrate.js +79 -0
  55. package/dist/src/store/migrate.test.js +139 -0
  56. package/dist/src/store/sprint10.test.js +186 -0
  57. package/dist/src/store/sqlite.js +574 -0
  58. package/dist/src/store.js +115 -0
  59. package/dist/src/store.test.js +142 -0
  60. package/dist/src/supersede.js +68 -0
  61. package/dist/src/supersede.test.js +36 -0
  62. package/dist/src/tokens.js +31 -0
  63. package/dist/src/types.js +8 -0
  64. package/dist/src/types.test.js +9 -0
  65. package/dist/src/vectorStore.js +465 -0
  66. package/dist/src/vectorStore.test.js +479 -0
  67. package/dist/src/wordpiece.js +129 -0
  68. package/package.json +4 -2
@@ -0,0 +1,106 @@
1
+ /**
2
+ * adapt.ts — the adapter between pi's runtime message types and the engine's
3
+ * pi-agnostic `EngineMessage` shape.
4
+ *
5
+ * The engine (src/compact.ts, supersede.ts, boundary.ts, vectorStore.ts) only
6
+ * ever reasons about EngineMessage, so it stays unit-testable without a pi
7
+ * runtime. This module is the single conversion boundary. The conversion is
8
+ * 1:1 and index-aligned: every output EngineMessage corresponds to exactly one
9
+ * input AgentMessage at the same index, which lets the extension apply
10
+ * drop-range indices computed on the engine view straight back onto the real
11
+ * message array.
12
+ */
13
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
14
+ import { computeDropRange } from "./boundary.js";
15
+ /** Map a pi AgentMessage role to the engine's coarse role. */
16
+ export function messageRole(m) {
17
+ if (m.role === "toolResult")
18
+ return "tool";
19
+ if (m.role === "user" || m.role === "assistant")
20
+ return m.role;
21
+ // custom / bashExecution / branchSummary / compactionSummary — all non-LLM
22
+ // bookkeeping as far as the engine is concerned.
23
+ return "custom";
24
+ }
25
+ /** Extract a tool name from a message, if it carries a tool call/result. */
26
+ export function messageToolName(m) {
27
+ if (m.role === "toolResult")
28
+ return m.toolName;
29
+ if (m.role === "assistant") {
30
+ const tc = m.content.find((c) => c.type === "toolCall");
31
+ return tc?.name;
32
+ }
33
+ return undefined;
34
+ }
35
+ /** Pull the text out of a string-or-blocks content field. */
36
+ function contentText(content) {
37
+ if (typeof content === "string")
38
+ return content;
39
+ return content
40
+ .filter((c) => c.type === "text" && typeof c.text === "string")
41
+ .map((c) => c.text)
42
+ .join("\n");
43
+ }
44
+ /** Project any AgentMessage into a single text blob the engine can reason on. */
45
+ function messageText(m) {
46
+ switch (m.role) {
47
+ case "toolResult":
48
+ case "user":
49
+ case "assistant":
50
+ case "custom":
51
+ return contentText(m.content);
52
+ case "bashExecution":
53
+ return `${m.command}\n${m.output}`;
54
+ case "branchSummary":
55
+ case "compactionSummary":
56
+ return m.summary;
57
+ }
58
+ }
59
+ /**
60
+ * Convert a pi message array into the engine's EngineMessage view, keeping
61
+ * index alignment (output[i] corresponds to input[i]).
62
+ */
63
+ export function toEngineMessages(messages) {
64
+ return messages.map((m) => {
65
+ const role = messageRole(m);
66
+ const toolName = messageToolName(m);
67
+ const text = messageText(m);
68
+ if (m.role === "toolResult") {
69
+ return { role, text, toolName, output: text };
70
+ }
71
+ if (m.role === "assistant") {
72
+ const blocks = m.content;
73
+ const callBlock = blocks.find((c) => c.type === "toolCall");
74
+ const input = callBlock
75
+ ? typeof callBlock.arguments === "string"
76
+ ? callBlock.arguments
77
+ : JSON.stringify(callBlock.arguments ?? {})
78
+ : undefined;
79
+ return { role, text, toolName, input };
80
+ }
81
+ return { role, text, toolName };
82
+ });
83
+ }
84
+ /**
85
+ * Project session entries into the engine view. Reuses pi's own
86
+ * sessionEntryToContextMessages so branching/compaction entries are resolved the
87
+ * same way the runtime would.
88
+ */
89
+ export function toEngineFromEntries(entries) {
90
+ return entries.flatMap((e) => toEngineMessages(sessionEntryToContextMessages(e)));
91
+ }
92
+ /**
93
+ * Compute the safe drop range over a pi message array using the engine's
94
+ * boundary guards (anchor floor + tool-pair), then return the surviving
95
+ * messages. Reuses the tested `computeDropRange` on an engine view and maps the
96
+ * indices back onto the original array (index alignment guarantees correctness).
97
+ */
98
+ export function dropCompactedRange(messages, keepFrom, anchorUserMessages) {
99
+ if (messages.length === 0)
100
+ return messages;
101
+ const view = toEngineMessages(messages);
102
+ const [, dropEnd] = computeDropRange(view, keepFrom, anchorUserMessages);
103
+ if (dropEnd <= 0)
104
+ return messages;
105
+ return messages.slice(dropEnd);
106
+ }
@@ -0,0 +1,88 @@
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
+ /** Is this message a tool result (pi `tool` role with a tool name)? */
14
+ function isToolResult(m) {
15
+ return m.role === "tool" && Boolean(m.toolName);
16
+ }
17
+ /** Does this assistant/tool message contain a tool call (toolName set)? */
18
+ function hasToolUse(m) {
19
+ return Boolean(m.toolName) && m.role !== "tool";
20
+ }
21
+ /**
22
+ * Compute the safe drop range [dropStart, dropEnd) within `messages`.
23
+ * `keepFrom` is the caller's desired first-preserved index. We then:
24
+ * 1. Walk it back (lower dropEnd = keep more) so the first preserved message
25
+ * is never an orphaned tool result (tool-pair invariant).
26
+ * 2. Raise it (lower dropEnd) to the anchor floor so the last N user messages
27
+ * are never dropped, when enough user messages exist.
28
+ *
29
+ * dropEnd is the first index KEPT. Returns [dropStart, dropEnd]; empty range
30
+ * if nothing should be dropped.
31
+ */
32
+ export function computeDropRange(messages, keepFrom, anchorUserMessages) {
33
+ if (keepFrom <= 0 || keepFrom >= messages.length)
34
+ return [0, 0];
35
+ const userIndexes = [];
36
+ messages.forEach((m, i) => { if (m.role === "user")
37
+ userIndexes.push(i); });
38
+ const anchorActive = anchorUserMessages > 0 && userIndexes.length >= anchorUserMessages;
39
+ const anchorStart = anchorActive ? userIndexes[userIndexes.length - anchorUserMessages] : 0;
40
+ const floor = anchorActive ? anchorStart : 0;
41
+ // Walk back for the tool-pair invariant (keep more when needed).
42
+ let k = keepFrom;
43
+ while (k > floor) {
44
+ const firstPreserved = messages[k];
45
+ if (!firstPreserved || !isToolResult(firstPreserved))
46
+ break;
47
+ const preceding = messages[k - 1];
48
+ if (preceding && hasToolUse(preceding)) {
49
+ k -= 1; // pair intact across boundary — include the assistant turn
50
+ break;
51
+ }
52
+ k -= 1;
53
+ }
54
+ if (k < floor)
55
+ k = floor;
56
+ // Anchor floor: never drop a must-keep user message. Raise dropEnd so we keep
57
+ // from anchorStart onward when the walk didn't already.
58
+ if (anchorActive && k > anchorStart)
59
+ k = anchorStart;
60
+ if (k <= 0)
61
+ return [0, 0];
62
+ return [0, k];
63
+ }
64
+ /**
65
+ * Validate that the intended split at `keepFrom` (drop [0, keepFrom), keep the
66
+ * rest) does not start the preserved run on an orphaned tool result. Checks
67
+ * messages[keepFrom] against messages[keepFrom-1] directly — independent of the
68
+ * walk-back that computeDropRange may apply.
69
+ */
70
+ export function isBoundarySafe(messages, keepFrom) {
71
+ if (keepFrom <= 0 || keepFrom >= messages.length)
72
+ return true;
73
+ const firstPreserved = messages[keepFrom];
74
+ if (!isToolResult(firstPreserved))
75
+ return true;
76
+ const preceding = messages[keepFrom - 1];
77
+ return Boolean(preceding && hasToolUse(preceding));
78
+ }
79
+ /**
80
+ * Drop everything before the safe keep-index, honoring both guards, returning
81
+ * the filtered message list.
82
+ */
83
+ export function dropBefore(messages, keepFrom, anchorUserMessages) {
84
+ const [dropStart, dropEnd] = computeDropRange(messages, keepFrom, anchorUserMessages);
85
+ if (dropStart === dropEnd)
86
+ return messages;
87
+ return messages.slice(dropEnd);
88
+ }
@@ -0,0 +1,53 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { computeDropRange, isBoundarySafe, dropBefore } from "./boundary.js";
4
+ function user(t) { return { role: "user", text: t }; }
5
+ function assistant(t) { return { role: "assistant", text: t }; }
6
+ function toolUse(n, i = "{}") { return { role: "assistant", text: "", toolName: n, input: i }; }
7
+ function toolResult(n, o = "ok") { return { role: "tool", text: "", toolName: n, output: o }; }
8
+ test("walks back so first preserved message is not an orphaned tool result", () => {
9
+ const messages = [
10
+ user("Search for files"),
11
+ toolUse("search"),
12
+ toolResult("search", "found 5 files"),
13
+ assistant("Done."),
14
+ ];
15
+ // keepFrom=2 would start the preserved run on the tool result at index 2,
16
+ // orphaning it. The guard walks back to include the assistant tool-call.
17
+ const [start, end] = computeDropRange(messages, 2, 0);
18
+ assert.equal(start, 0);
19
+ assert.equal(end, 1);
20
+ const kept = messages.slice(end);
21
+ assert.notEqual(kept[0].role, "tool");
22
+ assert.equal(kept[0].toolName, "search"); // assistant tool-call preserved
23
+ });
24
+ test("isBoundarySafe: tool result at boundary with preceding tool use is safe", () => {
25
+ const messages = [user("a"), toolUse("search"), toolResult("search")];
26
+ assert.equal(isBoundarySafe(messages, 2), true);
27
+ });
28
+ test("isBoundarySafe: orphaned tool result without preceding tool use is unsafe", () => {
29
+ const messages = [user("a"), toolResult("search", "orphan")];
30
+ assert.equal(isBoundarySafe(messages, 1), false);
31
+ });
32
+ test("anchor floor preserves the last N user messages", () => {
33
+ const messages = [
34
+ user("u1"), user("u2"), user("u3"),
35
+ assistant("a1"), assistant("a2"), assistant("a3"), assistant("a4"), assistant("a5"),
36
+ ];
37
+ // Caller wants to keep from index 2 (would drop u2). Anchor=2 forces keeping
38
+ // from u2 (index 1) onward.
39
+ const out = dropBefore(messages, 2, 2);
40
+ assert.ok(out.some((m) => m.text === "u2"));
41
+ assert.ok(out.some((m) => m.text === "u3"));
42
+ });
43
+ test("anchor floor is a no-op when fewer users than anchor", () => {
44
+ const messages = [user("u1"), assistant("a1"), assistant("a2"), assistant("a3")];
45
+ const out = dropBefore(messages, 1, 2);
46
+ // only 1 user, anchor=2 → no floor; keep from index 1 (drop the user)
47
+ assert.ok(!out.some((m) => m.text === "u1"));
48
+ assert.equal(out.length, 3);
49
+ });
50
+ test("dropBefore returns original when range is empty", () => {
51
+ const messages = [user("a"), assistant("b")];
52
+ assert.equal(dropBefore(messages, 0, 1), messages);
53
+ });
@@ -0,0 +1,118 @@
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
+ import { loadDedupConfig } from "./config/dedup.js";
13
+ import { p95 } from "./monitoring.js";
14
+ /** Canonical enablement order. */
15
+ export const CANARY_ORDER = ["L0", "L1", "L2", "RAPTOR"];
16
+ export class CanaryController {
17
+ config;
18
+ state;
19
+ constructor(base = loadDedupConfig()) {
20
+ // Start from the base config but begin with L0 only (sequential rollout).
21
+ this.config = { ...base };
22
+ this.config.L1_ENABLED = false;
23
+ this.config.L2_ENABLED = false;
24
+ this.config.RAPTOR_ENABLED = false;
25
+ this.state = {
26
+ enabled: new Set(["L0"]),
27
+ disabled: new Set(),
28
+ step: 1,
29
+ };
30
+ }
31
+ /** Enable the next tier in CANARY_ORDER (no-op if all enabled). */
32
+ stepForward() {
33
+ for (const tier of CANARY_ORDER) {
34
+ if (!this.state.enabled.has(tier) && !this.state.disabled.has(tier)) {
35
+ this.setEnabled(tier, true);
36
+ this.state.step++;
37
+ return tier;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ setEnabled(tier, on) {
43
+ switch (tier) {
44
+ case "L0":
45
+ this.config.L0_ENABLED = on;
46
+ break;
47
+ case "L1":
48
+ this.config.L1_ENABLED = on;
49
+ break;
50
+ case "L2":
51
+ this.config.L2_ENABLED = on;
52
+ break;
53
+ case "RAPTOR":
54
+ this.config.RAPTOR_ENABLED = on;
55
+ break;
56
+ }
57
+ if (on) {
58
+ this.state.enabled.add(tier);
59
+ this.state.disabled.delete(tier);
60
+ }
61
+ else {
62
+ this.state.enabled.delete(tier);
63
+ }
64
+ }
65
+ disableReason(tier) {
66
+ this.setEnabled(tier, false);
67
+ this.state.disabled.add(tier);
68
+ }
69
+ /**
70
+ * Evaluate p95 latency for every enabled tier against the budget. Any tier
71
+ * whose p95 exceeds `config.P95_BUDGET_MS` is auto-disabled. Returns the tiers
72
+ * it disabled this pass (so the caller can log/alert).
73
+ */
74
+ evaluate(metrics) {
75
+ const disabledNow = [];
76
+ for (const tier of CANARY_ORDER) {
77
+ if (!this.state.enabled.has(tier))
78
+ continue;
79
+ const lat = p95(metrics.latency[tier] ?? []);
80
+ if (lat > this.config.P95_BUDGET_MS) {
81
+ this.disableReason(tier);
82
+ disabledNow.push(tier);
83
+ }
84
+ }
85
+ return disabledNow;
86
+ }
87
+ getState() {
88
+ return {
89
+ enabled: new Set(this.state.enabled),
90
+ disabled: new Set(this.state.disabled),
91
+ step: this.state.step,
92
+ };
93
+ }
94
+ }
95
+ /**
96
+ * Run the full canary rollout against a metrics feed. `feed` is sampled at each
97
+ * step (after enabling a tier) to drive auto-disable. Deterministic + offline.
98
+ */
99
+ export function runCanary(metricsFeed, base = loadDedupConfig()) {
100
+ const controller = new CanaryController(base);
101
+ const allDisabled = [];
102
+ let step = 0;
103
+ // Up to one step per tier + a final eval.
104
+ while (true) {
105
+ const tier = controller.stepForward();
106
+ if (tier === null)
107
+ break;
108
+ const metrics = metricsFeed(controller.getState().step, controller.config);
109
+ const disabled = controller.evaluate(metrics);
110
+ allDisabled.push(...disabled);
111
+ if (++step > CANARY_ORDER.length + 1)
112
+ break;
113
+ }
114
+ // Final eval pass on the last enabled set.
115
+ const finalMetrics = metricsFeed(controller.getState().step, controller.config);
116
+ allDisabled.push(...controller.evaluate(finalMetrics));
117
+ return { controller, disabled: [...new Set(allDisabled)] };
118
+ }
@@ -0,0 +1,250 @@
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
+ import { estimateSessionTokens } from "./tokens.js";
10
+ const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
11
+ const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
12
+ const COMPACT_PREAMBLE = "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";
13
+ const RECENT_NOTE = "Recent messages are preserved verbatim.";
14
+ 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.";
15
+ function truncate(s, max) {
16
+ return s.length <= max ? s : `${s.slice(0, max)}…`;
17
+ }
18
+ function firstText(m) {
19
+ const t = m.text.trim();
20
+ return t.length > 0 ? t : undefined;
21
+ }
22
+ /** Heuristic: does this text look like chatty filler we can collapse? */
23
+ export function isChatty(text) {
24
+ const low = text.toLowerCase();
25
+ if (low.includes("hello") || low.includes("thanks") || low.includes("great") || low.includes("ok")) {
26
+ return true;
27
+ }
28
+ return text.length < 40 && !/(\/|\.|\{|import |def |function )/.test(text);
29
+ }
30
+ /** Extract plausible file paths (contain '/' + an interesting extension). */
31
+ export function extractFileCandidates(content) {
32
+ const out = [];
33
+ for (const raw of content.split(/\s+/)) {
34
+ // Trim surrounding punctuation only — do NOT strip internal dots, or we
35
+ // would erase the extension separator (src/server.ts -> src/server/ts).
36
+ const token = raw.replace(/^[^A-Za-z0-9/]+|[^A-Za-z0-9/]+$/g, "");
37
+ if (!token.includes("/") || !token.includes("."))
38
+ continue;
39
+ const ext = token.split(".").pop()?.toLowerCase() ?? "";
40
+ if (INTERESTING_EXT.has(ext))
41
+ out.push(token);
42
+ }
43
+ return out;
44
+ }
45
+ /** Collect unique key files referenced across a set of messages. */
46
+ export function collectKeyFiles(messages) {
47
+ const files = new Set();
48
+ for (const m of messages) {
49
+ for (const c of [m.text, m.input, m.output]) {
50
+ if (!c)
51
+ continue;
52
+ for (const f of extractFileCandidates(c))
53
+ files.add(f);
54
+ }
55
+ }
56
+ return [...files].slice(0, 8);
57
+ }
58
+ /** Infer pending work from recent messages via keyword scan. */
59
+ export function inferPendingWork(messages) {
60
+ const out = [];
61
+ for (const m of [...messages].reverse()) {
62
+ const t = firstText(m);
63
+ if (!t)
64
+ continue;
65
+ const low = t.toLowerCase();
66
+ if (PENDING_WORDS.some((w) => low.includes(w))) {
67
+ out.push(truncate(t, 160));
68
+ if (out.length >= 3)
69
+ break;
70
+ }
71
+ }
72
+ return out.reverse();
73
+ }
74
+ /** Latest user request (for "current work" line). */
75
+ export function inferCurrentWork(messages) {
76
+ for (const m of [...messages].reverse()) {
77
+ const t = firstText(m);
78
+ if (t && m.role === "user")
79
+ return truncate(t, 200);
80
+ }
81
+ return undefined;
82
+ }
83
+ /** Last N user requests, in original order. */
84
+ export function collectRecentUserRequests(messages, limit) {
85
+ const reqs = messages
86
+ .filter((m) => m.role === "user")
87
+ .map((m) => firstText(m))
88
+ .filter((t) => Boolean(t))
89
+ .map((t) => truncate(t, 160));
90
+ return reqs.slice(-limit);
91
+ }
92
+ /** Summarize a block to a one-line description. */
93
+ function summarizeBlock(m) {
94
+ if (m.role === "tool")
95
+ return `tool_result ${m.toolName ?? "?"}: ${truncate(m.output ?? m.text, 160)}`;
96
+ if (m.toolName)
97
+ return `tool_use ${m.toolName}(${truncate(m.input ?? "", 160)})`;
98
+ return truncate(m.text, 160);
99
+ }
100
+ function stripTag(block, tag) {
101
+ const start = `<${tag}>`;
102
+ const end = `</${tag}>`;
103
+ const s = block.indexOf(start);
104
+ const e = block.indexOf(end);
105
+ if (s === -1 || e === -1)
106
+ return block;
107
+ return block.slice(0, s) + block.slice(e + end.length);
108
+ }
109
+ function extractTag(block, tag) {
110
+ const s = block.indexOf(`<${tag}>`);
111
+ const e = block.indexOf(`</${tag}>`);
112
+ if (s === -1 || e === -1)
113
+ return undefined;
114
+ return block.slice(s + `<${tag}>`.length, e);
115
+ }
116
+ /** Normalize a raw summary into user-facing "Summary: ..." text. */
117
+ export function formatCompactSummary(summary) {
118
+ const withoutAnalysis = stripTag(summary, "analysis");
119
+ let formatted = withoutAnalysis;
120
+ const content = extractTag(withoutAnalysis, "summary");
121
+ if (content !== undefined) {
122
+ formatted = withoutAnalysis.replace(`<summary>${content}</summary>`, `Summary:\n${content.trim()}`);
123
+ }
124
+ return formatted.replace(/\n{3,}/g, "\n\n").trim();
125
+ }
126
+ /**
127
+ * Build a <summary> block from a slice of messages (the COLLAPSE output).
128
+ * Mirrors claw-code summarize_messages.
129
+ */
130
+ export function summarizeMessages(messages) {
131
+ const users = messages.filter((m) => m.role === "user").length;
132
+ const assistants = messages.filter((m) => m.role === "assistant").length;
133
+ const tools = messages.filter((m) => m.role === "tool").length;
134
+ const toolNames = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
135
+ const lines = [
136
+ "<summary>",
137
+ "Conversation summary:",
138
+ `- Scope: ${messages.length} earlier messages compacted (user=${users}, assistant=${assistants}, tool=${tools}).`,
139
+ ];
140
+ if (toolNames.length)
141
+ lines.push(`- Tools mentioned: ${toolNames.join(", ")}.`);
142
+ const recent = collectRecentUserRequests(messages, 3);
143
+ if (recent.length) {
144
+ lines.push("- Recent user requests:");
145
+ recent.forEach((r) => lines.push(` - ${r}`));
146
+ }
147
+ const pending = inferPendingWork(messages);
148
+ if (pending.length) {
149
+ lines.push("- Pending work:");
150
+ pending.forEach((p) => lines.push(` - ${p}`));
151
+ }
152
+ const files = collectKeyFiles(messages);
153
+ if (files.length)
154
+ lines.push(`- Key files referenced: ${files.join(", ")}.`);
155
+ const current = inferCurrentWork(messages);
156
+ if (current)
157
+ lines.push(`- Current work: ${current}`);
158
+ lines.push("- Key timeline:");
159
+ for (const m of messages) {
160
+ const role = m.role;
161
+ lines.push(` - ${role}: ${summarizeBlock(m)}`);
162
+ }
163
+ lines.push("</summary>");
164
+ return lines.join("\n");
165
+ }
166
+ /** Extract the prior "highlights" + "timeline" sections from an existing summary. */
167
+ function extractSummaryHighlights(summary) {
168
+ const lines = formatCompactSummary(summary).split("\n");
169
+ const out = [];
170
+ let inTimeline = false;
171
+ for (const line of lines) {
172
+ const t = line.trimEnd();
173
+ if (!t || t === "Summary:" || t === "Conversation summary:")
174
+ continue;
175
+ if (t === "- Key timeline:") {
176
+ inTimeline = true;
177
+ continue;
178
+ }
179
+ if (inTimeline)
180
+ continue;
181
+ out.push(t);
182
+ }
183
+ return out;
184
+ }
185
+ function extractSummaryTimeline(summary) {
186
+ const lines = formatCompactSummary(summary).split("\n");
187
+ const out = [];
188
+ let inTimeline = false;
189
+ for (const line of lines) {
190
+ const t = line.trimEnd();
191
+ if (t === "- Key timeline:") {
192
+ inTimeline = true;
193
+ continue;
194
+ }
195
+ if (!inTimeline)
196
+ continue;
197
+ if (!t)
198
+ break;
199
+ out.push(t);
200
+ }
201
+ return out;
202
+ }
203
+ /** Merge an existing compact summary with a new one (accumulate, don't overwrite). */
204
+ export function mergeCompactSummaries(existing, newSummary) {
205
+ if (!existing)
206
+ return newSummary;
207
+ const prevHighlights = extractSummaryHighlights(existing);
208
+ const newHighlights = extractSummaryHighlights(formatCompactSummary(newSummary));
209
+ const newTimeline = extractSummaryTimeline(formatCompactSummary(newSummary));
210
+ const lines = ["<summary>", "Conversation summary:"];
211
+ if (prevHighlights.length) {
212
+ lines.push("- Previously compacted context:");
213
+ prevHighlights.forEach((l) => lines.push(` ${l}`));
214
+ }
215
+ if (newHighlights.length) {
216
+ lines.push("- Newly compacted context:");
217
+ newHighlights.forEach((l) => lines.push(` ${l}`));
218
+ }
219
+ if (newTimeline.length) {
220
+ lines.push("- Key timeline:");
221
+ newTimeline.forEach((l) => lines.push(` ${l}`));
222
+ }
223
+ lines.push("</summary>");
224
+ return lines.join("\n");
225
+ }
226
+ /** True when the compactable portion exceeds the budget. */
227
+ export function shouldCompact(messages, maxEstimatedTokens, preserveRecent) {
228
+ if (messages.length <= preserveRecent)
229
+ return false;
230
+ const compactable = messages.slice(0, messages.length - preserveRecent);
231
+ return estimateSessionTokens(compactable) >= maxEstimatedTokens;
232
+ }
233
+ /** Local reimplementation of memory-mcp auto_compact_check. */
234
+ export function autoCompactCheck(currentTokens, threshold = 50000) {
235
+ return {
236
+ shouldCompact: currentTokens >= threshold,
237
+ currentTokens,
238
+ threshold,
239
+ utilizationPct: Math.round((currentTokens / threshold) * 1000) / 10,
240
+ };
241
+ }
242
+ /** Build the synthetic continuation message (system-prompt prepend form). */
243
+ export function getContinuationMessage(summary, suppressFollowUp, recentPreserved) {
244
+ let base = COMPACT_PREAMBLE + formatCompactSummary(summary);
245
+ if (recentPreserved)
246
+ base += `\n\n${RECENT_NOTE}`;
247
+ if (suppressFollowUp)
248
+ base += `\n${DIRECT_RESUME}`;
249
+ return base;
250
+ }