pi-mega-compact 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -2
  2. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +95 -47
  3. package/dist/extensions/dashboard-server/api-contracts/index.js +1 -1
  4. package/dist/extensions/dashboard-server/api-contracts/turns.js +10 -0
  5. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  6. package/dist/extensions/dashboard-server/routes-topics.js +87 -0
  7. package/dist/extensions/dashboard-server/routes-turns.js +251 -0
  8. package/dist/extensions/dashboard-server/routes.js +3 -1
  9. package/dist/extensions/dashboard-server/server.js +6 -2
  10. package/dist/extensions/mega-compact.js +10 -6
  11. package/dist/extensions/mega-config.js +4 -0
  12. package/dist/extensions/mega-events/agent-handlers.js +62 -49
  13. package/dist/extensions/mega-events/context-handler.js +48 -2
  14. package/dist/extensions/mega-events/session-handlers.js +24 -0
  15. package/dist/extensions/mega-metrics-cmds.js +90 -0
  16. package/dist/extensions/mega-pipeline/recall.js +47 -17
  17. package/dist/extensions/mega-topics-cmds.js +111 -0
  18. package/dist/extensions/mega-turn-store.js +110 -0
  19. package/dist/extensions/mega-turn-store.test.js +68 -0
  20. package/dist/src/config/turns.js +48 -0
  21. package/dist/src/fork.js +33 -0
  22. package/dist/src/fork.test.js +81 -0
  23. package/dist/src/intent.js +51 -0
  24. package/dist/src/intent.test.js +78 -0
  25. package/dist/src/metrics/index.js +4 -0
  26. package/dist/src/metrics/turns.js +118 -0
  27. package/dist/src/metrics/turns.test.js +152 -0
  28. package/dist/src/store/sqlite/raw-transcript.js +5 -3
  29. package/dist/src/store/sqlite.dbmirror.test.js +9 -0
  30. package/dist/src/store/turns/connection.js +123 -0
  31. package/dist/src/store/turns/contract-compliance.test.js +428 -0
  32. package/dist/src/store/turns/index.js +31 -0
  33. package/dist/src/store/turns/memory-store.js +357 -0
  34. package/dist/src/store/turns/memory-store.test.js +9 -0
  35. package/dist/src/store/turns/migrations.js +178 -0
  36. package/dist/src/store/turns/migrations.test.js +138 -0
  37. package/dist/src/store/turns/schema.js +115 -0
  38. package/dist/src/store/turns/sqlite-store.js +455 -0
  39. package/dist/src/store/turns/sqlite-store.test.js +144 -0
  40. package/dist/src/store/turns/types.js +16 -0
  41. package/dist/src/topics/cluster.js +141 -0
  42. package/dist/src/topics/cluster.test.js +150 -0
  43. package/dist/src/topics/index.js +6 -0
  44. package/dist/src/topics/kselection.js +142 -0
  45. package/dist/src/topics/labels.js +56 -0
  46. package/dist/src/topics/store.js +119 -0
  47. package/dist/src/topics/store.test.js +117 -0
  48. package/dist/src/topics/types.js +8 -0
  49. package/dist/src/wiki.js +133 -0
  50. package/dist/src/wiki.test.js +171 -0
  51. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js → AchievementTiles-DUulvXUY.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DUulvXUY.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-Dx0YtCNf.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-Dx0YtCNf.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DtkaUo1v.js} +2 -2
  56. package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DtkaUo1v.js.map} +1 -1
  57. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-CLa4KfiZ.js} +2 -2
  58. package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-CLa4KfiZ.js.map} +1 -1
  59. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-Q6tEIBBi.js} +2 -2
  60. package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-Q6tEIBBi.js.map} +1 -1
  61. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Hq7GNWSY.js} +2 -2
  62. package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Hq7GNWSY.js.map} +1 -1
  63. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-BtmBV6lU.js} +2 -2
  64. package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-BtmBV6lU.js.map} +1 -1
  65. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-CL10PwVU.js} +2 -2
  66. package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-CL10PwVU.js.map} +1 -1
  67. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DAwANwbI.js} +2 -2
  68. package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DAwANwbI.js.map} +1 -1
  69. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-Ec08TZRQ.js} +2 -2
  70. package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-Ec08TZRQ.js.map} +1 -1
  71. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-COb8dpTy.js} +2 -2
  72. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-COb8dpTy.js.map} +1 -1
  73. package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js +2 -0
  74. package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js.map +1 -0
  75. package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js +2 -0
  76. package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js.map +1 -0
  77. package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-UMrussFX.js} +10 -10
  78. package/extensions/dashboard-client/dist/assets/index-UMrussFX.js.map +1 -0
  79. package/extensions/dashboard-client/dist/assets/{index-og3ghxyY.css → index-gn66lYD2.css} +1 -1
  80. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js → useSSE-BDHZ7CeS.js} +2 -2
  81. package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-BDHZ7CeS.js.map} +1 -1
  82. package/extensions/dashboard-client/dist/index.html +2 -2
  83. package/extensions/dashboard-client/src/App.tsx +10 -2
  84. package/extensions/dashboard-client/src/api/client.ts +84 -1
  85. package/extensions/dashboard-client/src/main.tsx +1 -0
  86. package/extensions/dashboard-client/src/styles/turns.css +142 -0
  87. package/extensions/dashboard-client/src/tabs/TopicsTab.tsx +185 -0
  88. package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +326 -0
  89. package/extensions/dashboard-server/api-contracts/endpoints.ts +389 -291
  90. package/extensions/dashboard-server/api-contracts/game-types.ts +141 -88
  91. package/extensions/dashboard-server/api-contracts/index.ts +153 -133
  92. package/extensions/dashboard-server/api-contracts/turns.ts +114 -0
  93. package/extensions/dashboard-server/api-contracts.test.ts +920 -919
  94. package/extensions/dashboard-server/routes-topics.ts +119 -0
  95. package/extensions/dashboard-server/routes-turns.ts +282 -0
  96. package/extensions/dashboard-server/routes.ts +8 -1
  97. package/extensions/dashboard-server/server.ts +6 -2
  98. package/extensions/mega-compact.ts +75 -59
  99. package/extensions/mega-config.ts +232 -220
  100. package/extensions/mega-events/agent-handlers.ts +194 -151
  101. package/extensions/mega-events/context-handler.ts +50 -1
  102. package/extensions/mega-events/session-handlers.ts +23 -0
  103. package/extensions/mega-metrics-cmds.ts +114 -0
  104. package/extensions/mega-pipeline/recall.ts +215 -152
  105. package/extensions/mega-topics-cmds.ts +151 -0
  106. package/extensions/mega-turn-store.test.ts +108 -0
  107. package/extensions/mega-turn-store.ts +169 -0
  108. package/package.json +3 -1
  109. package/src/config/turns.ts +69 -0
  110. package/src/fork.test.ts +94 -0
  111. package/src/fork.ts +78 -0
  112. package/src/intent.test.ts +87 -0
  113. package/src/intent.ts +115 -0
  114. package/src/metrics/index.ts +9 -0
  115. package/src/metrics/turns.test.ts +165 -0
  116. package/src/metrics/turns.ts +201 -0
  117. package/src/store/sqlite/raw-transcript.ts +9 -3
  118. package/src/store/sqlite.dbmirror.test.ts +10 -0
  119. package/src/store/turns/connection.ts +137 -0
  120. package/src/store/turns/contract-compliance.test.ts +562 -0
  121. package/src/store/turns/index.ts +65 -0
  122. package/src/store/turns/memory-store.test.ts +15 -0
  123. package/src/store/turns/memory-store.ts +469 -0
  124. package/src/store/turns/migrations.test.ts +161 -0
  125. package/src/store/turns/migrations.ts +203 -0
  126. package/src/store/turns/schema.ts +158 -0
  127. package/src/store/turns/sqlite-store.test.ts +161 -0
  128. package/src/store/turns/sqlite-store.ts +656 -0
  129. package/src/store/turns/types.ts +181 -0
  130. package/src/topics/cluster.test.ts +176 -0
  131. package/src/topics/cluster.ts +191 -0
  132. package/src/topics/index.ts +18 -0
  133. package/src/topics/kselection.ts +182 -0
  134. package/src/topics/labels.ts +67 -0
  135. package/src/topics/store.test.ts +132 -0
  136. package/src/topics/store.ts +208 -0
  137. package/src/topics/types.ts +64 -0
  138. package/src/wiki.test.ts +217 -0
  139. package/src/wiki.ts +198 -0
  140. package/dist/extensions/openclaw-mega-compact.js +0 -291
  141. package/dist/src/minilm.js +0 -92
  142. package/dist/src/wordpiece.js +0 -129
  143. package/extensions/dashboard-client/dist/assets/index-D_WtU2TV.js.map +0 -1
@@ -0,0 +1,144 @@
1
+ /**
2
+ * sqlite-store.test.ts — SqliteTurnStore compliance + SQLite-specific tests.
3
+ *
4
+ * Compliance: shared suite run in-memory (fast).
5
+ * SQLite-specific: file-backed tests verifying:
6
+ * - Separate turns.db (not sqlite.db)
7
+ * - WAL mode active
8
+ * - Connection caching (same path → same connection)
9
+ * - dbSizeBytes reports file size
10
+ * - File persistence across store instances
11
+ */
12
+ import { describe, it, beforeEach, afterEach } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { existsSync, statSync, rmSync, mkdtempSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { SqliteTurnStore } from "./sqlite-store.js";
18
+ import { runComplianceSuite } from "./contract-compliance.test.js";
19
+ import { closeAllTurnDbs } from "./connection.js";
20
+ // ── Compliance suite (in-memory, fast) ──────────────────────────
21
+ runComplianceSuite("SqliteTurnStore", (options) => new SqliteTurnStore(options), { stateDir: join(tmpdir(), "turns-compliance-sqlite"), inMemory: true });
22
+ // ── SQLite-specific file-backed tests ───────────────────────────
23
+ describe("SqliteTurnStore — file-backed", () => {
24
+ let stateDir;
25
+ let store;
26
+ beforeEach(() => {
27
+ closeAllTurnDbs(); // clear any cached connections
28
+ stateDir = mkdtempSync(join(tmpdir(), "turns-file-"));
29
+ store = new SqliteTurnStore({ stateDir });
30
+ });
31
+ afterEach(() => {
32
+ try {
33
+ store.close();
34
+ }
35
+ catch {
36
+ // best-effort
37
+ }
38
+ closeAllTurnDbs();
39
+ try {
40
+ rmSync(stateDir, { recursive: true, force: true });
41
+ }
42
+ catch {
43
+ // best-effort
44
+ }
45
+ });
46
+ it("creates turns.db separate from sqlite.db", () => {
47
+ const turnsDbPath = join(stateDir, "turns.db");
48
+ assert.ok(existsSync(turnsDbPath), "turns.db should exist");
49
+ // sqlite.db should NOT be created by the turns store
50
+ const sqliteDbPath = join(stateDir, "sqlite.db");
51
+ assert.ok(!existsSync(sqliteDbPath), "sqlite.db should NOT be created");
52
+ });
53
+ it("uses WAL journal mode", () => {
54
+ const row = store
55
+ .db.prepare("PRAGMA journal_mode")
56
+ .get();
57
+ assert.equal(row.journal_mode, "wal");
58
+ });
59
+ it("caches connections for the same path", () => {
60
+ // Creating a second store with the same stateDir should reuse
61
+ // the cached connection (not create a new file lock conflict)
62
+ const store2 = new SqliteTurnStore({ stateDir });
63
+ try {
64
+ // Both should be able to write without locking errors
65
+ const entry = {
66
+ conversationId: "conv_cache",
67
+ sessionId: "sess_cache",
68
+ turnIndex: 0,
69
+ role: "assistant",
70
+ endedAt: Date.now(),
71
+ };
72
+ store.asWriter().appendTurn(entry);
73
+ store2.asWriter().appendTurn({
74
+ ...entry,
75
+ turnIndex: 1,
76
+ });
77
+ assert.equal(store.asReader().countTurns("conv_cache"), 2);
78
+ }
79
+ finally {
80
+ store2.close();
81
+ }
82
+ });
83
+ it("reports dbSizeBytes for file-backed store", () => {
84
+ const entry = {
85
+ conversationId: "conv_size",
86
+ sessionId: "sess_size",
87
+ turnIndex: 0,
88
+ role: "assistant",
89
+ endedAt: Date.now(),
90
+ };
91
+ store.asWriter().appendTurn(entry);
92
+ // prune returns a PruneReport with freedBytes; we can also
93
+ // verify the file exists and has content
94
+ const turnsDbPath = join(stateDir, "turns.db");
95
+ const size = statSync(turnsDbPath).size;
96
+ assert.ok(size > 0, "turns.db should have non-zero size after writes");
97
+ });
98
+ it("persists data across store instances", () => {
99
+ const entry = {
100
+ conversationId: "conv_persist",
101
+ sessionId: "sess_persist",
102
+ turnIndex: 0,
103
+ role: "assistant",
104
+ endedAt: Date.now(),
105
+ model: "test-model",
106
+ };
107
+ store.asWriter().appendTurn(entry);
108
+ store.close();
109
+ closeAllTurnDbs();
110
+ // Re-open the same stateDir
111
+ const store2 = new SqliteTurnStore({ stateDir });
112
+ try {
113
+ const turns = store2.asReader().query({ conversationId: "conv_persist" });
114
+ assert.equal(turns.length, 1);
115
+ assert.equal(turns[0].model, "test-model");
116
+ }
117
+ finally {
118
+ store2.close();
119
+ closeAllTurnDbs();
120
+ }
121
+ });
122
+ it("supports custom dbPath", () => {
123
+ const customPath = join(stateDir, "custom-turns.db");
124
+ const customStore = new SqliteTurnStore({
125
+ stateDir,
126
+ dbPath: customPath,
127
+ });
128
+ try {
129
+ assert.ok(existsSync(customPath), "custom db path should be created");
130
+ customStore.asWriter().appendTurn({
131
+ conversationId: "conv_custom",
132
+ sessionId: "sess_custom",
133
+ turnIndex: 0,
134
+ role: "user",
135
+ endedAt: Date.now(),
136
+ });
137
+ assert.equal(customStore.asReader().countTurns("conv_custom"), 1);
138
+ }
139
+ finally {
140
+ customStore.close();
141
+ closeAllTurnDbs();
142
+ }
143
+ });
144
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * types.ts — S49 contract module (the source of truth).
3
+ *
4
+ * Every implementation must satisfy these interfaces. Hosts import only this
5
+ * file + the factory from index.ts. SQL schemas are private to implementations.
6
+ *
7
+ * Design principles (from docs/specs/s49-rev1-architecture-upgrade.md):
8
+ * 1. Contract-first — the interface IS the spec.
9
+ * 2. Append-only — TurnWriter.append* are the only write methods; no UPDATE.
10
+ * 3. Capability-gated — asReader/asWriter/asAdmin return subset views.
11
+ * 4. Ledger protocol — host PUSHES facts, PULLS views. Store never initiates.
12
+ * 5. StoreSnapshot — checkpoint/restore for backup, migration, test seeding.
13
+ *
14
+ * PREVENT-PI-004: zero network. PREVENT-002: no SQL here (private to impls).
15
+ */
16
+ export {};
@@ -0,0 +1,141 @@
1
+ /**
2
+ * cluster.ts — S51A k-means topic clustering over real stored embeddings.
3
+ *
4
+ * Host-agnostic (no pi imports), pure local math (PREVENT-PI-004): clusters the
5
+ * real `context_chunks.embedding_blob` vectors with k-means++ (reused from
6
+ * src/dedup/raptor/kmeans.ts), picks k by a real criterion (elbow on WCSS, or
7
+ * silhouette when the corpus is large enough), and labels each cluster with the
8
+ * top TF-IDF terms of its real member text. Pure math only — no model calls,
9
+ * no network, no remote service (PREVENT-PI-004).
10
+ *
11
+ * The topic view is derived + read-only over `context_chunks` — building a model
12
+ * never mutates any memory (PREVENT-PI-001/002). Parameterized SQL (PREVENT-002).
13
+ */
14
+ import { cosineSimilarity } from "../embedder.js";
15
+ import { decodeEmbedding } from "../store/sqlite/utils.js";
16
+ import { tfidfScores, labelFromScores, membershipConfidence, } from "./labels.js";
17
+ import { selectK } from "./kselection.js";
18
+ export const DEFAULT_CLUSTER_CONFIG = {
19
+ kRange: [3, 15],
20
+ labelTopTerms: 5,
21
+ restarts: 5,
22
+ seed: 0x9e3779b9,
23
+ };
24
+ /**
25
+ * Load chunks that have a usable embedding. Text is the best-available surface
26
+ * (normalized_text → summary → topic_summary). Skips rows with no embedding or
27
+ * no text. Returns in stable (session_id, id) order for determinism.
28
+ */
29
+ export function loadEmbeddings(mainDb) {
30
+ const rows = mainDb
31
+ .prepare(`SELECT id, session_id, embedding_blob,
32
+ COALESCE(normalized_text, summary, topic_summary) AS text
33
+ FROM context_chunks
34
+ WHERE embedding_blob IS NOT NULL
35
+ ORDER BY session_id ASC, id ASC`)
36
+ .all();
37
+ const out = [];
38
+ for (const r of rows) {
39
+ const vec = decodeEmbedding(r.embedding_blob);
40
+ if (vec.length === 0)
41
+ continue;
42
+ const text = (r.text ?? "").trim();
43
+ if (text.length === 0)
44
+ continue;
45
+ out.push({ chunkId: r.id, sessionId: r.session_id, vec, text });
46
+ }
47
+ return out;
48
+ }
49
+ /**
50
+ * Build a topic model from the real stored chunks. Picks k by silhouette when
51
+ * computable, else elbow; degenerate/small corpus → single `general` cluster.
52
+ * Never throws on degenerate input — returns the safe single-cluster model.
53
+ */
54
+ export function buildTopicModel(mainDb, cfg = DEFAULT_CLUSTER_CONFIG) {
55
+ const builtAt = Date.now();
56
+ const chunks = loadEmbeddings(mainDb);
57
+ const totalChunks = chunks.length;
58
+ // Too small to cluster meaningfully → single general cluster.
59
+ if (totalChunks < Math.max(2, cfg.kRange[0])) {
60
+ return singleClusterModel(chunks, builtAt, cfg);
61
+ }
62
+ const points = chunks.map((c) => c.vec);
63
+ const [minK, maxK] = cfg.kRange;
64
+ const hi = Math.min(maxK, Math.max(minK, Math.floor(totalChunks / 2)));
65
+ const ks = [];
66
+ for (let k = minK; k <= hi; k++)
67
+ ks.push(k);
68
+ if (ks.length === 0)
69
+ ks.push(minK);
70
+ const { candidate: chosen, criterion } = selectK(points, ks, cfg);
71
+ const meanSil = chosen.silhouette;
72
+ return assembleModel(chunks, chosen, criterion, meanSil, builtAt, cfg);
73
+ }
74
+ /** Single `general` cluster holding every chunk (degenerate/small corpus). */
75
+ function singleClusterModel(chunks, builtAt, cfg) {
76
+ const topic = {
77
+ id: "topic_0",
78
+ label: labelFromScores(tfidfScores(chunks, chunks), cfg.labelTopTerms),
79
+ termScores: tfidfScores(chunks, chunks),
80
+ memoryCount: chunks.length,
81
+ lastUpdated: builtAt,
82
+ };
83
+ const assignments = chunks.map((c) => ({
84
+ memoryId: c.chunkId,
85
+ sessionId: c.sessionId,
86
+ topicId: "topic_0",
87
+ confidence: 1,
88
+ assignedAt: builtAt,
89
+ method: "kmeans+tfidf",
90
+ }));
91
+ return {
92
+ topics: chunks.length > 0 ? [topic] : [],
93
+ assignments,
94
+ k: chunks.length > 0 ? 1 : 0,
95
+ criterion: "elbow",
96
+ silhouetteScore: null,
97
+ totalChunks: chunks.length,
98
+ builtAt,
99
+ };
100
+ }
101
+ /** Assemble topics + assignments from a chosen clustering. */
102
+ function assembleModel(chunks, chosen, criterion, meanSil, builtAt, cfg) {
103
+ const topics = [];
104
+ const assignments = [];
105
+ for (let c = 0; c < chosen.k; c++) {
106
+ const members = chunks.filter((_, i) => chosen.assignments[i] === c);
107
+ if (members.length === 0)
108
+ continue;
109
+ const id = `topic_${topics.length}`;
110
+ const scores = tfidfScores(members, chunks);
111
+ topics.push({
112
+ id,
113
+ label: labelFromScores(scores, cfg.labelTopTerms),
114
+ termScores: scores,
115
+ memoryCount: members.length,
116
+ lastUpdated: builtAt,
117
+ });
118
+ }
119
+ for (let i = 0; i < chunks.length; i++) {
120
+ const cluster = chosen.assignments[i];
121
+ const centroid = chosen.centroids[cluster];
122
+ const sim = centroid ? cosineSimilarity(chunks[i].vec, centroid) : 0;
123
+ assignments.push({
124
+ memoryId: chunks[i].chunkId,
125
+ sessionId: chunks[i].sessionId,
126
+ topicId: `topic_${cluster}`,
127
+ confidence: membershipConfidence(sim),
128
+ assignedAt: builtAt,
129
+ method: "kmeans+tfidf",
130
+ });
131
+ }
132
+ return {
133
+ topics,
134
+ assignments,
135
+ k: topics.length,
136
+ criterion,
137
+ silhouetteScore: meanSil,
138
+ totalChunks: chunks.length,
139
+ builtAt,
140
+ };
141
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * cluster.test.ts — S51A clustering + labeling tests. No network, temp dirs.
3
+ * Includes the honest-boundary grep-asserts (no ollama/llm/fetch, no fabricated
4
+ * keyword literals) required by the s47/s51 acceptance criteria.
5
+ */
6
+ import { test, beforeEach, afterEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { mkdtempSync, rmSync, readFileSync, readdirSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { openStore } from "../store/sqlite/utils.js";
13
+ import { upsertCheckpoint } from "../store/sqlite/checkpoints.js";
14
+ import { loadEmbeddings, buildTopicModel, DEFAULT_CLUSTER_CONFIG } from "./cluster.js";
15
+ import { tfidfScores, membershipConfidence } from "./labels.js";
16
+ let tmpDir;
17
+ let counter = 0;
18
+ beforeEach(() => {
19
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-topics-"));
20
+ });
21
+ afterEach(() => {
22
+ rmSync(tmpDir, { recursive: true, force: true });
23
+ });
24
+ function stateDir() {
25
+ return join(tmpDir, `run-${counter++}`);
26
+ }
27
+ /** Unit vector along axis `axis` (dim = total dims). */
28
+ function axisVec(axis, dim) {
29
+ const v = new Array(dim).fill(0);
30
+ v[axis] = 1;
31
+ return v;
32
+ }
33
+ function cp(id, text, embedding, session = "sess_t") {
34
+ return {
35
+ checkpointId: id,
36
+ sessionId: session,
37
+ summary: text,
38
+ normalizedText: text,
39
+ keyDecisions: [],
40
+ nextSteps: [],
41
+ filesModified: [],
42
+ tokenEstimate: 0,
43
+ regionHash: "r",
44
+ embedding,
45
+ timestamp: 1,
46
+ };
47
+ }
48
+ /** Seed N clusters of M chunks each: cluster i sits near axis i with distinct terms. */
49
+ function seedClusters(dir, clusters, perCluster, dim) {
50
+ openStore(dir);
51
+ const terms = ["sqlite", "wal", "checkpoint", "recovery", "backup", "replay", "fork", "merge"];
52
+ for (let c = 0; c < clusters; c++) {
53
+ for (let m = 0; m < perCluster; m++) {
54
+ // Near-axis vector with slight per-member jitter (stays closest to its axis).
55
+ const v = axisVec(c, dim).map((x, i) => x + (i === c ? 0 : (m + 1) * 1e-4 * (i + 1)));
56
+ const term = terms[(c * 2) % terms.length];
57
+ const term2 = terms[(c * 2 + 1) % terms.length];
58
+ upsertCheckpoint(cp(`chkpt_c${c}_${m}`, `${term} ${term2} work item ${m} details`, v), dir);
59
+ }
60
+ }
61
+ }
62
+ test("loadEmbeddings returns only chunks with embeddings + text", () => {
63
+ const dir = stateDir();
64
+ openStore(dir);
65
+ upsertCheckpoint(cp("a", "hello world", axisVec(0, 4)), dir);
66
+ upsertCheckpoint(cp("b", "", axisVec(1, 4)), dir); // empty text → skipped
67
+ upsertCheckpoint(cp("c", "no embedding here", [], "sess_t"), dir); // empty embedding → skipped
68
+ const chunks = loadEmbeddings(openStore(dir));
69
+ assert.equal(chunks.length, 1);
70
+ assert.equal(chunks[0].chunkId, "a");
71
+ assert.equal(chunks[0].text, "hello world");
72
+ });
73
+ test("buildTopicModel finds 3 well-separated clusters (silhouette picks k=3)", () => {
74
+ const dir = stateDir();
75
+ seedClusters(dir, 3, 6, 8); // 18 chunks, 3 tight clusters
76
+ const model = buildTopicModel(openStore(dir), { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
77
+ assert.equal(model.totalChunks, 18);
78
+ assert.equal(model.k, 3);
79
+ assert.equal(model.topics.length, 3);
80
+ // Every chunk assigned; assignment count == chunk count.
81
+ assert.equal(model.assignments.length, 18);
82
+ // Labels reflect the seeded discriminative terms (sqlite/wal, checkpoint/recovery, backup/replay).
83
+ const labels = model.topics.map((t) => t.label).join(" | ");
84
+ assert.ok(/sqlite|checkpoint|backup/.test(labels), `labels should carry seeded terms: ${labels}`);
85
+ });
86
+ test("corpus too small → single general cluster, no crash", () => {
87
+ const dir = stateDir();
88
+ openStore(dir);
89
+ upsertCheckpoint(cp("only", "lone chunk about sqlite", axisVec(0, 4)), dir);
90
+ const model = buildTopicModel(openStore(dir), DEFAULT_CLUSTER_CONFIG); // kRange[0]=3 > 1 chunk
91
+ assert.equal(model.k, 1);
92
+ assert.equal(model.topics.length, 1);
93
+ assert.equal(model.topics[0].memoryCount, 1);
94
+ assert.equal(model.silhouetteScore, null);
95
+ });
96
+ test("empty corpus → zero topics, no crash", () => {
97
+ const dir = stateDir();
98
+ openStore(dir);
99
+ const model = buildTopicModel(openStore(dir), DEFAULT_CLUSTER_CONFIG);
100
+ assert.equal(model.k, 0);
101
+ assert.equal(model.topics.length, 0);
102
+ assert.equal(model.assignments.length, 0);
103
+ });
104
+ test("tfidfScores surfaces discriminative terms, down-weights common ones", () => {
105
+ const mk = (text) => ({
106
+ chunkId: text, sessionId: "s", vec: [1], text,
107
+ });
108
+ const corpus = [
109
+ mk("sqlite wal sqlite pragma"),
110
+ mk("sqlite wal journal"),
111
+ mk("react component render"),
112
+ mk("react hooks state"),
113
+ ];
114
+ const members = corpus.slice(0, 2); // the sqlite cluster
115
+ const scores = tfidfScores(members, corpus);
116
+ const top = scores[0]?.term;
117
+ assert.ok(top === "sqlite" || top === "wal", `top term should be discriminative, got ${top}`);
118
+ // 'react' never appears in members → not scored for this cluster.
119
+ assert.ok(!scores.some((s) => s.term === "react"));
120
+ });
121
+ test("membershipConfidence maps cosine [-1,1] → [0,1]", () => {
122
+ assert.equal(membershipConfidence(1), 1);
123
+ assert.equal(membershipConfidence(-1), 0);
124
+ assert.equal(membershipConfidence(0), 0.5);
125
+ });
126
+ test("buildTopicModel is deterministic for a fixed seed", () => {
127
+ const dir = stateDir();
128
+ seedClusters(dir, 3, 5, 8);
129
+ const db = openStore(dir);
130
+ const a = buildTopicModel(db, { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
131
+ const b = buildTopicModel(db, { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
132
+ assert.equal(a.k, b.k);
133
+ assert.deepEqual(a.topics.map((t) => t.label).sort(), b.topics.map((t) => t.label).sort());
134
+ });
135
+ test("honest boundary: no LLM/Ollama/network/keyword-literals in src/topics", () => {
136
+ const here = dirname(fileURLToPath(import.meta.url));
137
+ const srcTopics = join(here, "..", "..", "..", "src", "topics"); // dist/src/topics → src/topics
138
+ const files = readdirSync(srcTopics).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
139
+ const banned = [/ollama/i, /\bllm\b/i, /\bfetch\s*\(/, /\bhybrid\b/i];
140
+ const fabricated = ["graphql", "docker", "stacktrace", "bottleneck", "trade-off"];
141
+ for (const f of files) {
142
+ const text = readFileSync(join(srcTopics, f), "utf8");
143
+ for (const re of banned) {
144
+ assert.ok(!re.test(text), `${f} must not contain ${re} (PREVENT-PI-004 / no-LLM boundary)`);
145
+ }
146
+ for (const word of fabricated) {
147
+ assert.ok(!text.toLowerCase().includes(`"${word}"`), `${f} must not hardcode fabricated taxonomy term "${word}"`);
148
+ }
149
+ }
150
+ });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * index.ts — S51 topics barrel. Re-export only; no logic.
3
+ */
4
+ export { loadEmbeddings, buildTopicModel, DEFAULT_CLUSTER_CONFIG, } from "./cluster.js";
5
+ export { createTopicStore, getWikiCompactCounter, bumpWikiCompactCounter } from "./store.js";
6
+ export { tokenize, tfidfScores, labelFromScores, membershipConfidence } from "./labels.js";
@@ -0,0 +1,142 @@
1
+ /**
2
+ * kselection.ts — S51A k-selection criterion math for topic clustering.
3
+ *
4
+ * Pure local math (PREVENT-PI-004): within-cluster sum of squares (cosine
5
+ * distance), mean silhouette score, and the elbow heuristic. k-means++ is
6
+ * reused from src/dedup/raptor/kmeans.ts. No model calls, no network.
7
+ *
8
+ * Extracted from cluster.ts so each module stays under the project's 300-line
9
+ * file-size cap with a single responsibility: this module owns the
10
+ * "which k is best" criterion; cluster.ts owns load + orchestration.
11
+ *
12
+ * Host-agnostic (no pi imports). Parameterized only by Vector arrays.
13
+ */
14
+ import { cosineDistance, kmeanspp, meanVector, } from "../dedup/raptor/kmeans.js";
15
+ /** Within-cluster sum of squares (cosine distance) for one clustering. */
16
+ export function wcss(points, assignments, centroids) {
17
+ let s = 0;
18
+ for (let i = 0; i < points.length; i++) {
19
+ const d = cosineDistance(points[i], centroids[assignments[i]]);
20
+ s += d * d;
21
+ }
22
+ return s;
23
+ }
24
+ /**
25
+ * Mean silhouette score across all points (−1..1); null when undefined
26
+ * (k<=1 or k>=n, or every cluster is a singleton).
27
+ */
28
+ export function silhouette(points, assignments, k) {
29
+ const n = points.length;
30
+ if (k <= 1 || k >= n)
31
+ return null;
32
+ // Precompute cluster sizes once.
33
+ const sizes = new Map();
34
+ for (const a of assignments)
35
+ sizes.set(a, (sizes.get(a) ?? 0) + 1);
36
+ let sum = 0;
37
+ let counted = 0;
38
+ for (let i = 0; i < n; i++) {
39
+ const a = assignments[i];
40
+ const ownSize = sizes.get(a) ?? 0;
41
+ if (ownSize <= 1)
42
+ continue; // singleton cluster → contribution 0 (skip)
43
+ let aSum = 0;
44
+ const bSum = new Map();
45
+ for (let j = 0; j < n; j++) {
46
+ if (i === j)
47
+ continue;
48
+ const d = cosineDistance(points[i], points[j]);
49
+ if (assignments[j] === a) {
50
+ aSum += d;
51
+ }
52
+ else {
53
+ bSum.set(assignments[j], (bSum.get(assignments[j]) ?? 0) + d);
54
+ }
55
+ }
56
+ const aMean = aSum / (ownSize - 1); // exclude self
57
+ let b = Infinity;
58
+ for (const [cluster, total] of bSum) {
59
+ const cnt = sizes.get(cluster) ?? 0;
60
+ if (cnt > 0)
61
+ b = Math.min(b, total / cnt);
62
+ }
63
+ if (!Number.isFinite(b))
64
+ continue;
65
+ const denom = Math.max(aMean, b);
66
+ if (denom > 0) {
67
+ sum += (b - aMean) / denom;
68
+ counted++;
69
+ }
70
+ }
71
+ return counted > 0 ? sum / counted : null;
72
+ }
73
+ /** Elbow: index of max curvature on the WCSS-vs-k curve. */
74
+ export function elbowIndex(ks, wcssVals) {
75
+ if (ks.length <= 2)
76
+ return 0;
77
+ // Normalize and find the point farthest from the line joining the endpoints.
78
+ const x0 = ks[0];
79
+ const y0 = wcssVals[0];
80
+ const x1 = ks[ks.length - 1];
81
+ const y1 = wcssVals[wcssVals.length - 1];
82
+ const dx = x1 - x0;
83
+ const dy = y1 - y0;
84
+ const len = Math.hypot(dx, dy) || 1;
85
+ let best = 0;
86
+ let bestDist = -1;
87
+ for (let i = 0; i < ks.length; i++) {
88
+ const dist = Math.abs(dy * ks[i] - dx * wcssVals[i] + x1 * y0 - y1 * x0) / len;
89
+ if (dist > bestDist) {
90
+ bestDist = dist;
91
+ best = i;
92
+ }
93
+ }
94
+ return best;
95
+ }
96
+ /** Run k-means with restarts for one k; keep the lowest-WCSS result. */
97
+ export function bestForK(points, k, cfg) {
98
+ let best = null;
99
+ for (let r = 0; r < cfg.restarts; r++) {
100
+ const res = kmeanspp(points, k, { seed: cfg.seed + r });
101
+ if (res.k === 0)
102
+ continue;
103
+ const w = wcss(points, res.assignments, res.centroids);
104
+ if (!best || w < best.wcss) {
105
+ best = {
106
+ k: res.k,
107
+ assignments: res.assignments,
108
+ centroids: res.centroids,
109
+ wcss: w,
110
+ silhouette: null,
111
+ };
112
+ }
113
+ }
114
+ // Degenerate: kmeans collapsed everything to < k clusters.
115
+ if (!best) {
116
+ const mean = meanVector(points);
117
+ best = {
118
+ k: 1,
119
+ assignments: points.map(() => 0),
120
+ centroids: [mean],
121
+ wcss: 0,
122
+ silhouette: null,
123
+ };
124
+ }
125
+ best.silhouette = silhouette(points, best.assignments, best.k);
126
+ return best;
127
+ }
128
+ /**
129
+ * Run k-means across the k search space and pick the best candidate:
130
+ * prefer silhouette when any candidate produced a computable score, else
131
+ * the elbow on the WCSS curve. `points` must be non-empty and `ks` non-empty.
132
+ */
133
+ export function selectK(points, ks, cfg) {
134
+ const candidates = ks.map((k) => bestForK(points, k, cfg));
135
+ const withSil = candidates.filter((c) => c.silhouette !== null);
136
+ if (withSil.length > 0) {
137
+ const chosen = withSil.reduce((a, b) => (b.silhouette ?? -1) > (a.silhouette ?? -1) ? b : a);
138
+ return { candidate: chosen, criterion: "silhouette" };
139
+ }
140
+ const idx = elbowIndex(candidates.map((c) => c.k), candidates.map((c) => c.wcss));
141
+ return { candidate: candidates[idx], criterion: "elbow" };
142
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * labels.ts — S51A TF-IDF cluster labeling + membership confidence.
3
+ *
4
+ * Pure local math over real stored text (PREVENT-PI-004). NO fabricated keyword
5
+ * taxonomy, NO model: a cluster's label is the top TF-IDF terms of its real member
6
+ * chunks (real TF × real IDF over the whole corpus), so common words are
7
+ * naturally down-weighted by IDF rather than by a hand-written stopword list.
8
+ */
9
+ /** Tokenize text: lowercase, split on non-alphanumeric, drop empties/1-char. */
10
+ export function tokenize(text) {
11
+ return text
12
+ .toLowerCase()
13
+ .split(/[^a-z0-9]+/)
14
+ .filter((t) => t.length > 1);
15
+ }
16
+ /**
17
+ * Compute TF-IDF term scores for one cluster's member chunks against the whole
18
+ * corpus. TF = term count across the cluster's members; IDF = log(1 + N/df)
19
+ * where N = total chunks and df = chunks containing the term. Returns the full
20
+ * list sorted by score desc.
21
+ */
22
+ export function tfidfScores(memberChunks, corpus) {
23
+ const tf = new Map();
24
+ for (const c of memberChunks) {
25
+ for (const term of tokenize(c.text))
26
+ tf.set(term, (tf.get(term) ?? 0) + 1);
27
+ }
28
+ if (tf.size === 0)
29
+ return [];
30
+ // Document frequency over the whole corpus (one vote per chunk per term).
31
+ const df = new Map();
32
+ for (const c of corpus) {
33
+ for (const term of new Set(tokenize(c.text)))
34
+ df.set(term, (df.get(term) ?? 0) + 1);
35
+ }
36
+ const n = Math.max(1, corpus.length);
37
+ const out = [];
38
+ for (const [term, f] of tf) {
39
+ const idf = Math.log(1 + n / (df.get(term) ?? 1));
40
+ out.push({ term, score: f * idf });
41
+ }
42
+ out.sort((a, b) => b.score - a.score || a.term.localeCompare(b.term));
43
+ return out;
44
+ }
45
+ /** Join the top `topTerms` TF-IDF terms into a human label. */
46
+ export function labelFromScores(scores, topTerms) {
47
+ const top = scores.slice(0, Math.max(1, topTerms)).map((s) => s.term);
48
+ return top.length > 0 ? top.join(" · ") : "general";
49
+ }
50
+ /**
51
+ * Normalized cosine membership confidence in [0,1]. Cosine is in [-1,1]; map to
52
+ * [0,1] via (1 + cos)/2. A chunk sitting exactly on its centroid → 1.
53
+ */
54
+ export function membershipConfidence(cosineSimilarity) {
55
+ return Math.max(0, Math.min(1, (1 + cosineSimilarity) / 2));
56
+ }