pi-mega-compact 0.4.28 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -2
- package/dist/extensions/dashboard-server.js +66 -3
- package/dist/extensions/dashboard-server.test.js +95 -3
- package/dist/extensions/mega-commands.js +25 -9
- package/dist/extensions/mega-compact.test.js +133 -31
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-conflict-cmds.js +79 -0
- package/dist/extensions/mega-dashboard-cmds.js +6 -4
- package/dist/extensions/mega-events.js +144 -27
- package/dist/extensions/mega-pipeline.js +84 -1
- package/dist/extensions/mega-runtime.js +35 -2
- package/dist/extensions/mega-trim.js +48 -0
- package/dist/extensions/mega-trim.test.js +58 -0
- package/dist/src/config/dedup.js +1 -0
- package/dist/src/driftDetection.js +103 -0
- package/dist/src/driftDetection.test.js +87 -0
- package/dist/src/memory.js +147 -0
- package/dist/src/memory.test.js +41 -0
- package/dist/src/memoryConsolidate.test.js +38 -0
- package/dist/src/memoryOps.js +58 -0
- package/dist/src/memoryOps.test.js +41 -0
- package/dist/src/memoryRecall.js +60 -0
- package/dist/src/memoryRecall.test.js +92 -0
- package/dist/src/recall.js +70 -1
- package/dist/src/recall.test.js +69 -1
- package/dist/src/store/sqlite.js +127 -11
- package/dist/src/vectorStore.js +6 -1
- package/extensions/dashboard-server.test.ts +115 -3
- package/extensions/dashboard-server.ts +69 -4
- package/extensions/mega-commands.ts +24 -9
- package/extensions/mega-compact.test.ts +134 -31
- package/extensions/mega-config.ts +22 -0
- package/extensions/mega-conflict-cmds.ts +81 -0
- package/extensions/mega-dashboard-cmds.ts +6 -4
- package/extensions/mega-events.ts +139 -28
- package/extensions/mega-pipeline.ts +94 -1
- package/extensions/mega-runtime.ts +35 -2
- package/extensions/mega-trim.test.ts +64 -0
- package/extensions/mega-trim.ts +75 -0
- package/extensions/openclaw-mega-compact.ts +24 -9
- package/package.json +2 -2
- package/src/config/dedup.ts +2 -0
- package/src/driftDetection.test.ts +100 -0
- package/src/driftDetection.ts +136 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +164 -0
- package/src/memoryConsolidate.test.ts +47 -0
- package/src/memoryOps.test.ts +53 -0
- package/src/memoryOps.ts +75 -0
- package/src/memoryRecall.test.ts +100 -0
- package/src/memoryRecall.ts +83 -0
- package/src/recall.test.ts +77 -1
- package/src/recall.ts +94 -1
- package/src/store/sqlite.ts +188 -11
- package/src/store.ts +3 -0
- package/src/vectorStore.ts +10 -1
|
@@ -12,10 +12,11 @@ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
13
13
|
import { compactSession } from "../src/engine.js";
|
|
14
14
|
import type { EngineMessage } from "../src/types.js";
|
|
15
|
-
import { recallAndInline } from "../src/recall.js";
|
|
15
|
+
import { recallAndInline, recallAndInlineAsync, formatRecallBlock, type RecallInjectResult } from "../src/recall.js";
|
|
16
16
|
import { normalizeSessionId } from "../src/store.js";
|
|
17
17
|
import { estimateBlockTokens } from "../src/tokens.js";
|
|
18
18
|
import { touchSession, logDaily } from "../src/store/sqlite.js";
|
|
19
|
+
import { consolidateMemories } from "../src/memory.js";
|
|
19
20
|
import {
|
|
20
21
|
MegaRuntime,
|
|
21
22
|
C,
|
|
@@ -77,6 +78,10 @@ function doCompact(
|
|
|
77
78
|
runtime: MegaRuntime,
|
|
78
79
|
): RunCompactResult {
|
|
79
80
|
runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
|
|
81
|
+
// S21.2: reset the per-compaction memory-op counter so the post-compact
|
|
82
|
+
// consolidate pass only fires when memory rows actually changed during the
|
|
83
|
+
// compaction window (turn_end → auto-review may have written some).
|
|
84
|
+
runtime.memoriesTouchedThisCompaction = 0;
|
|
80
85
|
const result = compactSession(
|
|
81
86
|
{
|
|
82
87
|
sessionId: sid,
|
|
@@ -148,6 +153,28 @@ function doCompact(
|
|
|
148
153
|
/* non-fatal: stats bookkeeping only */
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
// S21.2: best-effort consolidation of near-duplicate memories for this repo.
|
|
157
|
+
// Runs after the per-repo stats touch so `consolidateMemories` can use the
|
|
158
|
+
// same stateDir. Non-fatal — a failed consolidate never blocks a compaction.
|
|
159
|
+
// Only runs when new memory ops landed in this pass (otherwise the prior
|
|
160
|
+
// compaction's consolidate already had its shot — re-running would just
|
|
161
|
+
// touch every row again with no merges).
|
|
162
|
+
if (!result.deduped && runtime.memoriesTouchedThisCompaction > 0) {
|
|
163
|
+
try {
|
|
164
|
+
const root = resolveRepoRoot(ctx.cwd);
|
|
165
|
+
void consolidateMemories(runtime.currentStateDir, root).then(
|
|
166
|
+
(n) => {
|
|
167
|
+
if (n > 0) runtime.pushTicker(`${C.green}∫${C.reset} consolidated ${n} memory dup${n === 1 ? "" : "s"}`);
|
|
168
|
+
},
|
|
169
|
+
() => {
|
|
170
|
+
/* swallow: consolidate failures must never surface to the user */
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
} catch {
|
|
174
|
+
/* non-fatal */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
151
178
|
// Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
|
|
152
179
|
// skip re-vectorizing an already-compacted region (zero token cost).
|
|
153
180
|
pi.appendEntry(MARKER_TYPE, {
|
|
@@ -363,6 +390,72 @@ export function doRecall(
|
|
|
363
390
|
return result;
|
|
364
391
|
}
|
|
365
392
|
|
|
393
|
+
/**
|
|
394
|
+
* S17: async recall with optional cross-repo augmentation. Used on resume
|
|
395
|
+
* (session_start) and /mega-recall --cross-repo — NEVER from the mid-turn
|
|
396
|
+
* context handler (that stays sync). Runs the sync same-repo scan first; if it
|
|
397
|
+
* returns < config.autoInlineK hits AND crossRepo is enabled, awaits the PGlite
|
|
398
|
+
* HNSW cross-repo path and merges (source-labeled, deduped by checkpointId). The
|
|
399
|
+
* recallMaxTokens cap + windowDedupe apply to the merged set so cross-repo can
|
|
400
|
+
* never net-inflate the window. Cross-repo uses a stricter cosine floor
|
|
401
|
+
* (config.crossRepoCosine) than same-repo. Non-fatal: any async failure returns
|
|
402
|
+
* the same-repo result unchanged.
|
|
403
|
+
*/
|
|
404
|
+
export async function doRecallAsync(
|
|
405
|
+
runtime: MegaRuntime,
|
|
406
|
+
config: MegaConfig,
|
|
407
|
+
ctx: ExtensionContext,
|
|
408
|
+
query: string,
|
|
409
|
+
source: "resume" | "command",
|
|
410
|
+
opts: { crossRepo?: boolean } = {},
|
|
411
|
+
): Promise<RecallInjectResult> {
|
|
412
|
+
runtime.bindRepo(ctx.cwd);
|
|
413
|
+
const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
|
|
414
|
+
const liveWindow = config.windowDedupe ? extractLiveWindow(ctx) : undefined;
|
|
415
|
+
// Sync same-repo first (fast, never blocks).
|
|
416
|
+
const sameRepo = recallAndInline(
|
|
417
|
+
{
|
|
418
|
+
sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
|
|
419
|
+
recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
|
|
420
|
+
liveWindow, dedupSim: config.dedupSim,
|
|
421
|
+
},
|
|
422
|
+
runtime.store,
|
|
423
|
+
);
|
|
424
|
+
if (!config.crossRepoEnabled || !opts.crossRepo) return sameRepo;
|
|
425
|
+
if (sameRepo.toInject.length >= config.autoInlineK) return sameRepo; // same-repo satisfied
|
|
426
|
+
// Augment: cross-repo HNSW (async) with the stricter floor. Non-fatal.
|
|
427
|
+
try {
|
|
428
|
+
const x = await recallAndInlineAsync(
|
|
429
|
+
{
|
|
430
|
+
sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true,
|
|
431
|
+
recallMaxTokens: config.recallMaxTokens, windowDedupe: config.windowDedupe,
|
|
432
|
+
liveWindow, dedupSim: config.crossRepoCosine, crossRepo: true,
|
|
433
|
+
globalIndexDir: process.env.MEGACOMPACT_INDEX_DIR,
|
|
434
|
+
},
|
|
435
|
+
runtime.store,
|
|
436
|
+
);
|
|
437
|
+
runtime.dashboard.event("recall-crossrepo", {
|
|
438
|
+
source, query: query.slice(0, 120), injected: x.toInject.length,
|
|
439
|
+
sourceRepos: x.toInject.map((h) => h.repoId).filter(Boolean),
|
|
440
|
+
});
|
|
441
|
+
// Merge, dedup by checkpointId, respect the same token cap by reformatting.
|
|
442
|
+
const seen = new Set(sameRepo.toInject.map((h) => h.checkpoint.checkpointId));
|
|
443
|
+
const merged = [...sameRepo.toInject];
|
|
444
|
+
for (const h of x.toInject) {
|
|
445
|
+
if (!seen.has(h.checkpoint.checkpointId)) { merged.push(h); seen.add(h.checkpoint.checkpointId); }
|
|
446
|
+
}
|
|
447
|
+
const block = merged.length ? formatRecallBlock(merged) : "";
|
|
448
|
+
return {
|
|
449
|
+
toInject: merged,
|
|
450
|
+
report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
|
|
451
|
+
block,
|
|
452
|
+
empty: merged.length === 0,
|
|
453
|
+
};
|
|
454
|
+
} catch {
|
|
455
|
+
return sameRepo; // cross-repo failure → same-repo only (non-fatal)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
366
459
|
/**
|
|
367
460
|
* Extract the live-window message texts from the session manager (Fix C),
|
|
368
461
|
* for inline-dedupe of recalled checkpoints. Best-effort: returns [] on any
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
15
|
-
import { join } from "node:path";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
16
18
|
import { VectorStore } from "../src/vectorStore.js";
|
|
17
19
|
import { toEngineMessages } from "../src/adapt.js";
|
|
18
20
|
import { normalizeSessionId } from "../src/store.js";
|
|
@@ -25,6 +27,22 @@ export const STATUS_KEY = "mega-compact";
|
|
|
25
27
|
export const WIDGET_KEY = "mega-compact-stats";
|
|
26
28
|
export const MARKER_TYPE = "mega-compact-marker";
|
|
27
29
|
|
|
30
|
+
/** Cached npm version, read once from this extension's own package.json. */
|
|
31
|
+
let CACHED_VERSION: string | null = null;
|
|
32
|
+
function ownVersion(): string {
|
|
33
|
+
if (CACHED_VERSION !== null) return CACHED_VERSION;
|
|
34
|
+
let v = "?";
|
|
35
|
+
try {
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
|
|
37
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
38
|
+
v = pkg.version ?? "?";
|
|
39
|
+
} catch {
|
|
40
|
+
v = "?";
|
|
41
|
+
}
|
|
42
|
+
CACHED_VERSION = v;
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/** Per-session runtime state kept in the closure (mirrors neuralwatt-mcr). */
|
|
29
47
|
interface SessionRuntime {
|
|
30
48
|
sessionId: string;
|
|
@@ -79,12 +97,17 @@ export class MegaRuntime {
|
|
|
79
97
|
tokensSaved: 0,
|
|
80
98
|
};
|
|
81
99
|
debounceUntil = 0;
|
|
100
|
+
// S16: debounce for the agent_end resume nudge (avoid busy-loops).
|
|
101
|
+
resumeNudgeUntil = 0;
|
|
82
102
|
// Agent tracking for real-time widget updates
|
|
83
103
|
activeAgents = 0;
|
|
84
104
|
currentTurn = 0;
|
|
85
105
|
// Recall block produced by auto-inline (resume/branch) that the next
|
|
86
106
|
// before_agent_start should prepend to the system prompt. Unset after use.
|
|
87
107
|
pendingRecallBlock: string | undefined;
|
|
108
|
+
// S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
|
|
109
|
+
// semantics; composed with the checkpoint block in before_agent_start.
|
|
110
|
+
pendingMemoryRecallBlock: string | undefined;
|
|
88
111
|
statusKey: string | undefined; // current status text for dashboard
|
|
89
112
|
// Active model/provider (for real cost estimation). Captured from ctx.model
|
|
90
113
|
// on model_select + session_start; persisted to SQL so cost + the dashboard
|
|
@@ -103,6 +126,11 @@ export class MegaRuntime {
|
|
|
103
126
|
readonly TICKER_MAX = 5;
|
|
104
127
|
// Pulsing status: set true while a compaction is in flight, cleared on result.
|
|
105
128
|
pulsing = false;
|
|
129
|
+
// S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
|
|
130
|
+
// the current compaction. The pipeline reads this after a successful compact
|
|
131
|
+
// to decide whether to fire `consolidateMemories` (skip the work entirely
|
|
132
|
+
// when no memory rows changed).
|
|
133
|
+
memoriesTouchedThisCompaction = 0;
|
|
106
134
|
// Rolling "saved" goal for the progress bar — grows as we save more, so the
|
|
107
135
|
// bar always has a meaningful denominator (never sits at 100% forever).
|
|
108
136
|
savedGoal = 50_000;
|
|
@@ -255,7 +283,7 @@ export class MegaRuntime {
|
|
|
255
283
|
// Phase 3 — pulsing status glyph while a compaction is in flight.
|
|
256
284
|
const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
|
|
257
285
|
const lines = [
|
|
258
|
-
` ${C.amber}⚡ ${this.config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
|
|
286
|
+
` ${C.amber}⚡ ${this.config.tier}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
|
|
259
287
|
` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
|
|
260
288
|
];
|
|
261
289
|
// Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
|
|
@@ -364,6 +392,11 @@ export class MegaRuntime {
|
|
|
364
392
|
} catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
|
|
365
393
|
}
|
|
366
394
|
|
|
395
|
+
/** S21: state dir of the currently bound repo (where memories live). */
|
|
396
|
+
getStateDir(): string {
|
|
397
|
+
return this.currentStateDir;
|
|
398
|
+
}
|
|
399
|
+
|
|
367
400
|
/** Build the sync onTier callback that paints the live per-tier trace. */
|
|
368
401
|
makeTierCallback(ctx: ExtensionContext): (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void {
|
|
369
402
|
const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-trim.test.ts — tests for the live compaction view builder (S16).
|
|
3
|
+
*/
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { buildLiveTrimmedView } from "./mega-trim.js";
|
|
7
|
+
import type { EngineMessage } from "../src/types.js";
|
|
8
|
+
|
|
9
|
+
function m(role: EngineMessage["role"], text: string, extra: Partial<EngineMessage> = {}): EngineMessage {
|
|
10
|
+
return { role, text, toolName: undefined, input: undefined, output: undefined, ...extra };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("buildLiveTrimmedView: prepends a compacted summary and keeps the recent anchor", () => {
|
|
14
|
+
const view: EngineMessage[] = [
|
|
15
|
+
m("user", "old request one"), m("assistant", "old answer one"),
|
|
16
|
+
m("user", "old request two"), m("assistant", "old answer two"),
|
|
17
|
+
m("user", "recent keep me"), m("assistant", "recent keep me too"),
|
|
18
|
+
];
|
|
19
|
+
// Compacted region = first 4; recent anchor = last 2.
|
|
20
|
+
const result = buildLiveTrimmedView(view, {
|
|
21
|
+
compactedFrom: 4, // index where the compacted region ends
|
|
22
|
+
summary: "<summary>earlier work on old requests</summary>",
|
|
23
|
+
anchorUserMessages: 1,
|
|
24
|
+
});
|
|
25
|
+
// First element is the injected compacted summary as a user-role message.
|
|
26
|
+
assert.equal(result[0].role, "user");
|
|
27
|
+
assert.ok(String(result[0].text).includes("earlier work on old requests"));
|
|
28
|
+
// Recent anchor preserved in order, no older messages leak through.
|
|
29
|
+
assert.equal(result.length, 1 + 2, "summary + 2 recent");
|
|
30
|
+
assert.ok(result.slice(1).some((x) => String(x.text).includes("recent keep me")));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("buildLiveTrimmedView: empty summary returns the original view unchanged", () => {
|
|
34
|
+
const view = [m("user", "x"), m("assistant", "y")];
|
|
35
|
+
const result = buildLiveTrimmedView(view, { compactedFrom: 0, summary: "", anchorUserMessages: 1 });
|
|
36
|
+
assert.deepEqual(result, view);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("buildLiveTrimmedView: never splits a toolCall/toolResult pair (PREVENT-PI-002)", () => {
|
|
40
|
+
const view: EngineMessage[] = [
|
|
41
|
+
m("user", "q"), m("assistant", "calls tool", { toolName: "read" }), m("tool", "result"),
|
|
42
|
+
m("user", "keep"), m("assistant", "ok"),
|
|
43
|
+
];
|
|
44
|
+
// cut=3 would start the preserved run on the orphaned tool result at index 2 —
|
|
45
|
+
// the builder must snap back so the toolCall/toolResult pair is not split.
|
|
46
|
+
const result = buildLiveTrimmedView(view, { compactedFrom: 3, summary: "<summary>s</summary>", anchorUserMessages: 1 });
|
|
47
|
+
// The tool result must never appear preserved WITHOUT its preceding toolCall.
|
|
48
|
+
const preserved = result.slice(1);
|
|
49
|
+
const hasToolResult = preserved.some((x) => x.role === "tool");
|
|
50
|
+
const hasToolCall = preserved.some((x) => x.role === "assistant" && x.toolName);
|
|
51
|
+
// Either the tool pair is kept together, or the tool result is dropped into
|
|
52
|
+
// the compacted region — it is never left orphaned.
|
|
53
|
+
assert.ok(!(hasToolResult && !hasToolCall), "no orphaned tool result in the preserved run");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("buildLiveTrimmedView: honors the anchor floor (PREVENT-PI-001)", () => {
|
|
57
|
+
// cut would leave zero user messages in the anchor — must skip the trim.
|
|
58
|
+
const view: EngineMessage[] = [
|
|
59
|
+
m("user", "old q"), m("assistant", "old a"),
|
|
60
|
+
m("assistant", "only assistant kept"),
|
|
61
|
+
];
|
|
62
|
+
const result = buildLiveTrimmedView(view, { compactedFrom: 2, summary: "<summary>s</summary>", anchorUserMessages: 1 });
|
|
63
|
+
assert.deepEqual(result, view, "below anchor floor → no trim this call");
|
|
64
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mega-trim.ts — the LIVE compaction view builder (S16).
|
|
3
|
+
*
|
|
4
|
+
* Produces the message list returned from the `context` event so the model sees
|
|
5
|
+
* a compacted window every LLM call WITHOUT aborting the turn (ctx.compact()
|
|
6
|
+
* would abort; the context-event return feeds pi's transformContext per call).
|
|
7
|
+
*
|
|
8
|
+
* Shape: [compactSummaryMessage, ...recentAnchor]. The compacted region
|
|
9
|
+
* [0, compactedFrom) is collapsed to a single user-role summary; the recent
|
|
10
|
+
* anchor [compactedFrom, end) is kept verbatim. Honors PREVENT-PI-002 (never
|
|
11
|
+
* splits a toolCall/toolResult pair) by snapping compactedFrom back to a
|
|
12
|
+
* boundary-safe index, and PREVENT-PI-001 (anchor floor) via the anchor knob.
|
|
13
|
+
*
|
|
14
|
+
* Pure + pi-agnostic: takes EngineMessage[], returns EngineMessage[]. No pi
|
|
15
|
+
* imports. Non-destructive: the caller still owns the real messages.
|
|
16
|
+
*/
|
|
17
|
+
import type { EngineMessage } from "../src/types.js";
|
|
18
|
+
import { isBoundarySafe } from "../src/boundary.js";
|
|
19
|
+
import { formatCompactSummary } from "../src/compact.js";
|
|
20
|
+
|
|
21
|
+
export interface BuildLiveTrimViewOpts {
|
|
22
|
+
/** Index where the compacted region ends (the recent anchor starts here). */
|
|
23
|
+
compactedFrom: number;
|
|
24
|
+
/** The compacted-region summary text (already generated by runCompact). */
|
|
25
|
+
summary: string;
|
|
26
|
+
/** Min recent user messages to keep as the anchor (PREVENT-PI-001). */
|
|
27
|
+
anchorUserMessages: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compute the safe cut index for the live trim. Snaps `compactedFrom` back to a
|
|
32
|
+
* boundary-safe index (PREVENT-PI-002: never start the preserved run on an
|
|
33
|
+
* orphaned tool result), and enforces the anchor floor (PREVENT-PI-001: keep at
|
|
34
|
+
* least `anchorUserMessages` user-role messages). Returns `null` when no trim is
|
|
35
|
+
* safe this call (empty summary, unsafe boundary, or below the anchor floor) so
|
|
36
|
+
* the caller keeps the original view and retries on the next context event.
|
|
37
|
+
*
|
|
38
|
+
* Exposed separately from `buildLiveTrimmedView` so the context handler can map
|
|
39
|
+
* the cut back onto the original pi `AgentMessage[]` (lossless index alignment,
|
|
40
|
+
* mirroring `dropCompactedRange` in src/adapt.ts).
|
|
41
|
+
*/
|
|
42
|
+
export function computeLiveTrimCut(view: EngineMessage[], opts: BuildLiveTrimViewOpts): number | null {
|
|
43
|
+
if (!opts.summary || !opts.summary.trim()) return null;
|
|
44
|
+
let cut = opts.compactedFrom;
|
|
45
|
+
while (cut > 0 && !isBoundarySafe(view, cut)) cut--;
|
|
46
|
+
if (cut <= 0) return null; // nothing safe to cut — keep everything this call
|
|
47
|
+
const recent = view.slice(cut);
|
|
48
|
+
const userCount = recent.filter((m) => m.role === "user").length;
|
|
49
|
+
if (userCount < opts.anchorUserMessages) return null;
|
|
50
|
+
return cut;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The formatted compacted-region summary as a user-role engine message. */
|
|
54
|
+
export function liveTrimSummaryMessage(opts: BuildLiveTrimViewOpts): EngineMessage {
|
|
55
|
+
return {
|
|
56
|
+
role: "user",
|
|
57
|
+
text: formatCompactSummary(opts.summary),
|
|
58
|
+
toolName: undefined,
|
|
59
|
+
input: undefined,
|
|
60
|
+
output: undefined,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build the live trimmed view. Returns the original view if summary is empty
|
|
65
|
+
* or the boundary is unsafe (no trim this call — try next). Pure + tested. */
|
|
66
|
+
export function buildLiveTrimmedView(
|
|
67
|
+
view: EngineMessage[],
|
|
68
|
+
opts: BuildLiveTrimViewOpts,
|
|
69
|
+
): EngineMessage[] {
|
|
70
|
+
const cut = computeLiveTrimCut(view, opts);
|
|
71
|
+
if (cut === null) return view;
|
|
72
|
+
const recent = view.slice(cut);
|
|
73
|
+
const summaryMsg = liveTrimSummaryMessage(opts);
|
|
74
|
+
return [summaryMsg, ...recent];
|
|
75
|
+
}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type CompactInput,
|
|
22
22
|
type CompactResult,
|
|
23
23
|
} from "../src/engine.js";
|
|
24
|
-
import { recallAndInline, type RecallInjectResult } from "../src/recall.js";
|
|
24
|
+
import { recallAndInline, recallMemoriesAndInline, type RecallInjectResult } from "../src/recall.js";
|
|
25
25
|
import { VectorStore } from "../src/vectorStore.js";
|
|
26
26
|
import type { EngineMessage } from "../src/types.js";
|
|
27
27
|
|
|
@@ -333,19 +333,34 @@ export default definePluginEntry({
|
|
|
333
333
|
store,
|
|
334
334
|
);
|
|
335
335
|
|
|
336
|
-
|
|
336
|
+
// S21: parallel memory recall for the slash command. Same query so the
|
|
337
|
+
// output combines checkpoint + memory context the user actually needs.
|
|
338
|
+
let memBlock = "";
|
|
339
|
+
let memReport: string[] = [];
|
|
340
|
+
try {
|
|
341
|
+
const mr = await recallMemoriesAndInline({ query, stateDir, limit: 5 });
|
|
342
|
+
if (!mr.empty) {
|
|
343
|
+
memBlock = mr.block;
|
|
344
|
+
memReport = mr.report;
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
// best-effort — never break the command over memory recall
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (result.toInject.length === 0 && !memBlock) {
|
|
337
351
|
return {
|
|
338
352
|
content: [{ type: "text", text: "No relevant context found in the mega-compact store." }],
|
|
339
353
|
};
|
|
340
354
|
}
|
|
341
355
|
|
|
342
|
-
const parts: string[] = [
|
|
343
|
-
|
|
344
|
-
...result.report,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
const parts: string[] = [];
|
|
357
|
+
if (result.toInject.length) {
|
|
358
|
+
parts.push(`**Recalled ${result.toInject.length} checkpoint(s):**`, ...result.report, "");
|
|
359
|
+
}
|
|
360
|
+
if (memBlock) {
|
|
361
|
+
parts.push(`**Recalled ${memReport.length} memory record(s):**`, ...memReport, "");
|
|
362
|
+
}
|
|
363
|
+
parts.push("---", result.block, memBlock);
|
|
349
364
|
|
|
350
365
|
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
351
366
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSD-2-Clause",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "tsc -p tsconfig.json",
|
|
44
44
|
"lint": "tsc --noEmit && node scripts/guardrails-scan.mjs",
|
|
45
|
-
"test": "npm run build && node
|
|
45
|
+
"test": "npm run build && node scripts/run-tests.mjs",
|
|
46
46
|
"guardrails": "python3 scripts/regression_check.py --all || node scripts/guardrails-scan.mjs",
|
|
47
47
|
"precommit": "bash .claude/hooks/pre-commit.sh",
|
|
48
48
|
"prepublishOnly": "npm run build",
|
package/src/config/dedup.ts
CHANGED
|
@@ -46,6 +46,7 @@ export interface DedupConfigShape {
|
|
|
46
46
|
DEDUP_SIM: number; // legacy content-similarity fallback
|
|
47
47
|
MMR_LAMBDA: number; // retrieval diversity
|
|
48
48
|
SEMDEDUP_COSINE: number; // offline SemDeDup pair threshold
|
|
49
|
+
CONSOLIDATE_COSINE: number; // memory consolidation merge threshold (Sprint 21)
|
|
49
50
|
// Caps / budgets.
|
|
50
51
|
SIMILARITY_BUDGET_MS: number;
|
|
51
52
|
L1_VERIFY_BUDGET_MS: number;
|
|
@@ -79,6 +80,7 @@ export function loadDedupConfig(): DedupConfigShape {
|
|
|
79
80
|
DEDUP_SIM: envNum("MEGACOMPACT_DEDUP_SIM", 0.9),
|
|
80
81
|
MMR_LAMBDA: envNum("MEGACOMPACT_MMR_LAMBDA", 0.5),
|
|
81
82
|
SEMDEDUP_COSINE: envNum("MEGACOMPACT_SEMDEDUP_COSINE", 0.95),
|
|
83
|
+
CONSOLIDATE_COSINE: envNum("MEGACOMPACT_CONSOLIDATE_COSINE", 0.7),
|
|
82
84
|
SIMILARITY_BUDGET_MS: envNum("MEGACOMPACT_SIMILARITY_BUDGET_MS", 50),
|
|
83
85
|
L1_VERIFY_BUDGET_MS: envNum("MEGACOMPACT_L1_VERIFY_BUDGET_MS", 20),
|
|
84
86
|
L1_CANDIDATE_CAP: envNum("MEGACOMPACT_L1_CANDIDATE_CAP", 100),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { upsertRepoRegistry } from "./store/sqlite.js";
|
|
7
|
+
import { detectCrossRepoDrift } from "./driftDetection.js";
|
|
8
|
+
|
|
9
|
+
const NOW = Math.floor(Date.now() / 1000);
|
|
10
|
+
const D = 86_400;
|
|
11
|
+
|
|
12
|
+
test("driftDetection: empty registry returns ok report", () => {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-empty-"));
|
|
14
|
+
try {
|
|
15
|
+
const report = detectCrossRepoDrift(dir);
|
|
16
|
+
assert.equal(report.totals.ok, 0);
|
|
17
|
+
assert.equal(report.totals.warn, 0);
|
|
18
|
+
assert.equal(report.repos.length, 0);
|
|
19
|
+
} finally {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("driftDetection: flags stale repos older than 30 days", () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-stale-"));
|
|
26
|
+
try {
|
|
27
|
+
upsertRepoRegistry(
|
|
28
|
+
{ repoRoot: "/r/old", displayName: "old", stateDir: "/r/old", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW - 45 * D },
|
|
29
|
+
dir,
|
|
30
|
+
);
|
|
31
|
+
const report = detectCrossRepoDrift(dir);
|
|
32
|
+
assert.equal(report.repos.length, 1);
|
|
33
|
+
assert.ok(report.repos[0].signals.some((s) => s.kind === "stale"), "stale signal present");
|
|
34
|
+
assert.equal(report.repos[0].status, "ok", "stale alone is info, not warn");
|
|
35
|
+
assert.equal(report.totals.stale, 1);
|
|
36
|
+
} finally {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("driftDetection: active repo with no compaction flagged as warn", () => {
|
|
42
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-lag-"));
|
|
43
|
+
try {
|
|
44
|
+
upsertRepoRegistry(
|
|
45
|
+
{ repoRoot: "/r/active", displayName: "active", stateDir: "/r/active", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW - 1 * D },
|
|
46
|
+
dir,
|
|
47
|
+
);
|
|
48
|
+
const report = detectCrossRepoDrift(dir);
|
|
49
|
+
const r = report.repos[0];
|
|
50
|
+
assert.ok(r.signals.some((s) => s.kind === "compaction_lag"), "lag signal present");
|
|
51
|
+
assert.equal(r.status, "warn", "compaction lag is warn-level");
|
|
52
|
+
assert.equal(report.totals.warn, 1);
|
|
53
|
+
} finally {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("driftDetection: active repo with recent compaction is ok", () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-ok-"));
|
|
60
|
+
try {
|
|
61
|
+
upsertRepoRegistry(
|
|
62
|
+
{ repoRoot: "/r/healthy", displayName: "healthy", stateDir: "/r/healthy", checkpointCount: 1, tokensSaved: 0, compressedOriginalBytes: 0, lastSeen: NOW, lastCompactedAt: NOW },
|
|
63
|
+
dir,
|
|
64
|
+
);
|
|
65
|
+
const report = detectCrossRepoDrift(dir);
|
|
66
|
+
const r = report.repos[0];
|
|
67
|
+
assert.equal(r.status, "ok");
|
|
68
|
+
assert.equal(r.signals.length, 0);
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(dir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("driftDetection: recent model churn flagged as info", () => {
|
|
75
|
+
const dir = mkdtempSync(join(tmpdir(), "drift-model-"));
|
|
76
|
+
try {
|
|
77
|
+
upsertRepoRegistry(
|
|
78
|
+
{
|
|
79
|
+
repoRoot: "/r/swap",
|
|
80
|
+
displayName: "swap",
|
|
81
|
+
stateDir: "/r/swap",
|
|
82
|
+
checkpointCount: 1,
|
|
83
|
+
tokensSaved: 0,
|
|
84
|
+
compressedOriginalBytes: 0,
|
|
85
|
+
lastSeen: NOW,
|
|
86
|
+
lastCompactedAt: NOW,
|
|
87
|
+
provider: "anthropic",
|
|
88
|
+
providerName: "Anthropic",
|
|
89
|
+
modelName: "sonnet-4.6",
|
|
90
|
+
modelCapturedAt: NOW - 1 * D,
|
|
91
|
+
},
|
|
92
|
+
dir,
|
|
93
|
+
);
|
|
94
|
+
const report = detectCrossRepoDrift(dir);
|
|
95
|
+
assert.ok(report.repos[0].signals.some((s) => s.kind === "model_churn"), "model churn detected");
|
|
96
|
+
assert.equal(report.totals.modelChurn, 1);
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* driftDetection.ts — R4: cross-repo drift detection over the machine-wide
|
|
3
|
+
* repo_registry (index.sqlite). Reads the registry, classifies each repo
|
|
4
|
+
* against simple drift signals, and returns a structured report that the
|
|
5
|
+
* dashboard's Multi-repo tab and the /api/drift endpoint can render.
|
|
6
|
+
*
|
|
7
|
+
* Signals (all derived from repo_registry alone — no checkpoint scans):
|
|
8
|
+
* - stale: last_seen older than STALE_DAYS (default 30). Repo is up but
|
|
9
|
+
* hasn't touched the dashboard in a while — usually parked work.
|
|
10
|
+
* - compaction_lag: last_seen within ACTIVE_DAYS (default 7) but
|
|
11
|
+
* last_compacted_at is null or > 24h behind. The repo is actively running
|
|
12
|
+
* work but compaction isn't keeping pace — usually a config regression.
|
|
13
|
+
* - model_churn: model_captured_at within MODEL_CHURN_DAYS (default 7) —
|
|
14
|
+
* the active model changed recently. Could be a routine upgrade or a
|
|
15
|
+
* silent fallback; both worth flagging.
|
|
16
|
+
*
|
|
17
|
+
* Scope: read-only by design. No writes — drift reporting should never mutate
|
|
18
|
+
* the registry. Severity classification is conservative: warnings, not alarms.
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { DatabaseSync } from "node:sqlite";
|
|
24
|
+
import { getIndexDir } from "./store/sqlite.js";
|
|
25
|
+
|
|
26
|
+
const DAY_SEC = 86_400;
|
|
27
|
+
const STALE_DAYS = 30;
|
|
28
|
+
const ACTIVE_DAYS = 7;
|
|
29
|
+
const MODEL_CHURN_DAYS = 7;
|
|
30
|
+
/** Compaction lag threshold: last_seen newer than this AND last_compacted_at
|
|
31
|
+
* more than this far behind. 24h is generous — compactions usually fire in
|
|
32
|
+
* minutes; >24h usually means something is wedged. */
|
|
33
|
+
const COMPACTION_LAG_SEC = 24 * 3600;
|
|
34
|
+
|
|
35
|
+
export type DriftSeverity = "warn" | "info";
|
|
36
|
+
|
|
37
|
+
export interface RepoDrift {
|
|
38
|
+
repoRoot: string;
|
|
39
|
+
displayName: string;
|
|
40
|
+
lastSeen: number;
|
|
41
|
+
lastCompactedAt: number | null;
|
|
42
|
+
modelCapturedAt: number | null;
|
|
43
|
+
signals: Array<{ kind: "stale" | "compaction_lag" | "model_churn"; severity: DriftSeverity; detail: string }>;
|
|
44
|
+
/** Highest severity across signals; "ok" if none. */
|
|
45
|
+
status: "ok" | "warn";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DriftReport {
|
|
49
|
+
generatedAt: number;
|
|
50
|
+
totals: { ok: number; warn: number; stale: number; compactionLag: number; modelChurn: number };
|
|
51
|
+
repos: RepoDrift[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read all repos from the machine-wide registry, classify drift, return report. */
|
|
55
|
+
export function detectCrossRepoDrift(indexDir: string = getIndexDir()): DriftReport {
|
|
56
|
+
const generatedAt = Math.floor(Date.now() / 1000);
|
|
57
|
+
const indexPath = join(indexDir, "index.sqlite");
|
|
58
|
+
const totals = { ok: 0, warn: 0, stale: 0, compactionLag: 0, modelChurn: 0 };
|
|
59
|
+
if (!existsSync(indexPath)) return { generatedAt, totals, repos: [] };
|
|
60
|
+
|
|
61
|
+
let db: DatabaseSync | undefined;
|
|
62
|
+
try {
|
|
63
|
+
db = new DatabaseSync(indexPath, { readOnly: true });
|
|
64
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
65
|
+
const rows = db
|
|
66
|
+
.prepare(
|
|
67
|
+
`SELECT repo_root, display_name, last_seen, last_compacted_at,
|
|
68
|
+
model_name, provider, model_captured_at
|
|
69
|
+
FROM repo_registry`,
|
|
70
|
+
)
|
|
71
|
+
.all() as Array<{
|
|
72
|
+
repo_root: string;
|
|
73
|
+
display_name: string | null;
|
|
74
|
+
last_seen: number | null;
|
|
75
|
+
last_compacted_at: number | null;
|
|
76
|
+
model_name: string | null;
|
|
77
|
+
provider: string | null;
|
|
78
|
+
model_captured_at: number | null;
|
|
79
|
+
}>;
|
|
80
|
+
|
|
81
|
+
const repos: RepoDrift[] = [];
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
const lastSeen = r.last_seen ?? 0;
|
|
84
|
+
const lastCompacted = r.last_compacted_at ?? null;
|
|
85
|
+
const modelCaptured = r.model_captured_at ?? null;
|
|
86
|
+
const signals: RepoDrift["signals"] = [];
|
|
87
|
+
|
|
88
|
+
if (lastSeen > 0 && generatedAt - lastSeen > STALE_DAYS * DAY_SEC) {
|
|
89
|
+
const daysAgo = Math.floor((generatedAt - lastSeen) / DAY_SEC);
|
|
90
|
+
signals.push({ kind: "stale", severity: "info", detail: `last activity ${daysAgo}d ago` });
|
|
91
|
+
totals.stale++;
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
lastSeen > 0 &&
|
|
95
|
+
generatedAt - lastSeen <= ACTIVE_DAYS * DAY_SEC &&
|
|
96
|
+
(lastCompacted === null || generatedAt - lastCompacted > COMPACTION_LAG_SEC)
|
|
97
|
+
) {
|
|
98
|
+
const lagSec = lastCompacted ? generatedAt - lastCompacted : generatedAt - lastSeen;
|
|
99
|
+
const lagH = Math.floor(lagSec / 3600);
|
|
100
|
+
signals.push({
|
|
101
|
+
kind: "compaction_lag",
|
|
102
|
+
severity: "warn",
|
|
103
|
+
detail: lastCompacted ? `${lagH}h behind last activity` : "never compacted",
|
|
104
|
+
});
|
|
105
|
+
totals.compactionLag++;
|
|
106
|
+
}
|
|
107
|
+
if (modelCaptured && generatedAt - modelCaptured <= MODEL_CHURN_DAYS * DAY_SEC) {
|
|
108
|
+
const label = [r.provider, r.model_name].filter(Boolean).join("/") || "model";
|
|
109
|
+
signals.push({ kind: "model_churn", severity: "info", detail: `${label} captured recently` });
|
|
110
|
+
totals.modelChurn++;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const status: RepoDrift["status"] = signals.some((s) => s.severity === "warn") ? "warn" : "ok";
|
|
114
|
+
if (status === "warn") totals.warn++;
|
|
115
|
+
else totals.ok++;
|
|
116
|
+
|
|
117
|
+
repos.push({
|
|
118
|
+
repoRoot: r.repo_root,
|
|
119
|
+
displayName: r.display_name ?? r.repo_root,
|
|
120
|
+
lastSeen,
|
|
121
|
+
lastCompactedAt: lastCompacted,
|
|
122
|
+
modelCapturedAt: modelCaptured,
|
|
123
|
+
signals,
|
|
124
|
+
status,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// Sort: warn first, then by lastSeen desc so the active ones are on top.
|
|
128
|
+
repos.sort((a, b) => {
|
|
129
|
+
if (a.status !== b.status) return a.status === "warn" ? -1 : 1;
|
|
130
|
+
return b.lastSeen - a.lastSeen;
|
|
131
|
+
});
|
|
132
|
+
return { generatedAt, totals, repos };
|
|
133
|
+
} finally {
|
|
134
|
+
db?.close();
|
|
135
|
+
}
|
|
136
|
+
}
|