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.
@@ -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" && Array.isArray(msg.content)) {
34
- for (const part of msg.content) {
35
- if ("text" in part && typeof part.text === "string") {
36
- textToEmbed += part.text + " ";
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
- // S27 DB-mirror: write checkpoint_epoch with deterministic nonce.
54
- // This makes the cache key stable across identical compactions.
55
- if (config.dbMirror) {
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
- epoch.epochId,
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. Non-fatal failure here never breaks
53
- // the agent loop (PREVENT-PI-004: zero network, local SQLite only).
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, role, content, turn_index, timestamp) VALUES (?, ?, ?, ?, ?)",
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
- threadStmt.run(sid, role, content, turn, now);
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
- toolStmt.run(sid, role, content, turn, now);
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 stability (fire-and-forget, non-fatal).
43
- // tailResult is sync, so use .then().catch() on the dynamic import.
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
- import("../../../src/cache-stripe-impl.js").then(({ computeStabilityScore }) => {
46
- const stableScore = computeStabilityScore(
47
- { content: messageContentText(result[0] ?? result[0]), chunkId: "prefix", accessCount: 0, lastAccessedAt: 0 },
48
- result.slice(0, 2).map((m) => ({ content: messageContentText(m), chunkId: "prefix", accessCount: 0, lastAccessedAt: 0 })),
49
- );
50
- runtime.logger.info("prefix_stability", {
51
- stableScore: Number.isFinite(stableScore) ? stableScore : 0,
52
- prefixMessages: result.length,
53
- separation: config.messageSeparation ? "v2" : "off",
54
- striping: config.cacheStriping ? "v3" : "off",
55
- });
56
- }).catch(() => {
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.23",
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",