pi-mega-compact 0.11.12 → 0.12.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.
- package/dist/extensions/dashboard-server/dashboard-client-repos.js +28 -2
- package/dist/extensions/dashboard-server/index-reader.js +14 -13
- package/dist/extensions/dashboard-server/routes-health.js +81 -0
- package/dist/extensions/dashboard-server/routes-topics.js +18 -0
- package/dist/extensions/dashboard-server/routes.js +1 -0
- package/dist/extensions/dashboard-server/server.js +5 -1
- package/dist/extensions/mega-config.js +5 -0
- package/dist/extensions/mega-events/agent-handlers.js +5 -0
- package/dist/extensions/mega-events/context-handler.js +10 -5
- package/dist/extensions/mega-events/health-handler.js +156 -0
- package/dist/extensions/mega-runtime/reset-runtime.js +5 -1
- package/dist/extensions/mega-runtime/runtime.js +6 -0
- package/dist/src/contextHealth/cachePoison.js +183 -0
- package/dist/src/contextHealth/drift.js +89 -0
- package/dist/src/contextHealth/outputQuality.js +112 -0
- package/dist/src/contextHealth.js +110 -0
- package/dist/src/memoryGraph/gates.js +1 -1
- package/dist/src/memoryGraph/sources.js +44 -22
- package/dist/src/store/sqlite/context-health.js +193 -0
- package/dist/src/store/sqlite/schema.js +43 -1
- package/dist/src/store/sqlite.js +1 -0
- package/dist/src/store/turns/sqlite-store.js +6 -6
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js → AchievementTiles-DeQ0qnBX.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-9KQgKBEh.js.map → AchievementTiles-DeQ0qnBX.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js → AchievementsTab-Dtk_RJfs.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-B6xoQTEa.js.map → AchievementsTab-Dtk_RJfs.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js → CacheTab-kWcQt3PI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-Dkm9rawo.js.map → CacheTab-kWcQt3PI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js → ConfigTab-UFlo73fR.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-Bd_v_OUF.js.map → ConfigTab-UFlo73fR.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js → EventsTab-BENO2N0y.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CYWoFOdI.js.map → EventsTab-BENO2N0y.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js → GameTab-BCJavOML.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-CH7lecNL.js.map → GameTab-BCJavOML.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js +2 -0
- package/extensions/dashboard-client/dist/assets/HealthTab-C7iw7rhx.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js → MaintenanceTab-C0rdAMWc.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MaintenanceTab-DNZ1IF4t.js.map → MaintenanceTab-C0rdAMWc.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js → MemoryMapTab-w5BZP3i-.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MemoryMapTab-tVRREYiG.js.map → MemoryMapTab-w5BZP3i-.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js → MetricsTab-BBCXa3xS.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CHblYuTY.js.map → MetricsTab-BBCXa3xS.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js → OverviewTab-CzABMSAQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-Dj0TgFvU.js.map → OverviewTab-CzABMSAQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js → ReposTab-DFiM_Ubg.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-BTJcfc1W.js.map → ReposTab-DFiM_Ubg.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js → SessionsTab-BtW4bees.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-C8tU8K5u.js.map → SessionsTab-BtW4bees.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js → SetupTab-Duy1LJhg.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SetupTab-BdxGMo6O.js.map → SetupTab-Duy1LJhg.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js → TimeSavedCard-CC13EXij.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-BWZSARzT.js.map → TimeSavedCard-CC13EXij.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js → TopicsTab-BrTB8uEK.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TopicsTab-BCJwPYl8.js.map → TopicsTab-BrTB8uEK.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js → TurnsTab-BJK7FIHu.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TurnsTab-DQFTSDpz.js.map → TurnsTab-BJK7FIHu.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js → index-B8k19B9g.js} +11 -11
- package/extensions/dashboard-client/dist/assets/{index-DrrDswj-.js.map → index-B8k19B9g.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js → useSSE-CWBB4Me7.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-o4tteeTs.js.map → useSSE-CWBB4Me7.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +1 -1
- package/extensions/dashboard-client/src/App.tsx +5 -1
- package/extensions/dashboard-client/src/api/health.ts +64 -0
- package/extensions/dashboard-client/src/tabs/HealthTab.tsx +165 -0
- package/extensions/dashboard-server/dashboard-client-repos.ts +28 -2
- package/extensions/dashboard-server/index-reader.ts +16 -17
- package/extensions/dashboard-server/routes-health.ts +104 -0
- package/extensions/dashboard-server/routes-topics.ts +21 -0
- package/extensions/dashboard-server/routes.ts +1 -0
- package/extensions/dashboard-server/server.ts +4 -0
- package/extensions/mega-config.ts +15 -0
- package/extensions/mega-events/agent-handlers.ts +6 -0
- package/extensions/mega-events/context-handler.ts +111 -104
- package/extensions/mega-events/health-handler.ts +190 -0
- package/extensions/mega-runtime/reset-runtime.ts +5 -1
- package/extensions/mega-runtime/runtime.ts +7 -0
- package/package.json +1 -1
- package/src/contextHealth/cachePoison.ts +243 -0
- package/src/contextHealth/drift.ts +107 -0
- package/src/contextHealth/outputQuality.ts +139 -0
- package/src/contextHealth.ts +191 -0
- package/src/memoryGraph/gates.ts +1 -1
- package/src/memoryGraph/sources.ts +56 -28
- package/src/store/sqlite/context-health.ts +342 -0
- package/src/store/sqlite/schema.ts +43 -1
- package/src/store/sqlite.ts +1 -0
- package/src/store/turns/sqlite-store.ts +6 -4
|
@@ -333,120 +333,127 @@ export function registerContextHandler(
|
|
|
333
333
|
} catch {
|
|
334
334
|
/* non-fatal: epoch stamping never breaks compaction */
|
|
335
335
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
336
|
+
|
|
337
|
+
// S51B: auto-categorizing wiki rebuild — every Nth compaction, derived
|
|
338
|
+
// from real context_chunks embeddings. Isolated-store only, gated on
|
|
339
|
+
// AUTO_WIKI_ENABLED; best-effort + non-fatal (never breaks compaction).
|
|
340
|
+
// Fire regardless of dbMirror — context_chunks is the isolated store.
|
|
341
|
+
// Uses the already-open db when inside the dbMirror block; opens its own
|
|
342
|
+
// connection otherwise.
|
|
343
|
+
// from real context_chunks embeddings. Isolated-store only, gated on
|
|
344
|
+
// AUTO_WIKI_ENABLED; best-effort + non-fatal (never breaks compaction).
|
|
345
|
+
try {
|
|
346
|
+
if (config.autoWikiEnabled && config.turnsDbEnabled) {
|
|
347
|
+
const every = Math.max(
|
|
348
|
+
1,
|
|
349
|
+
TurnsConfig.WIKI_REBUILD_EVERY_N_COMPACTS,
|
|
350
|
+
);
|
|
351
|
+
const tdb = openTurnStore(runtime.currentStateDir);
|
|
352
|
+
const n = bumpWikiCompactCounter(tdb);
|
|
353
|
+
if (n % every === 0) {
|
|
354
|
+
const model = buildTopicModel(db, {
|
|
355
|
+
kRange: [TurnsConfig.WIKI_K_MIN, TurnsConfig.WIKI_K_MAX],
|
|
356
|
+
labelTopTerms: TurnsConfig.WIKI_LABEL_TOP_TERMS,
|
|
357
|
+
restarts: 5,
|
|
358
|
+
seed: 0x9e3779b9,
|
|
359
|
+
});
|
|
360
|
+
createTopicStore(runtime.currentStateDir).replaceTopicModel(
|
|
361
|
+
model,
|
|
362
|
+
);
|
|
363
|
+
runtime.logger.info("wiki_rebuild", {
|
|
364
|
+
clusterCount: model.k,
|
|
365
|
+
totalChunks: model.totalChunks,
|
|
366
|
+
method: "kmeans+tfidf",
|
|
367
|
+
criterion: model.criterion,
|
|
368
|
+
silhouetteScore: model.silhouetteScore,
|
|
369
|
+
uncalibrated: false,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch (wikiErr) {
|
|
374
|
+
runtime.logger.warn("wiki_rebuild_failed", {
|
|
375
|
+
error: String(wikiErr),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// D1: seed the topic model from raw_transcript when context_chunks is
|
|
380
|
+
// thin (pre-compaction). Gated on WIKI_SEED_FROM_TURNS; non-fatal.
|
|
381
|
+
// Seeds buildTopicModel with on-the-fly trigram embeddings from
|
|
382
|
+
// recent raw_transcript rows for the current session.
|
|
383
|
+
try {
|
|
384
|
+
if (
|
|
385
|
+
config.autoWikiEnabled &&
|
|
386
|
+
config.turnsDbEnabled &&
|
|
387
|
+
TurnsConfig.WIKI_SEED_FROM_TURNS
|
|
388
|
+
) {
|
|
389
|
+
const floor = 50;
|
|
390
|
+
const countRow = db
|
|
391
|
+
.prepare(
|
|
392
|
+
`SELECT COUNT(*) AS cnt FROM context_chunks WHERE session_id = ?`,
|
|
393
|
+
)
|
|
394
|
+
.get(runtime.rt.sessionId) as { cnt: number } | undefined;
|
|
395
|
+
const chunkCount = countRow?.cnt ?? 0;
|
|
396
|
+
if (chunkCount < floor) {
|
|
397
|
+
const transcriptRows = db
|
|
398
|
+
.prepare(
|
|
399
|
+
`SELECT DISTINCT content_bytes
|
|
400
|
+
FROM raw_transcript
|
|
401
|
+
WHERE session_id = ?
|
|
402
|
+
AND length(content_bytes) > 10
|
|
403
|
+
ORDER BY seq ASC
|
|
404
|
+
LIMIT 200`,
|
|
405
|
+
)
|
|
406
|
+
.all(runtime.rt.sessionId) as Array<{
|
|
407
|
+
content_bytes: string;
|
|
408
|
+
}>;
|
|
409
|
+
if (transcriptRows.length > 0) {
|
|
410
|
+
const embedder = new TrigramEmbedder();
|
|
411
|
+
const seedChunks: EmbeddedChunk[] = [];
|
|
412
|
+
for (let i = 0; i < transcriptRows.length; i++) {
|
|
413
|
+
const text = transcriptRows[i].content_bytes.trim();
|
|
414
|
+
if (text.length === 0) continue;
|
|
415
|
+
const vec = embedder.embed(text);
|
|
416
|
+
seedChunks.push({
|
|
417
|
+
chunkId: `seed_transcript_${i}`,
|
|
418
|
+
sessionId: runtime.rt.sessionId,
|
|
419
|
+
vec,
|
|
420
|
+
text,
|
|
353
421
|
});
|
|
354
|
-
|
|
355
|
-
|
|
422
|
+
}
|
|
423
|
+
if (seedChunks.length > 0) {
|
|
424
|
+
const model = buildTopicModel(
|
|
425
|
+
db,
|
|
426
|
+
{
|
|
427
|
+
kRange: [
|
|
428
|
+
TurnsConfig.WIKI_K_MIN,
|
|
429
|
+
TurnsConfig.WIKI_K_MAX,
|
|
430
|
+
],
|
|
431
|
+
labelTopTerms:
|
|
432
|
+
TurnsConfig.WIKI_LABEL_TOP_TERMS,
|
|
433
|
+
restarts: 5,
|
|
434
|
+
seed: 0x9e3779b9,
|
|
435
|
+
},
|
|
436
|
+
seedChunks,
|
|
356
437
|
);
|
|
357
|
-
|
|
438
|
+
createTopicStore(
|
|
439
|
+
runtime.currentStateDir,
|
|
440
|
+
).replaceTopicModel(model);
|
|
441
|
+
runtime.logger.info("wiki_seed", {
|
|
358
442
|
clusterCount: model.k,
|
|
443
|
+
sourceChunks: seedChunks.length,
|
|
359
444
|
totalChunks: model.totalChunks,
|
|
360
445
|
method: "kmeans+tfidf",
|
|
361
|
-
criterion: model.criterion,
|
|
362
|
-
silhouetteScore: model.silhouetteScore,
|
|
363
|
-
uncalibrated: false,
|
|
364
446
|
});
|
|
365
447
|
}
|
|
366
448
|
}
|
|
367
|
-
} catch (wikiErr) {
|
|
368
|
-
runtime.logger.warn("wiki_rebuild_failed", {
|
|
369
|
-
error: String(wikiErr),
|
|
370
|
-
});
|
|
371
449
|
}
|
|
450
|
+
}
|
|
451
|
+
} catch (seedErr) {
|
|
452
|
+
runtime.logger.warn("wiki_seed_failed", {
|
|
453
|
+
error: String(seedErr),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
372
456
|
|
|
373
|
-
// D1: seed the topic model from raw_transcript when context_chunks is
|
|
374
|
-
// thin (pre-compaction). Gated on WIKI_SEED_FROM_TURNS; non-fatal.
|
|
375
|
-
// Seeds buildTopicModel with on-the-fly trigram embeddings from
|
|
376
|
-
// recent raw_transcript rows for the current session.
|
|
377
|
-
try {
|
|
378
|
-
if (
|
|
379
|
-
config.autoWikiEnabled &&
|
|
380
|
-
config.turnsDbEnabled &&
|
|
381
|
-
TurnsConfig.WIKI_SEED_FROM_TURNS
|
|
382
|
-
) {
|
|
383
|
-
const floor = 50;
|
|
384
|
-
const countRow = db
|
|
385
|
-
.prepare(
|
|
386
|
-
`SELECT COUNT(*) AS cnt FROM context_chunks WHERE session_id = ?`,
|
|
387
|
-
)
|
|
388
|
-
.get(runtime.rt.sessionId) as { cnt: number } | undefined;
|
|
389
|
-
const chunkCount = countRow?.cnt ?? 0;
|
|
390
|
-
if (chunkCount < floor) {
|
|
391
|
-
const transcriptRows = db
|
|
392
|
-
.prepare(
|
|
393
|
-
`SELECT DISTINCT content_bytes
|
|
394
|
-
FROM raw_transcript
|
|
395
|
-
WHERE session_id = ?
|
|
396
|
-
AND length(content_bytes) > 10
|
|
397
|
-
ORDER BY seq ASC
|
|
398
|
-
LIMIT 200`,
|
|
399
|
-
)
|
|
400
|
-
.all(runtime.rt.sessionId) as Array<{
|
|
401
|
-
content_bytes: string;
|
|
402
|
-
}>;
|
|
403
|
-
if (transcriptRows.length > 0) {
|
|
404
|
-
const embedder = new TrigramEmbedder();
|
|
405
|
-
const seedChunks: EmbeddedChunk[] = [];
|
|
406
|
-
for (let i = 0; i < transcriptRows.length; i++) {
|
|
407
|
-
const text = transcriptRows[i].content_bytes.trim();
|
|
408
|
-
if (text.length === 0) continue;
|
|
409
|
-
const vec = embedder.embed(text);
|
|
410
|
-
seedChunks.push({
|
|
411
|
-
chunkId: `seed_transcript_${i}`,
|
|
412
|
-
sessionId: runtime.rt.sessionId,
|
|
413
|
-
vec,
|
|
414
|
-
text,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
if (seedChunks.length > 0) {
|
|
418
|
-
const model = buildTopicModel(
|
|
419
|
-
db,
|
|
420
|
-
{
|
|
421
|
-
kRange: [
|
|
422
|
-
TurnsConfig.WIKI_K_MIN,
|
|
423
|
-
TurnsConfig.WIKI_K_MAX,
|
|
424
|
-
],
|
|
425
|
-
labelTopTerms:
|
|
426
|
-
TurnsConfig.WIKI_LABEL_TOP_TERMS,
|
|
427
|
-
restarts: 5,
|
|
428
|
-
seed: 0x9e3779b9,
|
|
429
|
-
},
|
|
430
|
-
seedChunks,
|
|
431
|
-
);
|
|
432
|
-
createTopicStore(
|
|
433
|
-
runtime.currentStateDir,
|
|
434
|
-
).replaceTopicModel(model);
|
|
435
|
-
runtime.logger.info("wiki_seed", {
|
|
436
|
-
clusterCount: model.k,
|
|
437
|
-
sourceChunks: seedChunks.length,
|
|
438
|
-
totalChunks: model.totalChunks,
|
|
439
|
-
method: "kmeans+tfidf",
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
} catch (seedErr) {
|
|
446
|
-
runtime.logger.warn("wiki_seed_failed", {
|
|
447
|
-
error: String(seedErr),
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
457
|
// S27 Task 6: Fire-and-forget dedup pipeline.
|
|
451
458
|
// Deduplicates raw_transcript rows for the compacted range.
|
|
452
459
|
try {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-handler.ts — Context Health turn_end hook (v0.12).
|
|
3
|
+
*
|
|
4
|
+
* Computes a composite 0-1 health score per turn from five sub-scores
|
|
5
|
+
* (drift, output quality, error rate, cache health, cache poison),
|
|
6
|
+
* persists it to the context_health SQLite table, and emits a dashboard
|
|
7
|
+
* event. All paths non-fatal — health monitoring never breaks the agent loop.
|
|
8
|
+
*
|
|
9
|
+
* PREVENT-PI-004: zero network (local TrigramEmbedder + SQLite only).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { MegaRuntime } from "../mega-runtime.js";
|
|
13
|
+
import type { MegaConfig } from "../mega-config.js";
|
|
14
|
+
import { defaultEmbedder } from "../../src/embedder.js";
|
|
15
|
+
import { computeOutputQuality } from "../../src/contextHealth/outputQuality.js";
|
|
16
|
+
import {
|
|
17
|
+
computeTopicDrift,
|
|
18
|
+
computeErrorEscalation,
|
|
19
|
+
computePrefixInstability,
|
|
20
|
+
computeDriftScore,
|
|
21
|
+
} from "../../src/contextHealth/drift.js";
|
|
22
|
+
import {
|
|
23
|
+
computePrefixHash,
|
|
24
|
+
checkPrefixHash,
|
|
25
|
+
evaluateCachePoison,
|
|
26
|
+
} from "../../src/contextHealth/cachePoison.js";
|
|
27
|
+
import {
|
|
28
|
+
computeHealthScore,
|
|
29
|
+
recordContextHealth,
|
|
30
|
+
type ContextHealthSubScores,
|
|
31
|
+
} from "../../src/contextHealth.js";
|
|
32
|
+
import {
|
|
33
|
+
recordCachePoisonEvent,
|
|
34
|
+
} from "../../src/store/sqlite/context-health.js";
|
|
35
|
+
|
|
36
|
+
const RING_MAX = 5;
|
|
37
|
+
|
|
38
|
+
/** Extract assistant text from the turn_end event message. */
|
|
39
|
+
function extractAssistantText(event: { message?: { role?: string; content?: unknown } }): string {
|
|
40
|
+
const msg = event.message;
|
|
41
|
+
if (!msg || msg.role !== "assistant") return "";
|
|
42
|
+
const c = msg.content;
|
|
43
|
+
if (typeof c === "string") return c;
|
|
44
|
+
if (Array.isArray(c)) {
|
|
45
|
+
return c
|
|
46
|
+
.map((p) => (p && typeof p === "object" && "text" in p ? String((p as { text?: string }).text ?? "") : ""))
|
|
47
|
+
.join(" ");
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract messages as string array for prefix hash (cache poison Layer 1). */
|
|
53
|
+
function extractMessageTexts(event: { message?: { content?: unknown } }): string[] {
|
|
54
|
+
const c = event?.message?.content;
|
|
55
|
+
if (typeof c === "string") return [c];
|
|
56
|
+
if (Array.isArray(c)) {
|
|
57
|
+
return c.map((p) => (p && typeof p === "object" && "text" in p ? String((p as { text?: string }).text ?? "") : ""));
|
|
58
|
+
}
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TurnEvent {
|
|
63
|
+
turnIndex: number;
|
|
64
|
+
message?: { role?: string; content?: unknown; stopReason?: string; usage?: { cacheRead?: number } };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Handle turn_end: compute + persist context health. Non-fatal. */
|
|
68
|
+
export function handleTurnEndHealth(
|
|
69
|
+
event: TurnEvent,
|
|
70
|
+
runtime: MegaRuntime,
|
|
71
|
+
config: MegaConfig,
|
|
72
|
+
): void {
|
|
73
|
+
if (!config.contextHealth) return;
|
|
74
|
+
try {
|
|
75
|
+
const text = extractAssistantText(event);
|
|
76
|
+
const embedder = defaultEmbedder();
|
|
77
|
+
const emb = text.length > 0 ? embedder.embed(text) : [];
|
|
78
|
+
|
|
79
|
+
// Output quality sub-score
|
|
80
|
+
let outputQuality = 1.0;
|
|
81
|
+
let repetitionRatio = 0;
|
|
82
|
+
let coherenceScore = 1.0;
|
|
83
|
+
if (config.contextHealthOutputQuality && text.length > 0) {
|
|
84
|
+
const oq = computeOutputQuality(text, embedder);
|
|
85
|
+
outputQuality = oq.score;
|
|
86
|
+
repetitionRatio = oq.repetitionRatio;
|
|
87
|
+
coherenceScore = oq.coherenceScore;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Drift sub-score
|
|
91
|
+
let drift = 1.0;
|
|
92
|
+
if (config.contextHealthDrift) {
|
|
93
|
+
const topic = computeTopicDrift(emb, runtime.recentTurnEmbeddings);
|
|
94
|
+
const error = computeErrorEscalation(runtime.recentErrorCategories);
|
|
95
|
+
const prefix = computePrefixInstability(0, 5);
|
|
96
|
+
drift = computeDriftScore(topic, error, prefix);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Error rate sub-score (1 = no errors)
|
|
100
|
+
const errorScore = computeErrorEscalation(runtime.recentErrorCategories);
|
|
101
|
+
|
|
102
|
+
// Cache health (from perf-handler)
|
|
103
|
+
const cacheHealth = runtime.rt._lastCacheHealthScore ?? 1.0;
|
|
104
|
+
|
|
105
|
+
// Cache poison sub-score
|
|
106
|
+
let cachePoison = 1.0;
|
|
107
|
+
if (config.contextHealthCachePoison) {
|
|
108
|
+
const msgTexts = extractMessageTexts(event);
|
|
109
|
+
const currentHash = computePrefixHash(msgTexts);
|
|
110
|
+
const cacheRead = event.message?.usage?.cacheRead ?? 0;
|
|
111
|
+
const l1 = checkPrefixHash(currentHash, runtime.lastPrefixHash, cacheRead);
|
|
112
|
+
const result = evaluateCachePoison({
|
|
113
|
+
currentHash,
|
|
114
|
+
storedHash: runtime.lastPrefixHash,
|
|
115
|
+
cacheRead,
|
|
116
|
+
qualityByCacheHit: [],
|
|
117
|
+
qualityByCacheMiss: [],
|
|
118
|
+
errorRateCacheHit: 0,
|
|
119
|
+
errorRateCacheMiss: 0,
|
|
120
|
+
sampleCount: 0,
|
|
121
|
+
});
|
|
122
|
+
cachePoison = result.score;
|
|
123
|
+
if (l1.poisoned) {
|
|
124
|
+
try {
|
|
125
|
+
recordCachePoisonEvent(runtime.currentStateDir, {
|
|
126
|
+
ts: Date.now(),
|
|
127
|
+
turnIndex: event.turnIndex,
|
|
128
|
+
sessionId: runtime.rt.sessionId,
|
|
129
|
+
layer: 1,
|
|
130
|
+
detail: l1.detail,
|
|
131
|
+
severity: "warn",
|
|
132
|
+
});
|
|
133
|
+
} catch { /* non-fatal */ }
|
|
134
|
+
}
|
|
135
|
+
runtime.lastPrefixHash = currentHash;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Composite
|
|
139
|
+
const sub: ContextHealthSubScores = {
|
|
140
|
+
drift,
|
|
141
|
+
outputQuality,
|
|
142
|
+
errorRate: errorScore,
|
|
143
|
+
cacheHealth,
|
|
144
|
+
cachePoison,
|
|
145
|
+
};
|
|
146
|
+
const composite = computeHealthScore(sub);
|
|
147
|
+
|
|
148
|
+
// Persist
|
|
149
|
+
recordContextHealth(runtime.currentStateDir, {
|
|
150
|
+
ts: Date.now(),
|
|
151
|
+
turnIndex: event.turnIndex,
|
|
152
|
+
sessionId: runtime.rt.sessionId,
|
|
153
|
+
driftScore: drift,
|
|
154
|
+
outputQuality,
|
|
155
|
+
errorScore,
|
|
156
|
+
cacheHealth,
|
|
157
|
+
cachePoison,
|
|
158
|
+
composite,
|
|
159
|
+
modelId: runtime.currentModel?.modelId,
|
|
160
|
+
repetitionRatio,
|
|
161
|
+
coherenceScore,
|
|
162
|
+
prefixHash: runtime.lastPrefixHash ?? undefined,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Emit dashboard event
|
|
166
|
+
runtime.dashboard.event("context_health", {
|
|
167
|
+
composite,
|
|
168
|
+
drift,
|
|
169
|
+
outputQuality,
|
|
170
|
+
errorRate: errorScore,
|
|
171
|
+
cacheHealth,
|
|
172
|
+
cachePoison,
|
|
173
|
+
turnIndex: event.turnIndex,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Update ring buffers
|
|
177
|
+
runtime.recentTurnEmbeddings.push(emb);
|
|
178
|
+
if (runtime.recentTurnEmbeddings.length > RING_MAX) {
|
|
179
|
+
runtime.recentTurnEmbeddings.shift();
|
|
180
|
+
}
|
|
181
|
+
runtime.recentErrorCategories.push(runtime.lastErrorCategory);
|
|
182
|
+
if (runtime.recentErrorCategories.length > RING_MAX) {
|
|
183
|
+
runtime.recentErrorCategories.shift();
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
runtime.logger?.error("context_health_failed", {
|
|
187
|
+
error: String(e instanceof Error ? e.message : e),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -42,7 +42,11 @@ export function resetRuntimeImpl(
|
|
|
42
42
|
self: ResetRuntimeContext,
|
|
43
43
|
sessionId: string | undefined,
|
|
44
44
|
): void {
|
|
45
|
-
|
|
45
|
+
// Only call normalizeSessionId when a real sessionId string is passed.
|
|
46
|
+
// When undefined, keep the existing self.rt.sessionId (set by the real
|
|
47
|
+
// session-start / compact pipeline) so the early-return guard can fire and
|
|
48
|
+
// dashboard stats keyed by sessionId are not silently orphaned.
|
|
49
|
+
const sid = sessionId ? normalizeSessionId(sessionId) : self.rt.sessionId;
|
|
46
50
|
if (self.rt.sessionId === sid && self.rt.persistedThisSession) return; // same session, keep checkpoint memory
|
|
47
51
|
self.rt = {
|
|
48
52
|
sessionId: sid,
|
|
@@ -263,6 +263,13 @@ export class MegaRuntime {
|
|
|
263
263
|
diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
|
|
264
264
|
diagCtxThrown = 0; // live-trim try threw (caught)
|
|
265
265
|
|
|
266
|
+
// Context health instrumentation (v0.12): rolling ring buffers for
|
|
267
|
+
// drift detection + cache poison Layer 1 hash baseline.
|
|
268
|
+
recentTurnEmbeddings: number[][] = [];
|
|
269
|
+
recentErrorCategories: (string | null)[] = [];
|
|
270
|
+
lastPrefixHash: string | null = null;
|
|
271
|
+
lastErrorCategory: string | null = null;
|
|
272
|
+
|
|
266
273
|
/**
|
|
267
274
|
* S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
|
|
268
275
|
* bug was invisible because captureModel swallowed the DB write in a silent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-mega-compact",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contextHealth/cachePoison.ts — tri-layer KV cache poison validator (v0.9.1).
|
|
3
|
+
*
|
|
4
|
+
* Three validation layers for detecting corrupted KV cache state. Each layer
|
|
5
|
+
* triggers independently; the composite score maps the number of triggered
|
|
6
|
+
* layers to a 0-1 health metric (1 = healthy, 0 = poisoned).
|
|
7
|
+
*
|
|
8
|
+
* Pure functions — zero I/O, zero SQLite, zero network. All inputs are
|
|
9
|
+
* primitive values or arrays passed in; this module makes no external calls
|
|
10
|
+
* and carries no state between invocations.
|
|
11
|
+
*
|
|
12
|
+
* Guardrails: no `any` (PREVENT-011), no un-null-checked JSON.parse
|
|
13
|
+
* (PREVENT-001), zero network (PREVENT-PI-004 trivially satisfied — this
|
|
14
|
+
* module never calls fetch or opens sockets).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** FNV-1a 32-bit offset basis (must match TrigramEmbedder._embedRaw). */
|
|
18
|
+
const FNV_OFFSET_BASIS = 0x811c9dc5;
|
|
19
|
+
/** FNV-1a 32-bit prime (must match TrigramEmbedder._embedRaw). */
|
|
20
|
+
const FNV_PRIME = 0x01000193;
|
|
21
|
+
/** Max input characters for prefix hash (4 KB of UTF-16). */
|
|
22
|
+
const PREFIX_HASH_CAP = 4096;
|
|
23
|
+
|
|
24
|
+
// ─── Layer 1 — Hash validation ──────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compute a stable 32-bit FNV-1a hash over the first 4 KB of the concatenated
|
|
28
|
+
* message text. Returns a lower-case hex string.
|
|
29
|
+
*
|
|
30
|
+
* The algorithm is identical to `TrigramEmbedder._embedRaw`'s internal hash
|
|
31
|
+
* (offset basis 0x811c9dc5, prime 0x01000193), so any change visible to the
|
|
32
|
+
* embedder is also visible here.
|
|
33
|
+
*/
|
|
34
|
+
export function computePrefixHash(messages: string[]): string {
|
|
35
|
+
let combined = "";
|
|
36
|
+
for (let i = 0; i < messages.length; i++) {
|
|
37
|
+
combined += messages[i];
|
|
38
|
+
if (combined.length >= PREFIX_HASH_CAP) break;
|
|
39
|
+
}
|
|
40
|
+
const input = combined.length > PREFIX_HASH_CAP
|
|
41
|
+
? combined.slice(0, PREFIX_HASH_CAP)
|
|
42
|
+
: combined;
|
|
43
|
+
|
|
44
|
+
let h = FNV_OFFSET_BASIS;
|
|
45
|
+
for (let i = 0; i < input.length; i++) {
|
|
46
|
+
h ^= input.charCodeAt(i);
|
|
47
|
+
h = Math.imul(h, FNV_PRIME);
|
|
48
|
+
}
|
|
49
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Layer 1: detect when the KV cache prefix has been silently mutated.
|
|
54
|
+
*
|
|
55
|
+
* Trigger conditions:
|
|
56
|
+
* - cacheRead > 0 (cache was actually used)
|
|
57
|
+
* - storedHash !== null (we have a baseline from a prior turn)
|
|
58
|
+
* - currentHash !== storedHash (prefix text has drifted)
|
|
59
|
+
*
|
|
60
|
+
* Not triggered when: cache was never read, or no baseline exists (first turn).
|
|
61
|
+
*/
|
|
62
|
+
export function checkPrefixHash(
|
|
63
|
+
currentHash: string,
|
|
64
|
+
storedHash: string | null,
|
|
65
|
+
cacheRead: number,
|
|
66
|
+
): { poisoned: boolean; detail: string } {
|
|
67
|
+
if (cacheRead === 0) {
|
|
68
|
+
return { poisoned: false, detail: "no cache read" };
|
|
69
|
+
}
|
|
70
|
+
if (storedHash === null) {
|
|
71
|
+
return { poisoned: false, detail: "no stored hash baseline" };
|
|
72
|
+
}
|
|
73
|
+
if (currentHash !== storedHash) {
|
|
74
|
+
return {
|
|
75
|
+
poisoned: true,
|
|
76
|
+
detail:
|
|
77
|
+
`prefix hash mismatch: cached prefix changed without cache invalidation (current=${currentHash} stored=${storedHash})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { poisoned: false, detail: "prefix hash matches baseline" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Layer 2 — Semantic validation ──────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** Arithmetic mean of a non-empty number array. */
|
|
86
|
+
function mean(values: number[]): number {
|
|
87
|
+
let sum = 0;
|
|
88
|
+
for (const v of values) sum += v;
|
|
89
|
+
return sum / values.length;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Layer 2: detect when cache-hit outputs are semantically worse than cache-miss
|
|
94
|
+
* outputs.
|
|
95
|
+
*
|
|
96
|
+
* Trigger conditions:
|
|
97
|
+
* - both groups have at least 3 samples (statistical floor)
|
|
98
|
+
* - cache-hit mean quality is more than 0.15 below cache-miss mean quality
|
|
99
|
+
*
|
|
100
|
+
* Not triggered when: insufficient data in either group.
|
|
101
|
+
*/
|
|
102
|
+
export function compareOutputQualityByCacheHit(
|
|
103
|
+
qualityByCacheHit: number[],
|
|
104
|
+
qualityByCacheMiss: number[],
|
|
105
|
+
): { poisoned: boolean; detail: string } {
|
|
106
|
+
const hitN = qualityByCacheHit.length;
|
|
107
|
+
const missN = qualityByCacheMiss.length;
|
|
108
|
+
|
|
109
|
+
if (hitN < 3 || missN < 3) {
|
|
110
|
+
return {
|
|
111
|
+
poisoned: false,
|
|
112
|
+
detail: `insufficient quality samples: hit=${hitN} miss=${missN} (need >= 3 each)`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const hitMean = mean(qualityByCacheHit);
|
|
117
|
+
const missMean = mean(qualityByCacheMiss);
|
|
118
|
+
const threshold = missMean - 0.15;
|
|
119
|
+
|
|
120
|
+
if (hitMean < threshold) {
|
|
121
|
+
return {
|
|
122
|
+
poisoned: true,
|
|
123
|
+
detail:
|
|
124
|
+
`cache-hit quality degraded: hitMean=${hitMean.toFixed(3)} missMean=${missMean.toFixed(3)} (threshold=${threshold.toFixed(3)})`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
poisoned: false,
|
|
129
|
+
detail: `quality comparable: hitMean=${hitMean.toFixed(3)} missMean=${missMean.toFixed(3)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Layer 3 — Behavioral validation ────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Layer 3: detect when errors cluster on cache-hit turns (indicating the cache
|
|
137
|
+
* is returning corrupt state rather than fresh computation catching them).
|
|
138
|
+
*
|
|
139
|
+
* Trigger conditions (all three must hold):
|
|
140
|
+
* - sampleCount >= 5
|
|
141
|
+
* - errorRateCacheHit > errorRateCacheMiss * 2 (hit error rate is >2x miss rate)
|
|
142
|
+
* - errorRateCacheHit > 0.1 (absolute hit error rate is non-trivial)
|
|
143
|
+
*
|
|
144
|
+
* Not triggered when: insufficient observations.
|
|
145
|
+
*/
|
|
146
|
+
export function correlateErrorsWithCacheHits(
|
|
147
|
+
errorRateCacheHit: number,
|
|
148
|
+
errorRateCacheMiss: number,
|
|
149
|
+
sampleCount: number,
|
|
150
|
+
): { poisoned: boolean; detail: string } {
|
|
151
|
+
if (sampleCount < 5) {
|
|
152
|
+
return {
|
|
153
|
+
poisoned: false,
|
|
154
|
+
detail: `insufficient behavioral samples: n=${sampleCount} (need >= 5)`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
errorRateCacheHit > errorRateCacheMiss * 2 &&
|
|
159
|
+
errorRateCacheHit > 0.1
|
|
160
|
+
) {
|
|
161
|
+
return {
|
|
162
|
+
poisoned: true,
|
|
163
|
+
detail:
|
|
164
|
+
`error rate inflated on cache hits: hit=${errorRateCacheHit.toFixed(3)} miss=${errorRateCacheMiss.toFixed(3)} (2x breach + absolute floor)`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
poisoned: false,
|
|
169
|
+
detail:
|
|
170
|
+
`error rates normal: hit=${errorRateCacheHit.toFixed(3)} miss=${errorRateCacheMiss.toFixed(3)}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Composite score ─────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute a 0-1 composite cache poison score from layer triggers.
|
|
178
|
+
*
|
|
179
|
+
* | triggered layers | score |
|
|
180
|
+
* |-----------------|-------|
|
|
181
|
+
* | 0 | 1.0 |
|
|
182
|
+
* | 1 | 0.3 |
|
|
183
|
+
* | 2 | 0.15 |
|
|
184
|
+
* | 3 | 0.0 |
|
|
185
|
+
*
|
|
186
|
+
* Higher = healthier. Scores below ~0.3 warrant immediate cache invalidation.
|
|
187
|
+
*/
|
|
188
|
+
export function computeCachePoisonScore(
|
|
189
|
+
l1: boolean,
|
|
190
|
+
l2: boolean,
|
|
191
|
+
l3: boolean,
|
|
192
|
+
): number {
|
|
193
|
+
const triggers = (l1 ? 1 : 0) + (l2 ? 1 : 0) + (l3 ? 1 : 0);
|
|
194
|
+
switch (triggers) {
|
|
195
|
+
case 0: return 1.0;
|
|
196
|
+
case 1: return 0.3;
|
|
197
|
+
case 2: return 0.15;
|
|
198
|
+
case 3: return 0.0;
|
|
199
|
+
default: return 0.0; // should never reach
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Result shape returned by `evaluateCachePoison`. */
|
|
204
|
+
export interface CachePoisonResult {
|
|
205
|
+
score: number;
|
|
206
|
+
layer1: { poisoned: boolean; detail: string };
|
|
207
|
+
layer2: { poisoned: boolean; detail: string };
|
|
208
|
+
layer3: { poisoned: boolean; detail: string };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Run all three validation layers and return a composite result.
|
|
213
|
+
*
|
|
214
|
+
* All arguments are primitive values; this function is pure and stateless.
|
|
215
|
+
*/
|
|
216
|
+
export function evaluateCachePoison(args: {
|
|
217
|
+
currentHash: string;
|
|
218
|
+
storedHash: string | null;
|
|
219
|
+
cacheRead: number;
|
|
220
|
+
qualityByCacheHit: number[];
|
|
221
|
+
qualityByCacheMiss: number[];
|
|
222
|
+
errorRateCacheHit: number;
|
|
223
|
+
errorRateCacheMiss: number;
|
|
224
|
+
sampleCount: number;
|
|
225
|
+
}): CachePoisonResult {
|
|
226
|
+
const l1 = checkPrefixHash(args.currentHash, args.storedHash, args.cacheRead);
|
|
227
|
+
const l2 = compareOutputQualityByCacheHit(
|
|
228
|
+
args.qualityByCacheHit,
|
|
229
|
+
args.qualityByCacheMiss,
|
|
230
|
+
);
|
|
231
|
+
const l3 = correlateErrorsWithCacheHits(
|
|
232
|
+
args.errorRateCacheHit,
|
|
233
|
+
args.errorRateCacheMiss,
|
|
234
|
+
args.sampleCount,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
score: computeCachePoisonScore(l1.poisoned, l2.poisoned, l3.poisoned),
|
|
239
|
+
layer1: l1,
|
|
240
|
+
layer2: l2,
|
|
241
|
+
layer3: l3,
|
|
242
|
+
};
|
|
243
|
+
}
|