pi-mega-compact 0.20.23 → 0.20.24
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/dist/extensions/dashboard-server/routes-rag-settings-helpers.js +2 -0
- package/dist/extensions/mega-events/agent-handlers/turnEndHandler/cacheStripe.js +15 -4
- package/dist/extensions/mega-events/context-handler/afterCompact.js +23 -4
- package/dist/extensions/mega-events/context-handler/dbMirrorAppend.js +35 -7
- package/dist/extensions/mega-events/context-handler/tailResult.js +45 -12
- package/dist/src/cache-stripe-impl.js +52 -214
- package/dist/src/cache-stripe-score.js +115 -0
- package/extensions/dashboard-server/routes-rag-settings-helpers.ts +12 -0
- package/extensions/mega-events/agent-handlers/turnEndHandler/cacheStripe.ts +13 -4
- package/extensions/mega-events/context-handler/afterCompact.ts +23 -4
- package/extensions/mega-events/context-handler/dbMirrorAppend.ts +39 -7
- package/extensions/mega-events/context-handler/tailResult.ts +56 -15
- package/package.json +1 -1
- package/src/cache-stripe-impl.ts +73 -277
- package/src/cache-stripe-score.ts +170 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache-stripe-score.ts — stability scoring + pure helpers for cache-striping.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from cache-stripe-impl.ts (delegate-shell split) so the DB-touching
|
|
5
|
+
* refreshStripeAssignments lives apart from pure scoring math. No SQL, no pi
|
|
6
|
+
* runtime types (PREVENT-PI-004, PREVENT-002).
|
|
7
|
+
*/
|
|
8
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
9
|
+
const WEIGHT_SEMANTIC = 0.5;
|
|
10
|
+
const WEIGHT_RECENCY = 0.3;
|
|
11
|
+
const WEIGHT_FREQUENCY = 0.2;
|
|
12
|
+
const STRIPE_THRESHOLDS = [
|
|
13
|
+
{ minStability: 0.90, stripe: 0 },
|
|
14
|
+
{ minStability: 0.70, stripe: 1 },
|
|
15
|
+
{ minStability: 0.50, stripe: 2 },
|
|
16
|
+
{ minStability: 0.30, stripe: 3 },
|
|
17
|
+
{ minStability: -Infinity, stripe: 4 },
|
|
18
|
+
];
|
|
19
|
+
// ─── Embedding helpers (no external dep) ─────────────────────────────────────
|
|
20
|
+
/** FNV-1a 32-bit hash for the content-based embedding fallback. */
|
|
21
|
+
function fnv1a(text) {
|
|
22
|
+
let hash = 0x811c9dc5;
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
hash ^= text.charCodeAt(i);
|
|
25
|
+
hash = Math.imul(hash, 0x01000193);
|
|
26
|
+
}
|
|
27
|
+
return (hash >>> 0) / 0x100000000;
|
|
28
|
+
}
|
|
29
|
+
/** Crude 128-dim hashed n-gram embedding fallback. Used when no embedder is
|
|
30
|
+
* injected (e.g. tests); production should pass TrigramEmbedder. */
|
|
31
|
+
export function fallbackEmbed(text) {
|
|
32
|
+
const dim = 128;
|
|
33
|
+
const vec = new Array(dim).fill(0);
|
|
34
|
+
const norm = text.toLowerCase().replace(/\s+/g, " ");
|
|
35
|
+
if (norm.length === 0)
|
|
36
|
+
return vec;
|
|
37
|
+
vec[Math.floor(fnv1a(norm) * dim)] += 1;
|
|
38
|
+
for (const word of norm.split(" ")) {
|
|
39
|
+
if (word.length === 0)
|
|
40
|
+
continue;
|
|
41
|
+
vec[Math.floor(fnv1a(word) * dim)] += 0.5;
|
|
42
|
+
for (let i = 0; i < Math.max(1, word.length - 1); i++) {
|
|
43
|
+
const trigram = word.slice(i, i + 3);
|
|
44
|
+
if (trigram.length === 3) {
|
|
45
|
+
vec[Math.floor(fnv1a(trigram) * dim)] += 0.25;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return l2Normalize(vec);
|
|
50
|
+
}
|
|
51
|
+
export function l2Normalize(v) {
|
|
52
|
+
let sumSq = 0;
|
|
53
|
+
for (let i = 0; i < v.length; i++)
|
|
54
|
+
sumSq += v[i] * v[i];
|
|
55
|
+
if (sumSq === 0)
|
|
56
|
+
return v;
|
|
57
|
+
const norm = Math.sqrt(sumSq);
|
|
58
|
+
for (let i = 0; i < v.length; i++)
|
|
59
|
+
v[i] /= norm;
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
/** Compute cosine similarity between two vectors of equal length. */
|
|
63
|
+
export function cosineSimilarity(a, b) {
|
|
64
|
+
if (a.length !== b.length || a.length === 0)
|
|
65
|
+
return 0;
|
|
66
|
+
let dot = 0;
|
|
67
|
+
let na = 0;
|
|
68
|
+
let nb = 0;
|
|
69
|
+
for (let i = 0; i < a.length; i++) {
|
|
70
|
+
dot += a[i] * b[i];
|
|
71
|
+
na += a[i] * a[i];
|
|
72
|
+
nb += b[i] * b[i];
|
|
73
|
+
}
|
|
74
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb);
|
|
75
|
+
return denom === 0 ? 0 : dot / denom;
|
|
76
|
+
}
|
|
77
|
+
// ─── Stability Scoring ───────────────────────────────────────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* Composite stability = 0.5*semantic + 0.3*recency + 0.2*frequency.
|
|
80
|
+
* Recency/frequency fall back to neutral scores when the epoch lacks
|
|
81
|
+
* access-tracking data (the current cache_stripes schema).
|
|
82
|
+
*/
|
|
83
|
+
export function computeStabilityScore(chunk, allChunks, embedder, sessionEmbed) {
|
|
84
|
+
const emb = embedder
|
|
85
|
+
? embedder.embed(chunk.content)
|
|
86
|
+
: fallbackEmbed(chunk.content);
|
|
87
|
+
// No session embedding → self-similarity baseline.
|
|
88
|
+
const sem = cosineSimilarity(emb, sessionEmbed ?? emb);
|
|
89
|
+
const semanticScore = isNaN(sem) ? 0 : sem;
|
|
90
|
+
let recencyScore = 0.5;
|
|
91
|
+
const accessed = allChunks
|
|
92
|
+
.map((c) => c.lastAccessedAt)
|
|
93
|
+
.filter((t) => t > 0);
|
|
94
|
+
if (accessed.length > 1) {
|
|
95
|
+
const minT = Math.min(...accessed);
|
|
96
|
+
const maxT = Math.max(...accessed);
|
|
97
|
+
const range = maxT - minT;
|
|
98
|
+
recencyScore = range > 0 ? (chunk.lastAccessedAt - minT) / range : 1.0;
|
|
99
|
+
}
|
|
100
|
+
const counts = allChunks.map((c) => c.accessCount);
|
|
101
|
+
const maxCount = Math.max(...counts, 1);
|
|
102
|
+
const freqScore = maxCount > 0 ? chunk.accessCount / maxCount : 0;
|
|
103
|
+
const stability = WEIGHT_SEMANTIC * semanticScore +
|
|
104
|
+
WEIGHT_RECENCY * recencyScore +
|
|
105
|
+
WEIGHT_FREQUENCY * freqScore;
|
|
106
|
+
return Math.max(0, Math.min(1, stability));
|
|
107
|
+
}
|
|
108
|
+
/** Map a stability score to its stripe (layer). */
|
|
109
|
+
export function stabilityToStripe(stability) {
|
|
110
|
+
for (const t of STRIPE_THRESHOLDS) {
|
|
111
|
+
if (stability >= t.minStability)
|
|
112
|
+
return t.stripe;
|
|
113
|
+
}
|
|
114
|
+
return 4;
|
|
115
|
+
}
|
|
@@ -122,6 +122,18 @@ export const SETTINGS: ReadonlyArray<SettingGroup> = [
|
|
|
122
122
|
"Tailwind + shadcn visual design (sidebar, glass panels)",
|
|
123
123
|
true,
|
|
124
124
|
),
|
|
125
|
+
boolDirect(
|
|
126
|
+
"MEGACOMPACT_MESSAGE_SEPARATION",
|
|
127
|
+
"Message Separation (P2)",
|
|
128
|
+
"PLAN_V2: split conversation thread from tool results to grow stable cache prefix",
|
|
129
|
+
false,
|
|
130
|
+
),
|
|
131
|
+
boolDirect(
|
|
132
|
+
"MEGACOMPACT_CACHE_STRIPING",
|
|
133
|
+
"Cache Striping (P3)",
|
|
134
|
+
"PLAN_V2: order stable context by stability score so durable chunks lead the prompt",
|
|
135
|
+
false,
|
|
136
|
+
),
|
|
125
137
|
],
|
|
126
138
|
},
|
|
127
139
|
{
|
|
@@ -28,12 +28,21 @@ export function cacheStripe(
|
|
|
28
28
|
const embedder = defaultEmbedder();
|
|
29
29
|
|
|
30
30
|
// (a) Extract text from the assistant's response for topic embedding.
|
|
31
|
+
// pi's assistant content may be a plain string OR an array of parts.
|
|
32
|
+
// Discriminate by type and only reach into `.text` after confirming the
|
|
33
|
+
// part is an object carrying a string `.text` — never assume shape.
|
|
31
34
|
let textToEmbed = "";
|
|
32
35
|
const msg = event.message;
|
|
33
|
-
if (msg.role === "assistant"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
if (msg.role === "assistant") {
|
|
37
|
+
const c = msg.content;
|
|
38
|
+
if (typeof c === "string") {
|
|
39
|
+
textToEmbed = c;
|
|
40
|
+
} else if (Array.isArray(c)) {
|
|
41
|
+
for (const part of c) {
|
|
42
|
+
if (part && typeof part === "object" && "text" in part) {
|
|
43
|
+
const t = (part as { text?: unknown }).text;
|
|
44
|
+
if (typeof t === "string") textToEmbed += t + " ";
|
|
45
|
+
}
|
|
37
46
|
}
|
|
38
47
|
}
|
|
39
48
|
}
|
|
@@ -50,9 +50,12 @@ export async function persistEpochAndMaintain(
|
|
|
50
50
|
config: MegaConfig,
|
|
51
51
|
ran: { result: CompactResult },
|
|
52
52
|
): Promise<void> {
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
53
|
+
// PLAN_V2 cache-striping epoch row. buildCacheOptimizedPrompt's stripe
|
|
54
|
+
// lookup reads the most-recent checkpoint_epochs row, so emit it whenever
|
|
55
|
+
// PLAN_V2 flags are live. Separate from the legacy dbMirror block below so
|
|
56
|
+
// PLAN_V2 can run with dbMirror OFF (flag-on is a genuine additive; flag-OFF
|
|
57
|
+
// = byte-identical to the predecessor, since this block no-ops then).
|
|
58
|
+
if (config.dbMirror || config.messageSeparation || config.cacheStriping) {
|
|
56
59
|
try {
|
|
57
60
|
const db = openStore(runtime.currentStateDir);
|
|
58
61
|
const cpId = ran.result.checkpointId ?? `epoch-${Date.now()}`;
|
|
@@ -67,6 +70,22 @@ export async function persistEpochAndMaintain(
|
|
|
67
70
|
createdAt: Date.now(),
|
|
68
71
|
};
|
|
69
72
|
writeCheckpointEpoch(db, epoch);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
runtime.logger.warn("planv2-epoch-fail", { error: String(e) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// S27 DB-mirror downstream: stamp turns, rebuild wiki, seed topics, dedup.
|
|
79
|
+
// Remains on `config.dbMirror` only — these are DB-mirror maintenance and
|
|
80
|
+
// must NOT run for PLAN_V2-only configurations.
|
|
81
|
+
if (config.dbMirror) {
|
|
82
|
+
try {
|
|
83
|
+
const db = openStore(runtime.currentStateDir);
|
|
84
|
+
const cpId = ran.result.checkpointId ?? `epoch-${Date.now()}`;
|
|
85
|
+
// epoch + writeCheckpointEpoch moved to the PLAN_V2 block above (it
|
|
86
|
+
// gates the wider set of flags); reuse epochIdFor(cpId) here so the
|
|
87
|
+
// stamps below land in the same epoch row.
|
|
88
|
+
const epochId = epochIdFor(cpId);
|
|
70
89
|
// S50B: link this session's turns to the epoch that just compacted
|
|
71
90
|
// them (compression-by-conversation-epoch metrics). Isolated-store
|
|
72
91
|
// only; best-effort + non-fatal.
|
|
@@ -74,7 +93,7 @@ export async function persistEpochAndMaintain(
|
|
|
74
93
|
stampTurnsEpochFor(
|
|
75
94
|
config,
|
|
76
95
|
runtime.rt.sessionId,
|
|
77
|
-
|
|
96
|
+
epochId,
|
|
78
97
|
runtime.currentStateDir,
|
|
79
98
|
);
|
|
80
99
|
} catch {
|
|
@@ -16,6 +16,21 @@ import type { MegaRuntime } from "../../mega-runtime.js";
|
|
|
16
16
|
import type { MegaConfig } from "../../mega-config.js";
|
|
17
17
|
import { messageContentText } from "./messageText.js";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Best-effort tool_call_id for the tool_results insert. The toolResult variant
|
|
21
|
+
* carries a top-level toolCallId (read via an `unknown`-narrowed cast — never
|
|
22
|
+
* reach into `.content`, which is variant-specific and requires narrowing).
|
|
23
|
+
* bashExecution has no toolCallId, so fall back to a stable synthetic id keyed
|
|
24
|
+
* on (turn, index) to satisfy the NOT NULL column. No `any` (PREVENT-011).
|
|
25
|
+
*/
|
|
26
|
+
function toolCallIdOf(m: AgentMessage, fallback: string): string {
|
|
27
|
+
if (m.role === "toolResult") {
|
|
28
|
+
const id = (m as unknown as { toolCallId?: unknown }).toolCallId;
|
|
29
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
30
|
+
}
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
19
34
|
/**
|
|
20
35
|
* Append incoming messages to the DB mirror (raw_transcript + thread/tool
|
|
21
36
|
* tables) and the v2 ledger. Gated on config.dbMirror for the mirror; the VC1B
|
|
@@ -49,25 +64,42 @@ export function appendMirrorAndLedger(
|
|
|
49
64
|
// separation (buildSeparatedPrompt / buildCacheOptimizedPrompt in
|
|
50
65
|
// tailResult) is sufficient for the prompt-construction path;
|
|
51
66
|
// these DB writes persist the split for post-hoc analysis, dashboard
|
|
52
|
-
// queries, and future readers.
|
|
53
|
-
//
|
|
54
|
-
|
|
67
|
+
// queries, and future readers. Gated on (messageSeparation ||
|
|
68
|
+
// cacheStriping) so flag-OFF remains byte-identical to the
|
|
69
|
+
// predecessor — when both flags are OFF the live prompt is never
|
|
70
|
+
// separated, and growing these tables would be dead state.
|
|
71
|
+
// Non-fatal — failure here never breaks the agent loop
|
|
72
|
+
// (PREVENT-PI-004: zero network, local SQLite only).
|
|
73
|
+
if (config.messageSeparation || config.cacheStriping) {
|
|
55
74
|
const sid = runtime.rt.sessionId;
|
|
56
75
|
const turn = runtime.currentTurn;
|
|
57
76
|
const now = Date.now();
|
|
58
77
|
const threadStmt = db.prepare(
|
|
59
78
|
"INSERT OR IGNORE INTO conversation_thread (conversation_id, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
|
|
60
79
|
);
|
|
80
|
+
// Schema (plan-v2.ts) is (conversation_id, tool_call_id,
|
|
81
|
+
// tool_result, turn_index, timestamp) — NOT role/content.
|
|
61
82
|
const toolStmt = db.prepare(
|
|
62
|
-
"INSERT OR IGNORE INTO tool_results (conversation_id,
|
|
83
|
+
"INSERT OR IGNORE INTO tool_results (conversation_id, tool_call_id, tool_result, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
|
|
84
|
+
);
|
|
85
|
+
const toolHas = db.prepare(
|
|
86
|
+
"SELECT 1 FROM tool_results WHERE conversation_id = ? AND turn_index = ? AND tool_call_id = ? AND tool_result = ? LIMIT 1",
|
|
87
|
+
);
|
|
88
|
+
const threadHas = db.prepare(
|
|
89
|
+
"SELECT 1 FROM conversation_thread WHERE conversation_id = ? AND turn_index = ? AND role = ? AND content = ? LIMIT 1",
|
|
63
90
|
);
|
|
64
|
-
for (const m of messages) {
|
|
91
|
+
for (const [idx, m] of messages.entries()) {
|
|
65
92
|
const role = m.role;
|
|
66
93
|
const content = messageContentText(m);
|
|
67
94
|
if (role === "user" || role === "assistant") {
|
|
68
|
-
|
|
95
|
+
if (threadHas.get(sid, turn, role, content) == null) {
|
|
96
|
+
threadStmt.run(sid, role, content, turn, now);
|
|
97
|
+
}
|
|
69
98
|
} else if (role === "toolResult" || role === "bashExecution") {
|
|
70
|
-
|
|
99
|
+
const toolCallId = toolCallIdOf(m, `bash:${turn}:${idx}`);
|
|
100
|
+
if (toolHas.get(sid, turn, toolCallId, content) == null) {
|
|
101
|
+
toolStmt.run(sid, toolCallId, content, turn, now);
|
|
102
|
+
}
|
|
71
103
|
}
|
|
72
104
|
}
|
|
73
105
|
}
|
|
@@ -12,6 +12,16 @@ import type { MegaConfig } from "../../mega-config.js";
|
|
|
12
12
|
import { stagedForTail, withRecallTail } from "../recall-tail.js";
|
|
13
13
|
import { buildSeparatedPrompt, buildCacheOptimizedPrompt } from "../separated-prompt.js";
|
|
14
14
|
import { messageContentText } from "./messageText.js";
|
|
15
|
+
import { computeContentDigest } from "../../../src/dedup/digest.js";
|
|
16
|
+
|
|
17
|
+
// P2.5: per-runtime cross-turn prompt-prefix footprints for stable-prefix
|
|
18
|
+
// measurement. WeakMap keyed by MegaRuntime so the entry dies with the runtime
|
|
19
|
+
// (same lifecycle as a session boundary in practice — a new session re-creates
|
|
20
|
+
// the pipeline over the same runtime, but the prior turn's fingerprints must
|
|
21
|
+
// still be visible for cross-turn compare; a fresh runtime has an empty map and
|
|
22
|
+
// the sessionId guard below blocks cross-session compare).
|
|
23
|
+
type TurnPrefix = { sessionId: string; turn: number; fingerprints: string[] };
|
|
24
|
+
const lastTurnPrefix = new WeakMap<MegaRuntime, TurnPrefix>();
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
27
|
* Build the tail injection closure. Returns undefined when nothing is staged
|
|
@@ -39,23 +49,54 @@ export function buildTailResult(
|
|
|
39
49
|
} else if (config.messageSeparation) {
|
|
40
50
|
result = buildSeparatedPrompt(result);
|
|
41
51
|
}
|
|
42
|
-
// P2.5: log prefix
|
|
43
|
-
//
|
|
52
|
+
// P2.5: log cross-turn stable-prefix length (cache-hit proxy). After the
|
|
53
|
+
// prompt is built (separated or cache-optimized), fingerprint each leading
|
|
54
|
+
// message and count how many are byte-identical to the previous turn's
|
|
55
|
+
// prompt, in order. A high stablePrefix = the provider KV-cache prefix is
|
|
56
|
+
// re-used (cache hit). Fire-and-forget + non-fatal.
|
|
57
|
+
//
|
|
58
|
+
// tailResult may run several times per turn (gate return / replay /
|
|
59
|
+
// debounce / live-trim), so the sessionId+turn guard measures once per
|
|
60
|
+
// turn — compare against the PREVIOUS turn's stored footprint, then store
|
|
61
|
+
// this turn's for the next comparison. Cross-session compares are skipped
|
|
62
|
+
// via the stored sessionId check (belt-and-suspenders; the WeakMap entry
|
|
63
|
+
// is scoped to this runtime, which owns the session).
|
|
44
64
|
if (result.length > 1) {
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
try {
|
|
66
|
+
const sessionId = runtime.rt.sessionId;
|
|
67
|
+
const turn = runtime.currentTurn;
|
|
68
|
+
const prev = lastTurnPrefix.get(runtime);
|
|
69
|
+
const isNewTurn =
|
|
70
|
+
!prev || prev.sessionId !== sessionId || prev.turn !== turn;
|
|
71
|
+
if (isNewTurn) {
|
|
72
|
+
const fingerprints = result.map((m) =>
|
|
73
|
+
// Role prefix disambiguates identical text across variants
|
|
74
|
+
// (roles are a fixed enum — no realistic fingerprint collision).
|
|
75
|
+
computeContentDigest(`${m.role}|${messageContentText(m)}`).contentHash,
|
|
76
|
+
);
|
|
77
|
+
let stablePrefix = 0;
|
|
78
|
+
if (prev && prev.sessionId === sessionId) {
|
|
79
|
+
for (let i = 0; i < fingerprints.length; i++) {
|
|
80
|
+
if (
|
|
81
|
+
i >= prev.fingerprints.length ||
|
|
82
|
+
prev.fingerprints[i] !== fingerprints[i]
|
|
83
|
+
) {
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
stablePrefix++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
lastTurnPrefix.set(runtime, { sessionId, turn, fingerprints });
|
|
90
|
+
runtime.logger.info("prefix_stability", {
|
|
91
|
+
stablePrefix,
|
|
92
|
+
totalMessages: result.length,
|
|
93
|
+
separation: config.messageSeparation ? "v2" : "off",
|
|
94
|
+
striping: config.cacheStriping ? "v3" : "off",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
57
98
|
// Non-fatal: stability logging is best-effort.
|
|
58
|
-
}
|
|
99
|
+
}
|
|
59
100
|
}
|
|
60
101
|
return { messages: result };
|
|
61
102
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.24",
|
|
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-3-Clause",
|