pi-mega-compact 0.9.2 → 0.11.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/README.md +3 -2
- package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
- package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
- package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
- package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
- package/dist/extensions/dashboard-server/routes-topics.js +87 -0
- package/dist/extensions/dashboard-server/routes-turns.js +264 -0
- package/dist/extensions/dashboard-server/routes.js +3 -1
- package/dist/extensions/dashboard-server/server.js +6 -2
- package/dist/extensions/mega-compact.js +10 -6
- package/dist/extensions/mega-config.js +4 -0
- package/dist/extensions/mega-events/agent-handlers.js +62 -49
- package/dist/extensions/mega-events/context-handler.js +48 -2
- package/dist/extensions/mega-events/session-handlers.js +25 -1
- package/dist/extensions/mega-metrics-cmds.js +90 -0
- package/dist/extensions/mega-pipeline/recall.js +47 -17
- package/dist/extensions/mega-topics-cmds.js +111 -0
- package/dist/extensions/mega-turn-store.js +110 -0
- package/dist/extensions/mega-turn-store.test.js +68 -0
- package/dist/src/config/turns.js +48 -0
- package/dist/src/fork.js +33 -0
- package/dist/src/fork.test.js +81 -0
- package/dist/src/intent.js +51 -0
- package/dist/src/intent.test.js +78 -0
- package/dist/src/metrics/index.js +4 -0
- package/dist/src/metrics/turns.js +118 -0
- package/dist/src/metrics/turns.test.js +152 -0
- package/dist/src/store/sqlite/raw-transcript.js +5 -3
- package/dist/src/store/sqlite.dbmirror.test.js +9 -0
- package/dist/src/store/turns/connection.js +123 -0
- package/dist/src/store/turns/contract-compliance.test.js +428 -0
- package/dist/src/store/turns/index.js +31 -0
- package/dist/src/store/turns/memory-store.js +357 -0
- package/dist/src/store/turns/memory-store.test.js +9 -0
- package/dist/src/store/turns/migrations.js +178 -0
- package/dist/src/store/turns/migrations.test.js +138 -0
- package/dist/src/store/turns/schema.js +115 -0
- package/dist/src/store/turns/sqlite-store.js +455 -0
- package/dist/src/store/turns/sqlite-store.test.js +144 -0
- package/dist/src/store/turns/types.js +16 -0
- package/dist/src/topics/cluster.js +141 -0
- package/dist/src/topics/cluster.test.js +150 -0
- package/dist/src/topics/index.js +6 -0
- package/dist/src/topics/kselection.js +142 -0
- package/dist/src/topics/labels.js +56 -0
- package/dist/src/topics/store.js +119 -0
- package/dist/src/topics/store.test.js +117 -0
- package/dist/src/topics/types.js +8 -0
- package/dist/src/wiki.js +133 -0
- package/dist/src/wiki.test.js +171 -0
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DZp_xQOW.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DZp_xQOW.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-BzFHpAHM.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-BzFHpAHM.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DEQbTq2T.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DEQbTq2T.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-DQ4FG33J.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-DQ4FG33J.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-D5HoEVYo.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-D5HoEVYo.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Jzsc4yeY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Jzsc4yeY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-D_XnsEYX.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-D_XnsEYX.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-BMREvAth.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-BMREvAth.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DuyhOktG.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DuyhOktG.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-mz_vz8i2.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-mz_vz8i2.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-B8AIjNTY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-B8AIjNTY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js +2 -0
- package/extensions/dashboard-client/dist/assets/TopicsTab-DKuct4oD.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js +2 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-11MGLxY8.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-CqLN7STl.js} +10 -10
- package/extensions/dashboard-client/dist/assets/index-CqLN7STl.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-CXrQSbVY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-CXrQSbVY.js.map} +1 -1
- package/extensions/dashboard-client/dist/index.html +2 -2
- package/extensions/dashboard-client/src/App.tsx +10 -2
- package/extensions/dashboard-client/src/api/client.ts +90 -1
- package/extensions/dashboard-client/src/main.tsx +1 -0
- package/extensions/dashboard-client/src/styles/turns.css +142 -0
- package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +186 -0
- package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +336 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +394 -291
- package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
- package/extensions/dashboard-server/api-contracts/index.ts +153 -133
- package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
- package/extensions/dashboard-server/api-contracts.test.ts +920 -919
- package/extensions/dashboard-server/routes-topics.ts +119 -0
- package/extensions/dashboard-server/routes-turns.ts +305 -0
- package/extensions/dashboard-server/routes.ts +8 -1
- package/extensions/dashboard-server/server.ts +6 -2
- package/extensions/mega-compact.ts +75 -59
- package/extensions/mega-config.ts +232 -220
- package/extensions/mega-events/agent-handlers.ts +194 -151
- package/extensions/mega-events/context-handler.ts +50 -1
- package/extensions/mega-events/session-handlers.ts +26 -5
- package/extensions/mega-metrics-cmds.ts +114 -0
- package/extensions/mega-pipeline/recall.ts +215 -152
- package/extensions/mega-topics-cmds.ts +151 -0
- package/extensions/mega-turn-store.test.ts +108 -0
- package/extensions/mega-turn-store.ts +169 -0
- package/package.json +3 -1
- package/src/config/turns.ts +69 -0
- package/src/fork.test.ts +94 -0
- package/src/fork.ts +78 -0
- package/src/intent.test.ts +87 -0
- package/src/intent.ts +115 -0
- package/src/metrics/index.ts +9 -0
- package/src/metrics/turns.test.ts +165 -0
- package/src/metrics/turns.ts +201 -0
- package/src/store/sqlite/raw-transcript.ts +9 -3
- package/src/store/sqlite.dbmirror.test.ts +10 -0
- package/src/store/turns/connection.ts +137 -0
- package/src/store/turns/contract-compliance.test.ts +562 -0
- package/src/store/turns/index.ts +65 -0
- package/src/store/turns/memory-store.test.ts +15 -0
- package/src/store/turns/memory-store.ts +469 -0
- package/src/store/turns/migrations.test.ts +161 -0
- package/src/store/turns/migrations.ts +203 -0
- package/src/store/turns/schema.ts +158 -0
- package/src/store/turns/sqlite-store.test.ts +161 -0
- package/src/store/turns/sqlite-store.ts +656 -0
- package/src/store/turns/types.ts +181 -0
- package/src/topics/cluster.test.ts +176 -0
- package/src/topics/cluster.ts +191 -0
- package/src/topics/index.ts +18 -0
- package/src/topics/kselection.ts +182 -0
- package/src/topics/labels.ts +67 -0
- package/src/topics/store.test.ts +132 -0
- package/src/topics/store.ts +208 -0
- package/src/topics/types.ts +64 -0
- package/src/wiki.test.ts +217 -0
- package/src/wiki.ts +198 -0
- package/dist/extensions/openclaw-mega-compact.js +0 -291
- package/dist/src/minilm.js +0 -92
- package/dist/src/wordpiece.js +0 -129
- package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intent.test.ts — S52A rewind-intent queue tests. No network; temp dirs.
|
|
3
|
+
*/
|
|
4
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { openTurnStore, closeAllTurnDbs } from "./store/turns/connection.js";
|
|
10
|
+
import { openIntentQueue } from "./intent.js";
|
|
11
|
+
let tmpDir;
|
|
12
|
+
let counter = 0;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mc-intent-"));
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
closeAllTurnDbs();
|
|
18
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
function stateDir() {
|
|
21
|
+
return join(tmpDir, `run-${counter++}`);
|
|
22
|
+
}
|
|
23
|
+
test("postIntent creates a pending intent", () => {
|
|
24
|
+
const dir = stateDir();
|
|
25
|
+
const db = openTurnStore(dir);
|
|
26
|
+
const q = openIntentQueue(db);
|
|
27
|
+
const intent = q.postIntent({ conversationId: "conv_a", targetTurnIndex: 3 });
|
|
28
|
+
assert.equal(intent.conversationId, "conv_a");
|
|
29
|
+
assert.equal(intent.targetTurnIndex, 3);
|
|
30
|
+
assert.equal(intent.status, "pending");
|
|
31
|
+
assert.ok(intent.id.length > 0);
|
|
32
|
+
assert.ok(intent.createdAt > 0);
|
|
33
|
+
});
|
|
34
|
+
test("pendingIntents lists only unconsumed", () => {
|
|
35
|
+
const dir = stateDir();
|
|
36
|
+
const db = openTurnStore(dir);
|
|
37
|
+
const q = openIntentQueue(db);
|
|
38
|
+
q.postIntent({ conversationId: "c1", targetTurnIndex: 1 });
|
|
39
|
+
const i2 = q.postIntent({ conversationId: "c2", targetTurnIndex: 2 });
|
|
40
|
+
assert.equal(q.pendingIntents().length, 2);
|
|
41
|
+
q.consumeIntent(i2.id);
|
|
42
|
+
assert.equal(q.pendingIntents().length, 1);
|
|
43
|
+
assert.equal(q.pendingIntents()[0].conversationId, "c1");
|
|
44
|
+
});
|
|
45
|
+
test("consumeIntent marks status consumed (not deleted)", () => {
|
|
46
|
+
const dir = stateDir();
|
|
47
|
+
const db = openTurnStore(dir);
|
|
48
|
+
const q = openIntentQueue(db);
|
|
49
|
+
const intent = q.postIntent({ conversationId: "c", targetTurnIndex: 0 });
|
|
50
|
+
q.consumeIntent(intent.id);
|
|
51
|
+
assert.equal(q.pendingIntents().length, 0);
|
|
52
|
+
const all = q.allIntents();
|
|
53
|
+
assert.equal(all.length, 1);
|
|
54
|
+
assert.equal(all[0].status, "consumed");
|
|
55
|
+
assert.equal(all[0].id, intent.id);
|
|
56
|
+
});
|
|
57
|
+
test("abandonIntent deletes the intent", () => {
|
|
58
|
+
const dir = stateDir();
|
|
59
|
+
const db = openTurnStore(dir);
|
|
60
|
+
const q = openIntentQueue(db);
|
|
61
|
+
const intent = q.postIntent({ conversationId: "c", targetTurnIndex: 0 });
|
|
62
|
+
q.abandonIntent(intent.id);
|
|
63
|
+
assert.equal(q.pendingIntents().length, 0);
|
|
64
|
+
assert.equal(q.allIntents().length, 0);
|
|
65
|
+
});
|
|
66
|
+
test("allIntents returns newest-first + respects limit", () => {
|
|
67
|
+
const dir = stateDir();
|
|
68
|
+
const db = openTurnStore(dir);
|
|
69
|
+
const q = openIntentQueue(db);
|
|
70
|
+
q.postIntent({ conversationId: "a", targetTurnIndex: 0 });
|
|
71
|
+
q.postIntent({ conversationId: "b", targetTurnIndex: 0 });
|
|
72
|
+
q.postIntent({ conversationId: "c", targetTurnIndex: 0 });
|
|
73
|
+
const all = q.allIntents();
|
|
74
|
+
assert.equal(all.length, 3);
|
|
75
|
+
// newest-first: c was posted last
|
|
76
|
+
assert.equal(all[0].conversationId, "c");
|
|
77
|
+
assert.equal(q.allIntents(2).length, 2);
|
|
78
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** Approximate byte length of a text (utf8). */
|
|
2
|
+
function byteLen(s) {
|
|
3
|
+
return Buffer.byteLength(s, "utf8");
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Per-turn metrics for a conversation. Combines turns.db (turn spine + recall)
|
|
7
|
+
* with main-db raw_transcript (dedup) + checkpoint_epochs (compression).
|
|
8
|
+
*
|
|
9
|
+
* `mainDb` may be a store without raw_transcript/checkpoint_epochs (e.g. a
|
|
10
|
+
* reuse host that only mirrors turns); in that case dedup/compression = 0 and
|
|
11
|
+
* rawMessageCount = 0. Detected defensively per-query.
|
|
12
|
+
*/
|
|
13
|
+
export function turnMetrics(store, mainDb, conversationId) {
|
|
14
|
+
const turns = store.query({ conversationId, limit: 10000 });
|
|
15
|
+
if (turns.length === 0)
|
|
16
|
+
return [];
|
|
17
|
+
const hasRaw = tableExists(mainDb, "raw_transcript");
|
|
18
|
+
const hasEpoch = tableExists(mainDb, "checkpoint_epochs");
|
|
19
|
+
// Aggregate raw_transcript per turn_index for this session (raw rows + unique
|
|
20
|
+
// content_hash). Grouped by turn_index; sessions may share turn_index values,
|
|
21
|
+
// so we scope by session_id below per turn.
|
|
22
|
+
const rawAggBySessionTurn = new Map();
|
|
23
|
+
if (hasRaw) {
|
|
24
|
+
const sessionIds = [...new Set(turns.map((t) => t.sessionId))];
|
|
25
|
+
const placeholders = sessionIds.map(() => "?").join(",");
|
|
26
|
+
const rows = mainDb
|
|
27
|
+
.prepare(`SELECT session_id, turn_index,
|
|
28
|
+
COUNT(*) AS raw_count,
|
|
29
|
+
COUNT(DISTINCT content_hash) AS unique_count
|
|
30
|
+
FROM raw_transcript
|
|
31
|
+
WHERE session_id IN (${placeholders}) AND turn_index IS NOT NULL
|
|
32
|
+
GROUP BY session_id, turn_index`)
|
|
33
|
+
.all(...sessionIds);
|
|
34
|
+
for (const r of rows) {
|
|
35
|
+
rawAggBySessionTurn.set(`${r.session_id}::${r.turn_index}`, r);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Compression ratio per epoch: committed raw bytes → summary bytes. Precomputed
|
|
39
|
+
// per epoch referenced by any turn.
|
|
40
|
+
const compressionByEpoch = new Map();
|
|
41
|
+
if (hasEpoch) {
|
|
42
|
+
const epochIds = turns
|
|
43
|
+
.map((t) => t.epochId)
|
|
44
|
+
.filter((e) => e != null);
|
|
45
|
+
for (const eid of new Set(epochIds)) {
|
|
46
|
+
const ratio = epochCompressionRatio(mainDb, eid);
|
|
47
|
+
if (ratio != null)
|
|
48
|
+
compressionByEpoch.set(eid, ratio);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return turns.map((t) => {
|
|
52
|
+
const recall = store.listRecallByIndex(conversationId, t.turnIndex);
|
|
53
|
+
const agg = rawAggBySessionTurn.get(`${t.sessionId}::${t.turnIndex}`);
|
|
54
|
+
const rawCount = agg?.raw_count ?? 0;
|
|
55
|
+
const uniqueCount = agg?.unique_count ?? 0;
|
|
56
|
+
return {
|
|
57
|
+
turnId: t.turnIndex,
|
|
58
|
+
conversationId: t.conversationId,
|
|
59
|
+
sessionId: t.sessionId,
|
|
60
|
+
turnIndex: t.turnIndex,
|
|
61
|
+
epochId: t.epochId ?? null,
|
|
62
|
+
ctxTokens: t.ctxTokens ?? null,
|
|
63
|
+
ctxPercent: t.ctxPercent ?? null,
|
|
64
|
+
recallCount: recall.length,
|
|
65
|
+
rawMessageCount: rawCount,
|
|
66
|
+
dedupUniqueRatio: rawCount > 0 ? uniqueCount / rawCount : 0,
|
|
67
|
+
compressionRatio: t.epochId
|
|
68
|
+
? (compressionByEpoch.get(t.epochId) ?? 0)
|
|
69
|
+
: 0,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Aggregate a conversation's per-turn metrics. */
|
|
74
|
+
export function conversationMetrics(store, mainDb, conversationId) {
|
|
75
|
+
const perTurn = turnMetrics(store, mainDb, conversationId);
|
|
76
|
+
const turnCount = perTurn.length;
|
|
77
|
+
const totalRecall = perTurn.reduce((s, t) => s + t.recallCount, 0);
|
|
78
|
+
const totalRaw = perTurn.reduce((s, t) => s + t.rawMessageCount, 0);
|
|
79
|
+
const dedupSum = perTurn.reduce((s, t) => s + t.dedupUniqueRatio, 0);
|
|
80
|
+
const compSum = perTurn.reduce((s, t) => s + t.compressionRatio, 0);
|
|
81
|
+
const epochs = new Set(perTurn.map((t) => t.epochId).filter(Boolean));
|
|
82
|
+
return {
|
|
83
|
+
conversationId,
|
|
84
|
+
turnCount,
|
|
85
|
+
totalRecall,
|
|
86
|
+
totalRawMessages: totalRaw,
|
|
87
|
+
avgDedupUniqueRatio: turnCount > 0 ? dedupSum / turnCount : 0,
|
|
88
|
+
avgCompressionRatio: turnCount > 0 ? compSum / turnCount : 0,
|
|
89
|
+
epochCount: epochs.size,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function tableExists(db, name) {
|
|
93
|
+
return (db
|
|
94
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
|
95
|
+
.get(name) !== undefined);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Compression ratio for an epoch: summaryMessageText bytes ÷ raw bytes of the
|
|
99
|
+
* committed range. Returns null when the epoch or its raw rows are absent.
|
|
100
|
+
* Ratio < 1 means compression shrank the content (lower = more compression).
|
|
101
|
+
*/
|
|
102
|
+
function epochCompressionRatio(mainDb, epochId) {
|
|
103
|
+
const epoch = mainDb
|
|
104
|
+
.prepare("SELECT session_id, committed_seq, summary_message_text FROM checkpoint_epochs WHERE epoch_id = ?")
|
|
105
|
+
.get(epochId);
|
|
106
|
+
if (!epoch)
|
|
107
|
+
return null;
|
|
108
|
+
const raw = mainDb
|
|
109
|
+
.prepare(`SELECT COALESCE(SUM(LENGTH(content_bytes)), 0) AS bytes
|
|
110
|
+
FROM raw_transcript
|
|
111
|
+
WHERE session_id = ? AND seq <= ?`)
|
|
112
|
+
.get(epoch.session_id, epoch.committed_seq);
|
|
113
|
+
const rawBytes = raw?.bytes ?? 0;
|
|
114
|
+
if (rawBytes <= 0)
|
|
115
|
+
return null;
|
|
116
|
+
const summaryBytes = byteLen(epoch.summary_message_text ?? "");
|
|
117
|
+
return summaryBytes / rawBytes;
|
|
118
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turns.test.ts — S50C per-turn + per-conversation metrics tests. No network.
|
|
3
|
+
* Seeds a turns.db (S49 contract store) and a main db (raw_transcript +
|
|
4
|
+
* checkpoint_epochs) and asserts the rollups match seeded truth.
|
|
5
|
+
*/
|
|
6
|
+
import { test, beforeEach, afterEach } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { createTurnStore, closeAllTurnDbs, } from "../store/turns/index.js";
|
|
13
|
+
import { openStore } from "../store/sqlite/utils.js";
|
|
14
|
+
import { appendRawTranscript } from "../store/sqlite/raw-transcript.js";
|
|
15
|
+
import { writeCheckpointEpoch } from "../store/sqlite/raw-transcript.js";
|
|
16
|
+
import { turnMetrics, conversationMetrics } from "./turns.js";
|
|
17
|
+
let tmpDir;
|
|
18
|
+
let counter = 0;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mc-metrics-"));
|
|
21
|
+
});
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
closeAllTurnDbs();
|
|
24
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
function stateDir() {
|
|
27
|
+
return join(tmpDir, `run-${counter++}`);
|
|
28
|
+
}
|
|
29
|
+
/** Seed one conversation: 2 turns, recall on turn 0, raw messages, one epoch. */
|
|
30
|
+
function seed(dir) {
|
|
31
|
+
const store = createTurnStore({ stateDir: dir });
|
|
32
|
+
const conv = store.ensureConversationId("sess_m");
|
|
33
|
+
const t0 = store.appendTurn({
|
|
34
|
+
conversationId: conv,
|
|
35
|
+
sessionId: "sess_m",
|
|
36
|
+
turnIndex: 0,
|
|
37
|
+
role: "assistant",
|
|
38
|
+
endedAt: Date.now(),
|
|
39
|
+
ctxTokens: 800,
|
|
40
|
+
ctxPercent: 0.4,
|
|
41
|
+
});
|
|
42
|
+
store.appendRecall({
|
|
43
|
+
turnId: t0,
|
|
44
|
+
checkpointId: "cp_1",
|
|
45
|
+
score: 0.9,
|
|
46
|
+
source: "checkpoint",
|
|
47
|
+
});
|
|
48
|
+
store.appendRecall({
|
|
49
|
+
turnId: t0,
|
|
50
|
+
checkpointId: "cp_2",
|
|
51
|
+
score: 0.8,
|
|
52
|
+
source: "checkpoint",
|
|
53
|
+
});
|
|
54
|
+
store.appendTurn({
|
|
55
|
+
conversationId: conv,
|
|
56
|
+
sessionId: "sess_m",
|
|
57
|
+
turnIndex: 1,
|
|
58
|
+
role: "assistant",
|
|
59
|
+
endedAt: Date.now(),
|
|
60
|
+
ctxTokens: 1200,
|
|
61
|
+
});
|
|
62
|
+
store.asAdmin().stampTurnsEpoch("sess_m", "ep_1");
|
|
63
|
+
const main = openStore(dir);
|
|
64
|
+
// 5 raw messages in the committed range; ep_1's summary is much smaller
|
|
65
|
+
// (compression ratio < 1). turn_index is set on all of them.
|
|
66
|
+
const msgs = [
|
|
67
|
+
{ hash: "h0", turn: 0, bytes: "aaaaaaaa" },
|
|
68
|
+
{ hash: "h1", turn: 0, bytes: "bbbbbbbb" },
|
|
69
|
+
{ hash: "h2", turn: 1, bytes: "cccccccc" },
|
|
70
|
+
{ hash: "h3", turn: 1, bytes: "dddddddd" },
|
|
71
|
+
{ hash: "h4", turn: 1, bytes: "eeeeeeee" },
|
|
72
|
+
];
|
|
73
|
+
for (const m of msgs) {
|
|
74
|
+
appendRawTranscript(main, {
|
|
75
|
+
contentHash: m.hash,
|
|
76
|
+
sessionId: "sess_m",
|
|
77
|
+
seq: 0,
|
|
78
|
+
role: "user",
|
|
79
|
+
contentBytes: m.bytes,
|
|
80
|
+
toolName: null,
|
|
81
|
+
messageTimestamp: null,
|
|
82
|
+
checkpointEpoch: "ep_1",
|
|
83
|
+
turnIndex: m.turn,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
writeCheckpointEpoch(main, {
|
|
87
|
+
epochId: "ep_1",
|
|
88
|
+
sessionId: "sess_m",
|
|
89
|
+
startedSeq: 0,
|
|
90
|
+
committedSeq: 3,
|
|
91
|
+
summaryMessageText: "sum",
|
|
92
|
+
cutIndex: 3,
|
|
93
|
+
checkpointId: "cp_x",
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
});
|
|
96
|
+
return { store, conv };
|
|
97
|
+
}
|
|
98
|
+
test("turnMetrics returns per-turn recall + dedup + compression", () => {
|
|
99
|
+
const dir = stateDir();
|
|
100
|
+
const { store, conv } = seed(dir);
|
|
101
|
+
const main = openStore(dir);
|
|
102
|
+
const rows = turnMetrics(store, main, conv);
|
|
103
|
+
assert.equal(rows.length, 2);
|
|
104
|
+
const t0 = rows.find((r) => r.turnIndex === 0);
|
|
105
|
+
assert.equal(t0.recallCount, 2);
|
|
106
|
+
assert.equal(t0.rawMessageCount, 2);
|
|
107
|
+
assert.equal(t0.dedupUniqueRatio, 1); // PK(content_hash,session_id) → all stored rows unique
|
|
108
|
+
const t1 = rows.find((r) => r.turnIndex === 1);
|
|
109
|
+
assert.equal(t1.rawMessageCount, 3);
|
|
110
|
+
assert.equal(t1.epochId, "ep_1");
|
|
111
|
+
assert.ok(t0.compressionRatio > 0 && t0.compressionRatio < 1); // summary smaller than raw
|
|
112
|
+
store.close();
|
|
113
|
+
});
|
|
114
|
+
test("conversationMetrics aggregates per-turn metrics", () => {
|
|
115
|
+
const dir = stateDir();
|
|
116
|
+
const { store, conv } = seed(dir);
|
|
117
|
+
const main = openStore(dir);
|
|
118
|
+
const m = conversationMetrics(store, main, conv);
|
|
119
|
+
assert.equal(m.turnCount, 2);
|
|
120
|
+
assert.equal(m.totalRecall, 2);
|
|
121
|
+
assert.equal(m.totalRawMessages, 5);
|
|
122
|
+
assert.equal(m.epochCount, 1);
|
|
123
|
+
assert.ok(m.avgDedupUniqueRatio > 0);
|
|
124
|
+
store.close();
|
|
125
|
+
});
|
|
126
|
+
test("metrics tolerate a main db without raw_transcript (reuse host)", () => {
|
|
127
|
+
const dir = stateDir();
|
|
128
|
+
const store = createTurnStore({ stateDir: dir });
|
|
129
|
+
const conv = store.ensureConversationId("sess_n");
|
|
130
|
+
const t = store.appendTurn({
|
|
131
|
+
conversationId: conv,
|
|
132
|
+
sessionId: "sess_n",
|
|
133
|
+
turnIndex: 0,
|
|
134
|
+
role: "assistant",
|
|
135
|
+
endedAt: Date.now(),
|
|
136
|
+
});
|
|
137
|
+
store.appendRecall({
|
|
138
|
+
turnId: t,
|
|
139
|
+
checkpointId: "cp",
|
|
140
|
+
score: 0.5,
|
|
141
|
+
source: "checkpoint",
|
|
142
|
+
});
|
|
143
|
+
// Bare in-memory db with NO raw_transcript / checkpoint_epochs tables.
|
|
144
|
+
const bare = new DatabaseSync(":memory:");
|
|
145
|
+
const rows = turnMetrics(store, bare, conv);
|
|
146
|
+
assert.equal(rows.length, 1);
|
|
147
|
+
assert.equal(rows[0].recallCount, 1);
|
|
148
|
+
assert.equal(rows[0].rawMessageCount, 0);
|
|
149
|
+
assert.equal(rows[0].dedupUniqueRatio, 0);
|
|
150
|
+
assert.equal(rows[0].compressionRatio, 0);
|
|
151
|
+
store.close();
|
|
152
|
+
});
|
|
@@ -9,6 +9,7 @@ function rowToRawTranscript(row) {
|
|
|
9
9
|
toolName: row.tool_name ?? null,
|
|
10
10
|
messageTimestamp: row.message_timestamp == null ? null : Number(row.message_timestamp),
|
|
11
11
|
checkpointEpoch: row.checkpoint_epoch,
|
|
12
|
+
turnIndex: row.turn_index == null ? null : Number(row.turn_index),
|
|
12
13
|
};
|
|
13
14
|
}
|
|
14
15
|
function rowToCheckpointEpoch(row) {
|
|
@@ -34,11 +35,11 @@ function rowToCheckpointEpoch(row) {
|
|
|
34
35
|
export function appendRawTranscript(db, row) {
|
|
35
36
|
withTx(db, () => {
|
|
36
37
|
db.prepare(`INSERT OR IGNORE INTO raw_transcript
|
|
37
|
-
(content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch)
|
|
38
|
+
(content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch, turn_index)
|
|
38
39
|
VALUES (
|
|
39
40
|
@content_hash, @session_id,
|
|
40
41
|
COALESCE((SELECT MAX(seq) FROM raw_transcript WHERE session_id = @session_id), 0) + 1,
|
|
41
|
-
@role, @content_bytes, @tool_name, @message_timestamp, @checkpoint_epoch
|
|
42
|
+
@role, @content_bytes, @tool_name, @message_timestamp, @checkpoint_epoch, @turn_index
|
|
42
43
|
)`).run({
|
|
43
44
|
"@content_hash": row.contentHash,
|
|
44
45
|
"@session_id": row.sessionId,
|
|
@@ -47,6 +48,7 @@ export function appendRawTranscript(db, row) {
|
|
|
47
48
|
"@tool_name": row.toolName,
|
|
48
49
|
"@message_timestamp": row.messageTimestamp,
|
|
49
50
|
"@checkpoint_epoch": row.checkpointEpoch,
|
|
51
|
+
"@turn_index": row.turnIndex ?? null,
|
|
50
52
|
});
|
|
51
53
|
});
|
|
52
54
|
}
|
|
@@ -56,7 +58,7 @@ export function appendRawTranscript(db, row) {
|
|
|
56
58
|
*/
|
|
57
59
|
export function listRawTranscriptRange(db, sessionId, fromSeq, toSeq) {
|
|
58
60
|
const rows = db
|
|
59
|
-
.prepare(`SELECT content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch
|
|
61
|
+
.prepare(`SELECT content_hash, session_id, seq, role, content_bytes, tool_name, message_timestamp, checkpoint_epoch, turn_index
|
|
60
62
|
FROM raw_transcript
|
|
61
63
|
WHERE session_id = @session_id AND seq >= @from_seq AND seq <= @to_seq
|
|
62
64
|
ORDER BY seq ASC`)
|
|
@@ -82,6 +82,15 @@ describe("DB mirror", () => {
|
|
|
82
82
|
assert.equal(rows[0].toolName, "bash");
|
|
83
83
|
assert.equal(rows[0].messageTimestamp, 1234567890);
|
|
84
84
|
});
|
|
85
|
+
it("S50A: stores turn_index when provided; null when omitted (back-compat)", () => {
|
|
86
|
+
appendRawTranscript(db, makeRow({ contentHash: "with-turn", turnIndex: 7 }));
|
|
87
|
+
appendRawTranscript(db, makeRow({ contentHash: "no-turn" }));
|
|
88
|
+
const rows = listRawTranscriptRange(db, "sess-1", 0, 999999);
|
|
89
|
+
const withTurn = rows.find((r) => r.contentHash === "with-turn");
|
|
90
|
+
const noTurn = rows.find((r) => r.contentHash === "no-turn");
|
|
91
|
+
assert.equal(withTurn?.turnIndex, 7);
|
|
92
|
+
assert.equal(noTurn?.turnIndex, null);
|
|
93
|
+
});
|
|
85
94
|
it("stores checkpoint_epoch", () => {
|
|
86
95
|
appendRawTranscript(db, makeRow({ checkpointEpoch: "ep-42" }));
|
|
87
96
|
const rows = listRawTranscriptRange(db, "sess-1", 0, 999999);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connection.ts — S49 reconciled turns.db connection manager.
|
|
3
|
+
*
|
|
4
|
+
* RECONCILIATION (s49-turn-db ∪ master): exposes master's contract-facing
|
|
5
|
+
* `openTurnDb` / `closeTurnDb` / `closeAllTurnDbs` (consumed by SqliteTurnStore)
|
|
6
|
+
* AND this branch's value-adds: closed-handle eviction, the MEGACOMPACT_TURNS_DB_PATH
|
|
7
|
+
* env override, and the one-time main-db → turns.db migration (S49B) run after
|
|
8
|
+
* schema init. The unified `initTurnSchema` (schema.ts) creates both the
|
|
9
|
+
* contract tables and the S51/S52 additive shells.
|
|
10
|
+
*
|
|
11
|
+
* Separate cache from the main memory store (src/store/sqlite/utils.ts) so a
|
|
12
|
+
* turn-DB failure can never touch the authoritative memory DB (program §2).
|
|
13
|
+
* Pi-agnostic (PREVENT-PI-004: node:sqlite in-process, no network).
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { DatabaseSync } from "node:sqlite";
|
|
18
|
+
import { TurnsConfig } from "../../config/turns.js";
|
|
19
|
+
import { initTurnSchema } from "./schema.js";
|
|
20
|
+
import { migrateTurnTablesIfNeeded } from "./migrations.js";
|
|
21
|
+
/** Default turns.db filename within a state dir. */
|
|
22
|
+
export const TURNS_DB_FILE = "turns.db";
|
|
23
|
+
// Module-level cache, SEPARATE from the memory store's cache. Keyed by the
|
|
24
|
+
// resolved db path (or `:memory:<stateDir>` for in-memory).
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
/** Resolve the turns.db path (env override wins — tests/DR). */
|
|
27
|
+
export function turnDbPath(stateDir) {
|
|
28
|
+
const override = process.env.MEGACOMPACT_TURNS_DB_PATH;
|
|
29
|
+
if (override && override.trim() !== "")
|
|
30
|
+
return override;
|
|
31
|
+
return join(stateDir, TURNS_DB_FILE);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Open (or reuse) the isolated turn database connection. Contract-facing
|
|
35
|
+
* shape consumed by `SqliteTurnStore`. Accepts `dbPath` / `inMemory` overrides
|
|
36
|
+
* for tests. Runs the unified schema init + the one-time main-db migration.
|
|
37
|
+
*/
|
|
38
|
+
export function openTurnDb(stateDir, options) {
|
|
39
|
+
const resolvedPath = options?.inMemory === true
|
|
40
|
+
? ":memory:"
|
|
41
|
+
: (options?.dbPath ?? turnDbPath(stateDir));
|
|
42
|
+
const cacheKey = options?.inMemory ? `:memory:${stateDir}` : resolvedPath;
|
|
43
|
+
const cached = cache.get(cacheKey);
|
|
44
|
+
if (cached) {
|
|
45
|
+
// A closed handle in the cache (test called db.close() directly) would
|
|
46
|
+
// surface as "database is not open" on reuse. Detect + evict.
|
|
47
|
+
try {
|
|
48
|
+
cached.exec("SELECT 1");
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
cache.delete(cacheKey);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (options?.inMemory !== true && !existsSync(stateDir)) {
|
|
56
|
+
mkdirSync(stateDir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
const db = new DatabaseSync(resolvedPath);
|
|
59
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
60
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
61
|
+
db.exec("PRAGMA busy_timeout = 5000");
|
|
62
|
+
initTurnSchema(db);
|
|
63
|
+
// S49B: one-time move of legacy main-db turn tables into turns.db (idempotent,
|
|
64
|
+
// non-fatal). File-backed only; never runs for the in-memory test backend.
|
|
65
|
+
if (options?.inMemory !== true && TurnsConfig.TURNS_DB_ENABLED) {
|
|
66
|
+
migrateTurnTablesIfNeeded(db, stateDir);
|
|
67
|
+
}
|
|
68
|
+
cache.set(cacheKey, db);
|
|
69
|
+
return db;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Back-compat alias for callers (S51 topics store, dashboard routes) that open
|
|
73
|
+
* a raw DatabaseSync against turns.db to run their own read queries. Equivalent
|
|
74
|
+
* to `openTurnDb(stateDir)` (file-backed, default path).
|
|
75
|
+
*/
|
|
76
|
+
export function openTurnStore(stateDir) {
|
|
77
|
+
return openTurnDb(stateDir);
|
|
78
|
+
}
|
|
79
|
+
/** Back-compat alias: close the cached file-backed connection for a state dir. */
|
|
80
|
+
export function closeTurnStore(stateDir) {
|
|
81
|
+
closeTurnDb(stateDir);
|
|
82
|
+
}
|
|
83
|
+
/** Close and evict a cached connection. Idempotent. */
|
|
84
|
+
export function closeTurnDb(stateDir, options) {
|
|
85
|
+
const resolvedPath = options?.inMemory === true
|
|
86
|
+
? ":memory:"
|
|
87
|
+
: (options?.dbPath ?? turnDbPath(stateDir));
|
|
88
|
+
const cacheKey = options?.inMemory ? `:memory:${stateDir}` : resolvedPath;
|
|
89
|
+
const db = cache.get(cacheKey);
|
|
90
|
+
if (!db)
|
|
91
|
+
return;
|
|
92
|
+
db.close();
|
|
93
|
+
cache.delete(cacheKey);
|
|
94
|
+
}
|
|
95
|
+
/** Close all cached connections (test teardown / graceful shutdown). */
|
|
96
|
+
export function closeAllTurnDbs() {
|
|
97
|
+
for (const db of cache.values()) {
|
|
98
|
+
try {
|
|
99
|
+
db.close();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// best-effort
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
cache.clear();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run `fn` atomically. SAVEPOINT (not BEGIN) so it nests safely under an outer
|
|
109
|
+
* transaction. Self-contained (not imported from memory utils) to keep the
|
|
110
|
+
* reuse seam clean.
|
|
111
|
+
*/
|
|
112
|
+
export function withTx(db, fn) {
|
|
113
|
+
db.exec("SAVEPOINT turns_tx");
|
|
114
|
+
try {
|
|
115
|
+
fn();
|
|
116
|
+
db.exec("RELEASE turns_tx");
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
db.exec("ROLLBACK TO turns_tx");
|
|
120
|
+
db.exec("RELEASE turns_tx");
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
}
|