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,370 @@
1
+ /**
2
+ * openclaw-mega-compact — OpenClaw plugin adapter for the pi-mega-compact engine.
3
+ *
4
+ * Wires the pi-agnostic Trident engine (src/) into OpenClaw's plugin lifecycle:
5
+ * - Registers a CompactionProvider that replaces the built-in summarizeInStages.
6
+ * - Exposes `mega_status` and `mega_recall` tools for on-demand inspection.
7
+ * - Hooks into `before_compaction` / `after_compaction` for diagnostics.
8
+ *
9
+ * Design constraints:
10
+ * - NO imports from `@earendil-works/pi-coding-agent` or pi-agent-core.
11
+ * - The engine core (src/) is pi-agnostic; this file is the sole OpenClaw boundary.
12
+ * - No network at runtime — everything is local (stores + extractive summarizer).
13
+ */
14
+
15
+ import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
16
+ import type { CompactionProvider } from "openclaw/plugin-sdk/compaction-provider";
17
+
18
+ import {
19
+ compactSession,
20
+ setDefaultStore,
21
+ type CompactInput,
22
+ type CompactResult,
23
+ } from "../src/engine.js";
24
+ import { recallAndInline, type RecallInjectResult } from "../src/recall.js";
25
+ import { VectorStore } from "../src/vectorStore.js";
26
+ import type { EngineMessage } from "../src/types.js";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Constants
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const PLUGIN_ID = "mega-compact";
33
+ const PLUGIN_LABEL = "Mega Compact (Trident)";
34
+
35
+ /** Default state directory for vector store persistence. */
36
+ const STATE_DIR = process.env.MEGA_COMPACT_STATE_DIR ?? undefined;
37
+
38
+ /** Minimum messages before we bother compacting. */
39
+ const MIN_MESSAGES_FOR_COMPACT = 6;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Message conversion — OpenClaw unknown[] → EngineMessage[]
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Best-effort conversion from OpenClaw's opaque message array to our
47
+ * EngineMessage shape. OpenClaw messages are typed as `unknown[]` so we
48
+ * handle whatever shape comes through gracefully.
49
+ */
50
+ function toEngineMessages(messages: unknown[]): EngineMessage[] {
51
+ return messages.map((msg) => {
52
+ if (!msg || typeof msg !== "object") {
53
+ // Primitive fallback — treat as custom text.
54
+ return {
55
+ role: "custom" as const,
56
+ text: String(msg ?? ""),
57
+ };
58
+ }
59
+
60
+ const m = msg as Record<string, unknown>;
61
+ const role = typeof m.role === "string" ? m.role : "custom";
62
+
63
+ // Normalize role to one of our four engine roles.
64
+ let engineRole: EngineMessage["role"];
65
+ switch (role) {
66
+ case "user":
67
+ engineRole = "user";
68
+ break;
69
+ case "assistant":
70
+ engineRole = "assistant";
71
+ break;
72
+ case "tool":
73
+ case "function":
74
+ engineRole = "tool";
75
+ break;
76
+ default:
77
+ engineRole = "custom";
78
+ break;
79
+ }
80
+
81
+ // Extract text content from common message shapes.
82
+ const text =
83
+ typeof m.content === "string"
84
+ ? m.content
85
+ : typeof m.text === "string"
86
+ ? m.text
87
+ : Array.isArray(m.content)
88
+ ? (m.content as Array<{ type?: string; text?: string }>)
89
+ .filter((part) => part.type === "text" && typeof part.text === "string")
90
+ .map((part) => part.text)
91
+ .join("\n")
92
+ : "";
93
+
94
+ // Preserve tool metadata when present.
95
+ const toolName =
96
+ typeof m.name === "string"
97
+ ? m.name
98
+ : typeof m.toolName === "string"
99
+ ? m.toolName
100
+ : undefined;
101
+
102
+ const input =
103
+ typeof m.input === "string"
104
+ ? m.input
105
+ : typeof m.arguments === "string"
106
+ ? m.arguments
107
+ : m.arguments !== undefined
108
+ ? JSON.stringify(m.arguments)
109
+ : undefined;
110
+
111
+ const output =
112
+ typeof m.output === "string"
113
+ ? m.output
114
+ : engineRole === "tool" && typeof m.content === "string"
115
+ ? m.content
116
+ : undefined;
117
+
118
+ return { role: engineRole, text, toolName, input, output };
119
+ });
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Compaction provider
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function createCompactionProvider(store: VectorStore): CompactionProvider {
127
+ return {
128
+ id: PLUGIN_ID,
129
+ label: PLUGIN_LABEL,
130
+
131
+ async summarize({
132
+ messages,
133
+ signal,
134
+ compressionRatio,
135
+ }): Promise<string> {
136
+ // Abort check — bail early if the caller cancelled.
137
+ if (signal?.aborted) {
138
+ throw new DOMException("Aborted", "AbortError");
139
+ }
140
+
141
+ const engineMessages = toEngineMessages(messages);
142
+
143
+ // Nothing meaningful to compact.
144
+ if (engineMessages.length < MIN_MESSAGES_FOR_COMPACT) {
145
+ return "";
146
+ }
147
+
148
+ // Map compression ratio → keepFrom boundary.
149
+ // compressionRatio=0.5 means "compact the oldest 50%".
150
+ // Default to compacting the oldest half if not specified.
151
+ const ratio = compressionRatio ?? 0.5;
152
+ const keepFrom = Math.max(
153
+ MIN_MESSAGES_FOR_COMPACT,
154
+ Math.floor(engineMessages.length * (1 - ratio)),
155
+ );
156
+
157
+ // Abort check after conversion (conversion is cheap but check anyway).
158
+ if (signal?.aborted) {
159
+ throw new DOMException("Aborted", "AbortError");
160
+ }
161
+
162
+ const sessionId = `openclaw-${Date.now()}`;
163
+
164
+ const input: CompactInput = {
165
+ sessionId,
166
+ messages: engineMessages,
167
+ keepFrom,
168
+ };
169
+
170
+ const result: CompactResult = compactSession(input, store);
171
+
172
+ if (result.skipped) {
173
+ return "";
174
+ }
175
+
176
+ return result.summary;
177
+ },
178
+ };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Plugin entry
183
+ // ---------------------------------------------------------------------------
184
+
185
+ export default definePluginEntry({
186
+ id: PLUGIN_ID,
187
+ name: "Mega Compact",
188
+ description:
189
+ "Layered, local, vector-backed context compressor (Trident engine) for OpenClaw compaction.",
190
+
191
+ register(api: OpenClawPluginApi) {
192
+ const logger = api.logger;
193
+
194
+ // Resolve state directory — prefer plugin config override.
195
+ const pluginCfg = (api.pluginConfig ?? {}) as Record<string, unknown>;
196
+ const stateDir =
197
+ typeof pluginCfg.stateDir === "string" && pluginCfg.stateDir.length > 0
198
+ ? pluginCfg.stateDir
199
+ : STATE_DIR;
200
+
201
+ // Initialize vector store.
202
+ let store: VectorStore;
203
+ try {
204
+ store = new VectorStore({ stateDir });
205
+ setDefaultStore(store);
206
+ logger.info?.(`${PLUGIN_ID}: vector store initialized (stateDir=${stateDir ?? "default"})`);
207
+ } catch (err) {
208
+ logger.error?.(`${PLUGIN_ID}: failed to init vector store:`, err);
209
+ return; // Hard bail — no point registering if store is broken.
210
+ }
211
+
212
+ // -----------------------------------------------------------------------
213
+ // Register compaction provider
214
+ // -----------------------------------------------------------------------
215
+ const provider = createCompactionProvider(store);
216
+
217
+ api.registerCompactionProvider(provider);
218
+ logger.info?.(`${PLUGIN_ID}: registered compaction provider "${provider.id}"`);
219
+
220
+ // -----------------------------------------------------------------------
221
+ // Hooks — before / after compaction diagnostics
222
+ // -----------------------------------------------------------------------
223
+ api.registerHook({
224
+ event: "before_compaction",
225
+ handler: async (ctx) => {
226
+ const msgCount = Array.isArray(ctx?.messages) ? ctx.messages.length : 0;
227
+ logger.info?.(`${PLUGIN_ID}: before_compaction — ${msgCount} messages in scope`);
228
+ },
229
+ });
230
+
231
+ api.registerHook({
232
+ event: "after_compaction",
233
+ handler: async (ctx) => {
234
+ const summaryLen =
235
+ typeof ctx?.summary === "string" ? ctx.summary.length : 0;
236
+ logger.info?.(
237
+ `${PLUGIN_ID}: after_compaction — summary ${summaryLen} chars`,
238
+ );
239
+ },
240
+ });
241
+
242
+ // -----------------------------------------------------------------------
243
+ // Tool: mega_status
244
+ // -----------------------------------------------------------------------
245
+ api.registerTool({
246
+ name: "mega_status",
247
+ description:
248
+ "Show the current status of the mega-compact engine: vector store stats, checkpoint count, and recent compaction activity.",
249
+ parameters: {
250
+ type: "object",
251
+ properties: {
252
+ sessionId: {
253
+ type: "string",
254
+ description: "Optional session ID to scope stats to.",
255
+ },
256
+ },
257
+ additionalProperties: false,
258
+ },
259
+ handler: async (args) => {
260
+ const sessionId = (args as Record<string, string>)?.sessionId ?? "global";
261
+
262
+ try {
263
+ const stats = store.stats(sessionId);
264
+ const parts: string[] = [
265
+ `**Mega Compact Status**`,
266
+ `Session: ${sessionId}`,
267
+ `Checkpoints: ${stats.checkpointCount}`,
268
+ `Total tokens saved: ${stats.totalTokenEstimate}`,
269
+ `Last checkpoint: ${stats.lastCheckpointId ?? "—"}`,
270
+ `Injected count: ${stats.injectedCount}`,
271
+ `Dedup hit rate: ${(stats.dedupHitRate * 100).toFixed(0)}%`,
272
+ ];
273
+
274
+ if (stats.lastSummary) {
275
+ parts.push(
276
+ `\nLast summary (truncated):\n ${stats.lastSummary.slice(0, 120).replace(/\n/g, " ")}…`,
277
+ );
278
+ }
279
+
280
+ return { content: [{ type: "text", text: parts.join("\n") }] };
281
+ } catch (err) {
282
+ return {
283
+ content: [{ type: "text", text: `Error reading mega-compact status: ${err}` }],
284
+ isError: true,
285
+ };
286
+ }
287
+ },
288
+ });
289
+
290
+ // -----------------------------------------------------------------------
291
+ // Tool: mega_recall
292
+ // -----------------------------------------------------------------------
293
+ api.registerTool({
294
+ name: "mega_recall",
295
+ description:
296
+ "Recall and inline relevant context from the mega-compact vector store for the current session.",
297
+ parameters: {
298
+ type: "object",
299
+ properties: {
300
+ sessionId: {
301
+ type: "string",
302
+ description: "Session ID to recall context for.",
303
+ },
304
+ query: {
305
+ type: "string",
306
+ description: "Natural language query for relevant context.",
307
+ },
308
+ limit: {
309
+ type: "number",
310
+ description: "Max checkpoints to recall (default 3).",
311
+ },
312
+ },
313
+ required: ["sessionId", "query"],
314
+ additionalProperties: false,
315
+ },
316
+ handler: async (args) => {
317
+ const { sessionId, query, limit } = args as {
318
+ sessionId: string;
319
+ query: string;
320
+ limit?: number;
321
+ };
322
+
323
+ if (!sessionId || !query) {
324
+ return {
325
+ content: [{ type: "text", text: "Both `sessionId` and `query` are required." }],
326
+ isError: true,
327
+ };
328
+ }
329
+
330
+ try {
331
+ const result: RecallInjectResult = recallAndInline(
332
+ { sessionId, query, limit: limit ?? 3, source: "command", skipInjected: false },
333
+ store,
334
+ );
335
+
336
+ if (result.toInject.length === 0) {
337
+ return {
338
+ content: [{ type: "text", text: "No relevant context found in the mega-compact store." }],
339
+ };
340
+ }
341
+
342
+ const parts: string[] = [
343
+ `**Recalled ${result.toInject.length} checkpoint(s):**`,
344
+ ...result.report,
345
+ "",
346
+ "---",
347
+ result.block,
348
+ ];
349
+
350
+ return { content: [{ type: "text", text: parts.join("\n") }] };
351
+ } catch (err) {
352
+ return {
353
+ content: [{ type: "text", text: `Error during mega-recall: ${err}` }],
354
+ isError: true,
355
+ };
356
+ }
357
+ },
358
+ });
359
+
360
+ // -----------------------------------------------------------------------
361
+ // Cleanup on shutdown
362
+ // -----------------------------------------------------------------------
363
+ api.on("shutdown", () => {
364
+ logger.info?.(`${PLUGIN_ID}: shutting down — clearing default store`);
365
+ setDefaultStore(undefined);
366
+ });
367
+
368
+ logger.info?.(`${PLUGIN_ID}: plugin registered (tools: mega_status, mega_recall)`);
369
+ },
370
+ });
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pi-mega-compact",
3
+ "version": "0.4.0",
4
+ "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
+ "type": "module",
6
+ "license": "BSD-2-Clause",
7
+ "author": "TheArchitectit",
8
+ "homepage": "https://github.com/TheArchitectit/pi-mega-compact",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/TheArchitectit/pi-mega-compact.git"
12
+ },
13
+ "keywords": [
14
+ "pi-extension",
15
+ "pi-coding-agent",
16
+ "openclaw-plugin",
17
+ "context-compaction",
18
+ "auto-compact",
19
+ "vector-store",
20
+ "trident"
21
+ ],
22
+ "engines": {
23
+ "node": ">=18"
24
+ },
25
+ "files": [
26
+ "extensions",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "pi": {
32
+ "extensions": [
33
+ "./extensions/mega-compact.ts"
34
+ ]
35
+ },
36
+ "openclaw": {
37
+ "plugin": "./extensions/openclaw-mega-compact.ts",
38
+ "manifest": "./openclaw.plugin.json",
39
+ "compactionProvider": "mega-compact"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.json",
43
+ "lint": "tsc --noEmit && node scripts/guardrails-scan.mjs",
44
+ "test": "npm run build && node --test \"dist/src/**/*.test.js\" \"dist/extensions/**/*.test.js\"",
45
+ "guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs",
46
+ "precommit": "bash .claude/hooks/pre-commit.sh"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "openclaw": ">=0.1.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/better-sqlite3": "^7.6.13",
54
+ "@types/node": "^20.0.0",
55
+ "typescript": "^5.4.0"
56
+ },
57
+ "dependencies": {
58
+ "@mongodb-js/zstd": "^7.0.0",
59
+ "better-sqlite3": "^12.11.1"
60
+ }
61
+ }
package/src/adapt.ts ADDED
@@ -0,0 +1,120 @@
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
+
14
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
15
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
16
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
17
+ import type { EngineMessage } from "./types.js";
18
+ import { computeDropRange } from "./boundary.js";
19
+
20
+ /** Coarse role the engine cares about. */
21
+ export type EngineRole = "user" | "assistant" | "tool" | "custom";
22
+
23
+ /** Map a pi AgentMessage role to the engine's coarse role. */
24
+ export function messageRole(m: AgentMessage): EngineRole {
25
+ if (m.role === "toolResult") return "tool";
26
+ if (m.role === "user" || m.role === "assistant") return m.role;
27
+ // custom / bashExecution / branchSummary / compactionSummary — all non-LLM
28
+ // bookkeeping as far as the engine is concerned.
29
+ return "custom";
30
+ }
31
+
32
+ /** Extract a tool name from a message, if it carries a tool call/result. */
33
+ export function messageToolName(m: AgentMessage): string | undefined {
34
+ if (m.role === "toolResult") return m.toolName;
35
+ if (m.role === "assistant") {
36
+ const tc = (m.content as Array<{ type: string; name?: string }>).find(
37
+ (c) => c.type === "toolCall",
38
+ );
39
+ return tc?.name;
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ /** Pull the text out of a string-or-blocks content field. */
45
+ function contentText(content: string | Array<{ type: string; text?: string }>): string {
46
+ if (typeof content === "string") return content;
47
+ return content
48
+ .filter((c) => c.type === "text" && typeof c.text === "string")
49
+ .map((c) => c.text as string)
50
+ .join("\n");
51
+ }
52
+
53
+ /** Project any AgentMessage into a single text blob the engine can reason on. */
54
+ function messageText(m: AgentMessage): string {
55
+ switch (m.role) {
56
+ case "toolResult":
57
+ case "user":
58
+ case "assistant":
59
+ case "custom":
60
+ return contentText((m as { content: string | Array<{ type: string; text?: string }> }).content);
61
+ case "bashExecution":
62
+ return `${(m as { command: string }).command}\n${(m as { output: string }).output}`;
63
+ case "branchSummary":
64
+ case "compactionSummary":
65
+ return (m as { summary: string }).summary;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Convert a pi message array into the engine's EngineMessage view, keeping
71
+ * index alignment (output[i] corresponds to input[i]).
72
+ */
73
+ export function toEngineMessages(messages: AgentMessage[]): EngineMessage[] {
74
+ return messages.map((m) => {
75
+ const role = messageRole(m);
76
+ const toolName = messageToolName(m);
77
+ const text = messageText(m);
78
+ if (m.role === "toolResult") {
79
+ return { role, text, toolName, output: text } satisfies EngineMessage;
80
+ }
81
+ if (m.role === "assistant") {
82
+ const blocks = m.content as Array<{ type: string; text?: string; arguments?: unknown }>;
83
+ const callBlock = blocks.find((c) => c.type === "toolCall");
84
+ const input = callBlock
85
+ ? typeof callBlock.arguments === "string"
86
+ ? callBlock.arguments
87
+ : JSON.stringify(callBlock.arguments ?? {})
88
+ : undefined;
89
+ return { role, text, toolName, input } satisfies EngineMessage;
90
+ }
91
+ return { role, text, toolName } satisfies EngineMessage;
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Project session entries into the engine view. Reuses pi's own
97
+ * sessionEntryToContextMessages so branching/compaction entries are resolved the
98
+ * same way the runtime would.
99
+ */
100
+ export function toEngineFromEntries(entries: SessionEntry[]): EngineMessage[] {
101
+ return entries.flatMap((e) => toEngineMessages(sessionEntryToContextMessages(e)));
102
+ }
103
+
104
+ /**
105
+ * Compute the safe drop range over a pi message array using the engine's
106
+ * boundary guards (anchor floor + tool-pair), then return the surviving
107
+ * messages. Reuses the tested `computeDropRange` on an engine view and maps the
108
+ * indices back onto the original array (index alignment guarantees correctness).
109
+ */
110
+ export function dropCompactedRange(
111
+ messages: AgentMessage[],
112
+ keepFrom: number,
113
+ anchorUserMessages: number,
114
+ ): AgentMessage[] {
115
+ if (messages.length === 0) return messages;
116
+ const view = toEngineMessages(messages);
117
+ const [, dropEnd] = computeDropRange(view, keepFrom, anchorUserMessages);
118
+ if (dropEnd <= 0) return messages;
119
+ return messages.slice(dropEnd);
120
+ }
@@ -0,0 +1,61 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { EngineMessage } from "./types.js";
4
+ import { computeDropRange, isBoundarySafe, dropBefore } from "./boundary.js";
5
+
6
+ function user(t: string): EngineMessage { return { role: "user", text: t }; }
7
+ function assistant(t: string): EngineMessage { return { role: "assistant", text: t }; }
8
+ function toolUse(n: string, i = "{}"): EngineMessage { return { role: "assistant", text: "", toolName: n, input: i }; }
9
+ function toolResult(n: string, o = "ok"): EngineMessage { return { role: "tool", text: "", toolName: n, output: o }; }
10
+
11
+ test("walks back so first preserved message is not an orphaned tool result", () => {
12
+ const messages = [
13
+ user("Search for files"),
14
+ toolUse("search"),
15
+ toolResult("search", "found 5 files"),
16
+ assistant("Done."),
17
+ ];
18
+ // keepFrom=2 would start the preserved run on the tool result at index 2,
19
+ // orphaning it. The guard walks back to include the assistant tool-call.
20
+ const [start, end] = computeDropRange(messages, 2, 0);
21
+ assert.equal(start, 0);
22
+ assert.equal(end, 1);
23
+ const kept = messages.slice(end);
24
+ assert.notEqual(kept[0].role, "tool");
25
+ assert.equal(kept[0].toolName, "search"); // assistant tool-call preserved
26
+ });
27
+
28
+ test("isBoundarySafe: tool result at boundary with preceding tool use is safe", () => {
29
+ const messages = [user("a"), toolUse("search"), toolResult("search")];
30
+ assert.equal(isBoundarySafe(messages, 2), true);
31
+ });
32
+
33
+ test("isBoundarySafe: orphaned tool result without preceding tool use is unsafe", () => {
34
+ const messages = [user("a"), toolResult("search", "orphan")];
35
+ assert.equal(isBoundarySafe(messages, 1), false);
36
+ });
37
+
38
+ test("anchor floor preserves the last N user messages", () => {
39
+ const messages = [
40
+ user("u1"), user("u2"), user("u3"),
41
+ assistant("a1"), assistant("a2"), assistant("a3"), assistant("a4"), assistant("a5"),
42
+ ];
43
+ // Caller wants to keep from index 2 (would drop u2). Anchor=2 forces keeping
44
+ // from u2 (index 1) onward.
45
+ const out = dropBefore(messages, 2, 2);
46
+ assert.ok(out.some((m) => m.text === "u2"));
47
+ assert.ok(out.some((m) => m.text === "u3"));
48
+ });
49
+
50
+ test("anchor floor is a no-op when fewer users than anchor", () => {
51
+ const messages = [user("u1"), assistant("a1"), assistant("a2"), assistant("a3")];
52
+ const out = dropBefore(messages, 1, 2);
53
+ // only 1 user, anchor=2 → no floor; keep from index 1 (drop the user)
54
+ assert.ok(!out.some((m) => m.text === "u1"));
55
+ assert.equal(out.length, 3);
56
+ });
57
+
58
+ test("dropBefore returns original when range is empty", () => {
59
+ const messages = [user("a"), assistant("b")];
60
+ assert.equal(dropBefore(messages, 0, 1), messages);
61
+ });