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,181 @@
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
+
17
+ // ─── Domain types ───────────────────────────────────────────────────
18
+
19
+ /** Unique turn identifier (string to stay backend-agnostic). */
20
+ export type TurnId = string;
21
+
22
+ /** Unique conversation identifier. */
23
+ export type ConversationId = string;
24
+
25
+ /** Unique session identifier. */
26
+ export type SessionId = string;
27
+
28
+ /** A single turn record — an immutable, append-only fact. */
29
+ export interface TurnEntry {
30
+ conversationId: ConversationId;
31
+ sessionId: SessionId;
32
+ turnIndex: number;
33
+ role: "user" | "assistant" | "system" | "tool";
34
+ endedAt: number; // epoch ms
35
+ ctxTokens?: number; // context window tokens at end of turn
36
+ ctxPercent?: number; // context window utilization 0-1
37
+ pressureBand?: "green" | "yellow" | "red";
38
+ model?: string; // model used for this turn
39
+ /** S50B: the compact epoch that superseded this turn (stamped post-hoc). */
40
+ epochId?: string;
41
+ }
42
+
43
+ /** A recall hit recorded during a turn — an immutable, append-only fact. */
44
+ export interface TurnRecallEntry {
45
+ turnId: TurnId;
46
+ checkpointId: string;
47
+ score: number;
48
+ source: "checkpoint" | "cluster_summary" | "memory";
49
+ raptorLevel?: number;
50
+ }
51
+
52
+ /** A conversation fork — an immutable, append-only fact. */
53
+ export interface ConversationFork {
54
+ parentConversationId: ConversationId;
55
+ childConversationId: ConversationId;
56
+ forkTurnIndex: number; // turn in the parent where the fork happened
57
+ createdAt: number;
58
+ }
59
+
60
+ /** Filters for querying turns. All fields optional (AND-combined). */
61
+ export interface TurnFilter {
62
+ conversationId?: ConversationId;
63
+ sessionId?: SessionId;
64
+ sinceMs?: number; // epoch ms lower bound
65
+ untilMs?: number; // epoch ms upper bound
66
+ pressureBand?: string;
67
+ limit?: number;
68
+ offset?: number;
69
+ }
70
+
71
+ /** What a prune operation removed. */
72
+ export interface PruneReport {
73
+ turnsRemoved: number;
74
+ recallRemoved: number;
75
+ branchesPreserved: number;
76
+ freedBytes: number; // approximate, from file-size delta
77
+ }
78
+
79
+ /** Retention policy — what the admin capability allows. */
80
+ export interface RetentionPolicy {
81
+ maxTurnAgeMs: number; // delete turns older than this
82
+ keepMinPerConversation: number; // always keep at least N turns per conversation
83
+ vacuumAfterPrune: boolean; // run VACUUM after pruning
84
+ }
85
+
86
+ /** A complete snapshot for backup/migration/test-seeding. */
87
+ export interface StoreSnapshot {
88
+ version: 1;
89
+ exportedAt: number; // epoch ms
90
+ turns: TurnEntry[];
91
+ recall: TurnRecallEntry[];
92
+ forks: ConversationFork[];
93
+ }
94
+
95
+ /** Aggregate stats for a conversation (materialized view, derived on read). */
96
+ export interface ConversationStats {
97
+ turnCount: number;
98
+ firstTurnAt: number;
99
+ lastTurnAt: number;
100
+ avgCtxPercent: number;
101
+ pressureBands: Record<string, number>;
102
+ }
103
+
104
+ // ─── Capability interfaces ──────────────────────────────────────────
105
+
106
+ /** Read-only view — dashboards, TUI, analytics. Cannot write. */
107
+ export interface TurnReader {
108
+ query(filter: TurnFilter): TurnEntry[];
109
+ getTurn(turnId: TurnId): TurnEntry | undefined;
110
+ /** Resolve a turn by its (conversationId, turnIndex) coordinate (fork + recall replay). */
111
+ getTurnByIndex(
112
+ conversationId: ConversationId,
113
+ turnIndex: number,
114
+ ): TurnEntry | undefined;
115
+ listRecall(turnId: TurnId): TurnRecallEntry[];
116
+ /** Recall hits recorded for the turn at (conversationId, turnIndex) — the
117
+ * replay set a fork rehydrates. Avoids needing the opaque TurnId. */
118
+ listRecallByIndex(
119
+ conversationId: ConversationId,
120
+ turnIndex: number,
121
+ ): TurnRecallEntry[];
122
+ listForks(conversationId: ConversationId): ConversationFork[];
123
+ countTurns(conversationId: ConversationId): number;
124
+ conversationStats(conversationId: ConversationId): ConversationStats;
125
+ }
126
+
127
+ /** Append-only writer — compaction engine, event handlers. Cannot prune. */
128
+ export interface TurnWriter {
129
+ appendTurn(entry: TurnEntry): TurnId;
130
+ appendRecall(entry: TurnRecallEntry): void;
131
+ ensureConversationId(sessionId: SessionId): ConversationId;
132
+ forkConversation(
133
+ parentId: ConversationId,
134
+ forkTurnIndex: number,
135
+ ): ConversationId;
136
+ }
137
+
138
+ /** Admin operations — prune command, DR, migration. */
139
+ export interface TurnAdmin {
140
+ prune(policy: RetentionPolicy): PruneReport;
141
+ vacuum(): void;
142
+ checkpoint(): StoreSnapshot;
143
+ restore(from: StoreSnapshot): void;
144
+ clear(): void; // test-only; wipes all data
145
+ /** S50B: stamp `epoch_id` on this session's turns that have none yet (links
146
+ * a turn to the compact epoch that superseded it). Backfill UPDATE, so it
147
+ * lives on the admin capability (the writer is append-only). Returns the
148
+ * number of turns stamped. */
149
+ stampTurnsEpoch(sessionId: SessionId, epochId: string): number;
150
+ }
151
+
152
+ /** The composed store — hosts get a capability-gated view. */
153
+ export interface TurnStore extends TurnReader, TurnWriter, TurnAdmin {
154
+ /** Return a read-only view (for dashboards, TUI, analytics). */
155
+ asReader(): TurnReader;
156
+ /** Return an append-only view (for event handlers, compaction). */
157
+ asWriter(): TurnWriter;
158
+ /** Return an admin view (for prune, DR, migration). */
159
+ asAdmin(): TurnAdmin;
160
+ /** Close the underlying connection. For tests + graceful shutdown. */
161
+ close(): void;
162
+ }
163
+
164
+ // ─── Factory ───────────────────────────────────────────────────────
165
+
166
+ /** Options for creating a TurnStore. */
167
+ export interface TurnStoreOptions {
168
+ stateDir: string;
169
+ /** Override DB path (for tests / DR). Default: join(stateDir, "turns.db") */
170
+ dbPath?: string;
171
+ /** In-memory mode (for tests). Default: false. */
172
+ inMemory?: boolean;
173
+ }
174
+
175
+ /**
176
+ * Factory: create a TurnStore from a state directory.
177
+ *
178
+ * By default returns a SqliteTurnStore backed by turns.db.
179
+ * When options.inMemory is true, returns an InMemoryTurnStore.
180
+ */
181
+ export type TurnStoreFactory = (options: TurnStoreOptions) => TurnStore;
@@ -0,0 +1,176 @@
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 type { StoredCheckpoint } from "../store.js";
15
+ import { loadEmbeddings, buildTopicModel, DEFAULT_CLUSTER_CONFIG } from "./cluster.js";
16
+ import { tfidfScores, membershipConfidence } from "./labels.js";
17
+ import type { EmbeddedChunk } from "./types.js";
18
+
19
+ let tmpDir: string;
20
+ let counter = 0;
21
+
22
+ beforeEach(() => {
23
+ tmpDir = mkdtempSync(join(tmpdir(), "mc-topics-"));
24
+ });
25
+
26
+ afterEach(() => {
27
+ rmSync(tmpDir, { recursive: true, force: true });
28
+ });
29
+
30
+ function stateDir(): string {
31
+ return join(tmpDir, `run-${counter++}`);
32
+ }
33
+
34
+ /** Unit vector along axis `axis` (dim = total dims). */
35
+ function axisVec(axis: number, dim: number): number[] {
36
+ const v = new Array<number>(dim).fill(0);
37
+ v[axis] = 1;
38
+ return v;
39
+ }
40
+
41
+ function cp(id: string, text: string, embedding: number[], session = "sess_t"): StoredCheckpoint {
42
+ return {
43
+ checkpointId: id,
44
+ sessionId: session,
45
+ summary: text,
46
+ normalizedText: text,
47
+ keyDecisions: [],
48
+ nextSteps: [],
49
+ filesModified: [],
50
+ tokenEstimate: 0,
51
+ regionHash: "r",
52
+ embedding,
53
+ timestamp: 1,
54
+ };
55
+ }
56
+
57
+ /** Seed N clusters of M chunks each: cluster i sits near axis i with distinct terms. */
58
+ function seedClusters(dir: string, clusters: number, perCluster: number, dim: number): void {
59
+ openStore(dir);
60
+ const terms = ["sqlite", "wal", "checkpoint", "recovery", "backup", "replay", "fork", "merge"];
61
+ for (let c = 0; c < clusters; c++) {
62
+ for (let m = 0; m < perCluster; m++) {
63
+ // Near-axis vector with slight per-member jitter (stays closest to its axis).
64
+ const v = axisVec(c, dim).map((x, i) => x + (i === c ? 0 : (m + 1) * 1e-4 * (i + 1)));
65
+ const term = terms[(c * 2) % terms.length];
66
+ const term2 = terms[(c * 2 + 1) % terms.length];
67
+ upsertCheckpoint(
68
+ cp(`chkpt_c${c}_${m}`, `${term} ${term2} work item ${m} details`, v),
69
+ dir,
70
+ );
71
+ }
72
+ }
73
+ }
74
+
75
+ test("loadEmbeddings returns only chunks with embeddings + text", () => {
76
+ const dir = stateDir();
77
+ openStore(dir);
78
+ upsertCheckpoint(cp("a", "hello world", axisVec(0, 4)), dir);
79
+ upsertCheckpoint(cp("b", "", axisVec(1, 4)), dir); // empty text → skipped
80
+ upsertCheckpoint(cp("c", "no embedding here", [], "sess_t"), dir); // empty embedding → skipped
81
+ const chunks = loadEmbeddings(openStore(dir));
82
+ assert.equal(chunks.length, 1);
83
+ assert.equal(chunks[0].chunkId, "a");
84
+ assert.equal(chunks[0].text, "hello world");
85
+ });
86
+
87
+ test("buildTopicModel finds 3 well-separated clusters (silhouette picks k=3)", () => {
88
+ const dir = stateDir();
89
+ seedClusters(dir, 3, 6, 8); // 18 chunks, 3 tight clusters
90
+ const model = buildTopicModel(openStore(dir), { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
91
+ assert.equal(model.totalChunks, 18);
92
+ assert.equal(model.k, 3);
93
+ assert.equal(model.topics.length, 3);
94
+ // Every chunk assigned; assignment count == chunk count.
95
+ assert.equal(model.assignments.length, 18);
96
+ // Labels reflect the seeded discriminative terms (sqlite/wal, checkpoint/recovery, backup/replay).
97
+ const labels = model.topics.map((t) => t.label).join(" | ");
98
+ assert.ok(/sqlite|checkpoint|backup/.test(labels), `labels should carry seeded terms: ${labels}`);
99
+ });
100
+
101
+ test("corpus too small → single general cluster, no crash", () => {
102
+ const dir = stateDir();
103
+ openStore(dir);
104
+ upsertCheckpoint(cp("only", "lone chunk about sqlite", axisVec(0, 4)), dir);
105
+ const model = buildTopicModel(openStore(dir), DEFAULT_CLUSTER_CONFIG); // kRange[0]=3 > 1 chunk
106
+ assert.equal(model.k, 1);
107
+ assert.equal(model.topics.length, 1);
108
+ assert.equal(model.topics[0].memoryCount, 1);
109
+ assert.equal(model.silhouetteScore, null);
110
+ });
111
+
112
+ test("empty corpus → zero topics, no crash", () => {
113
+ const dir = stateDir();
114
+ openStore(dir);
115
+ const model = buildTopicModel(openStore(dir), DEFAULT_CLUSTER_CONFIG);
116
+ assert.equal(model.k, 0);
117
+ assert.equal(model.topics.length, 0);
118
+ assert.equal(model.assignments.length, 0);
119
+ });
120
+
121
+ test("tfidfScores surfaces discriminative terms, down-weights common ones", () => {
122
+ const mk = (text: string): EmbeddedChunk => ({
123
+ chunkId: text, sessionId: "s", vec: [1], text,
124
+ });
125
+ const corpus = [
126
+ mk("sqlite wal sqlite pragma"),
127
+ mk("sqlite wal journal"),
128
+ mk("react component render"),
129
+ mk("react hooks state"),
130
+ ];
131
+ const members = corpus.slice(0, 2); // the sqlite cluster
132
+ const scores = tfidfScores(members, corpus);
133
+ const top = scores[0]?.term;
134
+ assert.ok(top === "sqlite" || top === "wal", `top term should be discriminative, got ${top}`);
135
+ // 'react' never appears in members → not scored for this cluster.
136
+ assert.ok(!scores.some((s) => s.term === "react"));
137
+ });
138
+
139
+ test("membershipConfidence maps cosine [-1,1] → [0,1]", () => {
140
+ assert.equal(membershipConfidence(1), 1);
141
+ assert.equal(membershipConfidence(-1), 0);
142
+ assert.equal(membershipConfidence(0), 0.5);
143
+ });
144
+
145
+ test("buildTopicModel is deterministic for a fixed seed", () => {
146
+ const dir = stateDir();
147
+ seedClusters(dir, 3, 5, 8);
148
+ const db = openStore(dir);
149
+ const a = buildTopicModel(db, { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
150
+ const b = buildTopicModel(db, { ...DEFAULT_CLUSTER_CONFIG, kRange: [2, 5] });
151
+ assert.equal(a.k, b.k);
152
+ assert.deepEqual(
153
+ a.topics.map((t) => t.label).sort(),
154
+ b.topics.map((t) => t.label).sort(),
155
+ );
156
+ });
157
+
158
+ test("honest boundary: no LLM/Ollama/network/keyword-literals in src/topics", () => {
159
+ const here = dirname(fileURLToPath(import.meta.url));
160
+ const srcTopics = join(here, "..", "..", "..", "src", "topics"); // dist/src/topics → src/topics
161
+ const files = readdirSync(srcTopics).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
162
+ const banned = [/ollama/i, /\bllm\b/i, /\bfetch\s*\(/, /\bhybrid\b/i];
163
+ const fabricated = ["graphql", "docker", "stacktrace", "bottleneck", "trade-off"];
164
+ for (const f of files) {
165
+ const text = readFileSync(join(srcTopics, f), "utf8");
166
+ for (const re of banned) {
167
+ assert.ok(!re.test(text), `${f} must not contain ${re} (PREVENT-PI-004 / no-LLM boundary)`);
168
+ }
169
+ for (const word of fabricated) {
170
+ assert.ok(
171
+ !text.toLowerCase().includes(`"${word}"`),
172
+ `${f} must not hardcode fabricated taxonomy term "${word}"`,
173
+ );
174
+ }
175
+ }
176
+ });
@@ -0,0 +1,191 @@
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
+
15
+ import type { DatabaseSync } from "node:sqlite";
16
+ import { cosineSimilarity } from "../embedder.js";
17
+ import { decodeEmbedding } from "../store/sqlite/utils.js";
18
+ import {
19
+ tfidfScores,
20
+ labelFromScores,
21
+ membershipConfidence,
22
+ } from "./labels.js";
23
+ import { selectK, type Candidate } from "./kselection.js";
24
+ import type {
25
+ ClusterModel,
26
+ EmbeddedChunk,
27
+ Topic,
28
+ TopicAssignment,
29
+ } from "./types.js";
30
+
31
+ /** Config surface for clustering (host supplies; see src/config/dedup.ts in S51B). */
32
+ export interface WikiClusterConfig {
33
+ /** [minK, maxK] search space for k selection. Default [3, 15]. */
34
+ kRange: [number, number];
35
+ /** How many TF-IDF terms form a label. Default 5. */
36
+ labelTopTerms: number;
37
+ /** k-means restarts per k (keep lowest WCSS). Default 5. */
38
+ restarts: number;
39
+ /** PRNG seed base for deterministic clustering. Default fixed. */
40
+ seed: number;
41
+ }
42
+
43
+ export const DEFAULT_CLUSTER_CONFIG: WikiClusterConfig = {
44
+ kRange: [3, 15],
45
+ labelTopTerms: 5,
46
+ restarts: 5,
47
+ seed: 0x9e3779b9,
48
+ };
49
+
50
+ interface ChunkRow {
51
+ id: string;
52
+ session_id: string;
53
+ embedding_blob: Uint8Array | null;
54
+ text: string | null;
55
+ }
56
+
57
+ /**
58
+ * Load chunks that have a usable embedding. Text is the best-available surface
59
+ * (normalized_text → summary → topic_summary). Skips rows with no embedding or
60
+ * no text. Returns in stable (session_id, id) order for determinism.
61
+ */
62
+ export function loadEmbeddings(mainDb: DatabaseSync): EmbeddedChunk[] {
63
+ const rows = mainDb
64
+ .prepare(
65
+ `SELECT id, session_id, embedding_blob,
66
+ COALESCE(normalized_text, summary, topic_summary) AS text
67
+ FROM context_chunks
68
+ WHERE embedding_blob IS NOT NULL
69
+ ORDER BY session_id ASC, id ASC`,
70
+ )
71
+ .all() as unknown as ChunkRow[];
72
+ const out: EmbeddedChunk[] = [];
73
+ for (const r of rows) {
74
+ const vec = decodeEmbedding(r.embedding_blob);
75
+ if (vec.length === 0) continue;
76
+ const text = (r.text ?? "").trim();
77
+ if (text.length === 0) continue;
78
+ out.push({ chunkId: r.id, sessionId: r.session_id, vec, text });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * Build a topic model from the real stored chunks. Picks k by silhouette when
85
+ * computable, else elbow; degenerate/small corpus → single `general` cluster.
86
+ * Never throws on degenerate input — returns the safe single-cluster model.
87
+ */
88
+ export function buildTopicModel(
89
+ mainDb: DatabaseSync,
90
+ cfg: WikiClusterConfig = DEFAULT_CLUSTER_CONFIG,
91
+ ): ClusterModel {
92
+ const builtAt = Date.now();
93
+ const chunks = loadEmbeddings(mainDb);
94
+ const totalChunks = chunks.length;
95
+
96
+ // Too small to cluster meaningfully → single general cluster.
97
+ if (totalChunks < Math.max(2, cfg.kRange[0])) {
98
+ return singleClusterModel(chunks, builtAt, cfg);
99
+ }
100
+
101
+ const points = chunks.map((c) => c.vec);
102
+ const [minK, maxK] = cfg.kRange;
103
+ const hi = Math.min(maxK, Math.max(minK, Math.floor(totalChunks / 2)));
104
+ const ks: number[] = [];
105
+ for (let k = minK; k <= hi; k++) ks.push(k);
106
+ if (ks.length === 0) ks.push(minK);
107
+
108
+ const { candidate: chosen, criterion } = selectK(points, ks, cfg);
109
+ const meanSil = chosen.silhouette;
110
+ return assembleModel(chunks, chosen, criterion, meanSil, builtAt, cfg);
111
+ }
112
+
113
+ /** Single `general` cluster holding every chunk (degenerate/small corpus). */
114
+ function singleClusterModel(
115
+ chunks: EmbeddedChunk[],
116
+ builtAt: number,
117
+ cfg: WikiClusterConfig,
118
+ ): ClusterModel {
119
+ const topic: Topic = {
120
+ id: "topic_0",
121
+ label: labelFromScores(tfidfScores(chunks, chunks), cfg.labelTopTerms),
122
+ termScores: tfidfScores(chunks, chunks),
123
+ memoryCount: chunks.length,
124
+ lastUpdated: builtAt,
125
+ };
126
+ const assignments: TopicAssignment[] = chunks.map((c) => ({
127
+ memoryId: c.chunkId,
128
+ sessionId: c.sessionId,
129
+ topicId: "topic_0",
130
+ confidence: 1,
131
+ assignedAt: builtAt,
132
+ method: "kmeans+tfidf",
133
+ }));
134
+ return {
135
+ topics: chunks.length > 0 ? [topic] : [],
136
+ assignments,
137
+ k: chunks.length > 0 ? 1 : 0,
138
+ criterion: "elbow",
139
+ silhouetteScore: null,
140
+ totalChunks: chunks.length,
141
+ builtAt,
142
+ };
143
+ }
144
+
145
+ /** Assemble topics + assignments from a chosen clustering. */
146
+ function assembleModel(
147
+ chunks: EmbeddedChunk[],
148
+ chosen: Candidate,
149
+ criterion: "elbow" | "silhouette",
150
+ meanSil: number | null,
151
+ builtAt: number,
152
+ cfg: WikiClusterConfig,
153
+ ): ClusterModel {
154
+ const topics: Topic[] = [];
155
+ const assignments: TopicAssignment[] = [];
156
+ for (let c = 0; c < chosen.k; c++) {
157
+ const members = chunks.filter((_, i) => chosen.assignments[i] === c);
158
+ if (members.length === 0) continue;
159
+ const id = `topic_${topics.length}`;
160
+ const scores = tfidfScores(members, chunks);
161
+ topics.push({
162
+ id,
163
+ label: labelFromScores(scores, cfg.labelTopTerms),
164
+ termScores: scores,
165
+ memoryCount: members.length,
166
+ lastUpdated: builtAt,
167
+ });
168
+ }
169
+ for (let i = 0; i < chunks.length; i++) {
170
+ const cluster = chosen.assignments[i];
171
+ const centroid = chosen.centroids[cluster];
172
+ const sim = centroid ? cosineSimilarity(chunks[i].vec, centroid) : 0;
173
+ assignments.push({
174
+ memoryId: chunks[i].chunkId,
175
+ sessionId: chunks[i].sessionId,
176
+ topicId: `topic_${cluster}`,
177
+ confidence: membershipConfidence(sim),
178
+ assignedAt: builtAt,
179
+ method: "kmeans+tfidf",
180
+ });
181
+ }
182
+ return {
183
+ topics,
184
+ assignments,
185
+ k: topics.length,
186
+ criterion,
187
+ silhouetteScore: meanSil,
188
+ totalChunks: chunks.length,
189
+ builtAt,
190
+ };
191
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * index.ts — S51 topics barrel. Re-export only; no logic.
3
+ */
4
+ export {
5
+ loadEmbeddings,
6
+ buildTopicModel,
7
+ DEFAULT_CLUSTER_CONFIG,
8
+ type WikiClusterConfig,
9
+ } from "./cluster.js";
10
+ export { createTopicStore, getWikiCompactCounter, bumpWikiCompactCounter } from "./store.js";
11
+ export type { TopicStore, StoredTopic } from "./store.js";
12
+ export { tokenize, tfidfScores, labelFromScores, membershipConfidence } from "./labels.js";
13
+ export type {
14
+ Topic,
15
+ TopicAssignment,
16
+ ClusterModel,
17
+ EmbeddedChunk,
18
+ } from "./types.js";