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.
- 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 +251 -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 +24 -0
- 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-DUulvXUY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementTiles-QPo46Pwg.js.map → AchievementTiles-DUulvXUY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js → AchievementsTab-Dx0YtCNf.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{AchievementsTab-BDJxRXRY.js.map → AchievementsTab-Dx0YtCNf.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js → CacheTab-DtkaUo1v.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{CacheTab-CUMqXQjd.js.map → CacheTab-DtkaUo1v.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js → ConfigTab-CLa4KfiZ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ConfigTab-j1dRzurG.js.map → ConfigTab-CLa4KfiZ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js → EventsTab-Q6tEIBBi.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{EventsTab-CMglXK4p.js.map → EventsTab-Q6tEIBBi.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js → GameTab-Hq7GNWSY.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{GameTab-fpaDoyxi.js.map → GameTab-Hq7GNWSY.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js → MetricsTab-BtmBV6lU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{MetricsTab-CWG2crG_.js.map → MetricsTab-BtmBV6lU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js → OverviewTab-CL10PwVU.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{OverviewTab-BaV2D2nf.js.map → OverviewTab-CL10PwVU.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js → ReposTab-DAwANwbI.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{ReposTab-C-0kuZ_0.js.map → ReposTab-DAwANwbI.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js → SessionsTab-Ec08TZRQ.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{SessionsTab-CSYhddCA.js.map → SessionsTab-Ec08TZRQ.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js → TimeSavedCard-COb8dpTy.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{TimeSavedCard-Dy4KRjSM.js.map → TimeSavedCard-COb8dpTy.js.map} +1 -1
- package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js +2 -0
- package/extensions/dashboard-client/dist/assets/TopicsTab-kvX3F3dY.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js +2 -0
- package/extensions/dashboard-client/dist/assets/TurnsTab-CCUE73Aa.js.map +1 -0
- package/extensions/dashboard-client/dist/assets/{index-D_WtU2TV.js → index-UMrussFX.js} +10 -10
- package/extensions/dashboard-client/dist/assets/index-UMrussFX.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-BDHZ7CeS.js} +2 -2
- package/extensions/dashboard-client/dist/assets/{useSSE-C9yVJlGP.js.map → useSSE-BDHZ7CeS.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 +84 -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 +185 -0
- package/extensions/dashboard-client/src/tabs/TurnsTab.tsx +326 -0
- package/extensions/dashboard-server/api-contracts/endpoints.ts +389 -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 +282 -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 +23 -0
- 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,182 @@
|
|
|
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
|
+
|
|
15
|
+
import {
|
|
16
|
+
cosineDistance,
|
|
17
|
+
kmeanspp,
|
|
18
|
+
meanVector,
|
|
19
|
+
} from "../dedup/raptor/kmeans.js";
|
|
20
|
+
import type { Vector } from "../embedder.js";
|
|
21
|
+
import type { WikiClusterConfig } from "./cluster.js";
|
|
22
|
+
|
|
23
|
+
/** One k-means candidate result for a single k, kept for criterion scoring. */
|
|
24
|
+
export interface Candidate {
|
|
25
|
+
k: number;
|
|
26
|
+
assignments: number[];
|
|
27
|
+
centroids: Vector[];
|
|
28
|
+
wcss: number;
|
|
29
|
+
silhouette: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Within-cluster sum of squares (cosine distance) for one clustering. */
|
|
33
|
+
export function wcss(
|
|
34
|
+
points: Vector[],
|
|
35
|
+
assignments: number[],
|
|
36
|
+
centroids: Vector[],
|
|
37
|
+
): number {
|
|
38
|
+
let s = 0;
|
|
39
|
+
for (let i = 0; i < points.length; i++) {
|
|
40
|
+
const d = cosineDistance(points[i], centroids[assignments[i]]);
|
|
41
|
+
s += d * d;
|
|
42
|
+
}
|
|
43
|
+
return s;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Mean silhouette score across all points (−1..1); null when undefined
|
|
48
|
+
* (k<=1 or k>=n, or every cluster is a singleton).
|
|
49
|
+
*/
|
|
50
|
+
export function silhouette(
|
|
51
|
+
points: Vector[],
|
|
52
|
+
assignments: number[],
|
|
53
|
+
k: number,
|
|
54
|
+
): number | null {
|
|
55
|
+
const n = points.length;
|
|
56
|
+
if (k <= 1 || k >= n) return null;
|
|
57
|
+
// Precompute cluster sizes once.
|
|
58
|
+
const sizes = new Map<number, number>();
|
|
59
|
+
for (const a of assignments) sizes.set(a, (sizes.get(a) ?? 0) + 1);
|
|
60
|
+
let sum = 0;
|
|
61
|
+
let counted = 0;
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
const a = assignments[i];
|
|
64
|
+
const ownSize = sizes.get(a) ?? 0;
|
|
65
|
+
if (ownSize <= 1) continue; // singleton cluster → contribution 0 (skip)
|
|
66
|
+
let aSum = 0;
|
|
67
|
+
const bSum = new Map<number, number>();
|
|
68
|
+
for (let j = 0; j < n; j++) {
|
|
69
|
+
if (i === j) continue;
|
|
70
|
+
const d = cosineDistance(points[i], points[j]);
|
|
71
|
+
if (assignments[j] === a) {
|
|
72
|
+
aSum += d;
|
|
73
|
+
} else {
|
|
74
|
+
bSum.set(assignments[j], (bSum.get(assignments[j]) ?? 0) + d);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const aMean = aSum / (ownSize - 1); // exclude self
|
|
78
|
+
let b = Infinity;
|
|
79
|
+
for (const [cluster, total] of bSum) {
|
|
80
|
+
const cnt = sizes.get(cluster) ?? 0;
|
|
81
|
+
if (cnt > 0) b = Math.min(b, total / cnt);
|
|
82
|
+
}
|
|
83
|
+
if (!Number.isFinite(b)) continue;
|
|
84
|
+
const denom = Math.max(aMean, b);
|
|
85
|
+
if (denom > 0) {
|
|
86
|
+
sum += (b - aMean) / denom;
|
|
87
|
+
counted++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return counted > 0 ? sum / counted : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Elbow: index of max curvature on the WCSS-vs-k curve. */
|
|
94
|
+
export function elbowIndex(ks: number[], wcssVals: number[]): number {
|
|
95
|
+
if (ks.length <= 2) return 0;
|
|
96
|
+
// Normalize and find the point farthest from the line joining the endpoints.
|
|
97
|
+
const x0 = ks[0];
|
|
98
|
+
const y0 = wcssVals[0];
|
|
99
|
+
const x1 = ks[ks.length - 1];
|
|
100
|
+
const y1 = wcssVals[wcssVals.length - 1];
|
|
101
|
+
const dx = x1 - x0;
|
|
102
|
+
const dy = y1 - y0;
|
|
103
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
104
|
+
let best = 0;
|
|
105
|
+
let bestDist = -1;
|
|
106
|
+
for (let i = 0; i < ks.length; i++) {
|
|
107
|
+
const dist =
|
|
108
|
+
Math.abs(dy * ks[i] - dx * wcssVals[i] + x1 * y0 - y1 * x0) / len;
|
|
109
|
+
if (dist > bestDist) {
|
|
110
|
+
bestDist = dist;
|
|
111
|
+
best = i;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Run k-means with restarts for one k; keep the lowest-WCSS result. */
|
|
118
|
+
export function bestForK(
|
|
119
|
+
points: Vector[],
|
|
120
|
+
k: number,
|
|
121
|
+
cfg: WikiClusterConfig,
|
|
122
|
+
): Candidate {
|
|
123
|
+
let best: Candidate | null = null;
|
|
124
|
+
for (let r = 0; r < cfg.restarts; r++) {
|
|
125
|
+
const res = kmeanspp(points, k, { seed: cfg.seed + r });
|
|
126
|
+
if (res.k === 0) continue;
|
|
127
|
+
const w = wcss(points, res.assignments, res.centroids);
|
|
128
|
+
if (!best || w < best.wcss) {
|
|
129
|
+
best = {
|
|
130
|
+
k: res.k,
|
|
131
|
+
assignments: res.assignments,
|
|
132
|
+
centroids: res.centroids,
|
|
133
|
+
wcss: w,
|
|
134
|
+
silhouette: null,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Degenerate: kmeans collapsed everything to < k clusters.
|
|
139
|
+
if (!best) {
|
|
140
|
+
const mean = meanVector(points);
|
|
141
|
+
best = {
|
|
142
|
+
k: 1,
|
|
143
|
+
assignments: points.map(() => 0),
|
|
144
|
+
centroids: [mean],
|
|
145
|
+
wcss: 0,
|
|
146
|
+
silhouette: null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
best.silhouette = silhouette(points, best.assignments, best.k);
|
|
150
|
+
return best;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The chosen candidate + which criterion selected it. */
|
|
154
|
+
export interface SelectedK {
|
|
155
|
+
candidate: Candidate;
|
|
156
|
+
criterion: "elbow" | "silhouette";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run k-means across the k search space and pick the best candidate:
|
|
161
|
+
* prefer silhouette when any candidate produced a computable score, else
|
|
162
|
+
* the elbow on the WCSS curve. `points` must be non-empty and `ks` non-empty.
|
|
163
|
+
*/
|
|
164
|
+
export function selectK(
|
|
165
|
+
points: Vector[],
|
|
166
|
+
ks: number[],
|
|
167
|
+
cfg: WikiClusterConfig,
|
|
168
|
+
): SelectedK {
|
|
169
|
+
const candidates = ks.map((k) => bestForK(points, k, cfg));
|
|
170
|
+
const withSil = candidates.filter((c) => c.silhouette !== null);
|
|
171
|
+
if (withSil.length > 0) {
|
|
172
|
+
const chosen = withSil.reduce((a, b) =>
|
|
173
|
+
(b.silhouette ?? -1) > (a.silhouette ?? -1) ? b : a,
|
|
174
|
+
);
|
|
175
|
+
return { candidate: chosen, criterion: "silhouette" };
|
|
176
|
+
}
|
|
177
|
+
const idx = elbowIndex(
|
|
178
|
+
candidates.map((c) => c.k),
|
|
179
|
+
candidates.map((c) => c.wcss),
|
|
180
|
+
);
|
|
181
|
+
return { candidate: candidates[idx], criterion: "elbow" };
|
|
182
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
|
|
10
|
+
import type { EmbeddedChunk } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** Tokenize text: lowercase, split on non-alphanumeric, drop empties/1-char. */
|
|
13
|
+
export function tokenize(text: string): string[] {
|
|
14
|
+
return text
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(/[^a-z0-9]+/)
|
|
17
|
+
.filter((t) => t.length > 1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute TF-IDF term scores for one cluster's member chunks against the whole
|
|
22
|
+
* corpus. TF = term count across the cluster's members; IDF = log(1 + N/df)
|
|
23
|
+
* where N = total chunks and df = chunks containing the term. Returns the full
|
|
24
|
+
* list sorted by score desc.
|
|
25
|
+
*/
|
|
26
|
+
export function tfidfScores(
|
|
27
|
+
memberChunks: EmbeddedChunk[],
|
|
28
|
+
corpus: EmbeddedChunk[],
|
|
29
|
+
): Array<{ term: string; score: number }> {
|
|
30
|
+
const tf = new Map<string, number>();
|
|
31
|
+
for (const c of memberChunks) {
|
|
32
|
+
for (const term of tokenize(c.text)) tf.set(term, (tf.get(term) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
if (tf.size === 0) return [];
|
|
35
|
+
|
|
36
|
+
// Document frequency over the whole corpus (one vote per chunk per term).
|
|
37
|
+
const df = new Map<string, number>();
|
|
38
|
+
for (const c of corpus) {
|
|
39
|
+
for (const term of new Set(tokenize(c.text))) df.set(term, (df.get(term) ?? 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
const n = Math.max(1, corpus.length);
|
|
42
|
+
|
|
43
|
+
const out: Array<{ term: string; score: number }> = [];
|
|
44
|
+
for (const [term, f] of tf) {
|
|
45
|
+
const idf = Math.log(1 + n / (df.get(term) ?? 1));
|
|
46
|
+
out.push({ term, score: f * idf });
|
|
47
|
+
}
|
|
48
|
+
out.sort((a, b) => b.score - a.score || a.term.localeCompare(b.term));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Join the top `topTerms` TF-IDF terms into a human label. */
|
|
53
|
+
export function labelFromScores(
|
|
54
|
+
scores: Array<{ term: string; score: number }>,
|
|
55
|
+
topTerms: number,
|
|
56
|
+
): string {
|
|
57
|
+
const top = scores.slice(0, Math.max(1, topTerms)).map((s) => s.term);
|
|
58
|
+
return top.length > 0 ? top.join(" · ") : "general";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Normalized cosine membership confidence in [0,1]. Cosine is in [-1,1]; map to
|
|
63
|
+
* [0,1] via (1 + cos)/2. A chunk sitting exactly on its centroid → 1.
|
|
64
|
+
*/
|
|
65
|
+
export function membershipConfidence(cosineSimilarity: number): number {
|
|
66
|
+
return Math.max(0, Math.min(1, (1 + cosineSimilarity) / 2));
|
|
67
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.test.ts — S51B topic persistence tests. Temp dirs; node:sqlite only.
|
|
3
|
+
* Verifies the topics/memory_topics shells (S49) are written atomically, CRUD
|
|
4
|
+
* round-trips, and the every-Nth-compaction counter behaves.
|
|
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 {
|
|
12
|
+
createTopicStore,
|
|
13
|
+
getWikiCompactCounter,
|
|
14
|
+
bumpWikiCompactCounter,
|
|
15
|
+
} from "./store.js";
|
|
16
|
+
import { openTurnStore, closeTurnStore } from "../store/turns/connection.js";
|
|
17
|
+
import type { ClusterModel } from "./types.js";
|
|
18
|
+
|
|
19
|
+
let tmpDir: string;
|
|
20
|
+
let counter = 0;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mc-topicstore-"));
|
|
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
|
+
function makeModel(builtAt: number, topics: number): ClusterModel {
|
|
35
|
+
const tops = [];
|
|
36
|
+
const assigns = [];
|
|
37
|
+
for (let i = 0; i < topics; i++) {
|
|
38
|
+
tops.push({
|
|
39
|
+
id: `topic_${i}`,
|
|
40
|
+
label: `label ${i}`,
|
|
41
|
+
termScores: [{ term: `t${i}`, score: i + 1 }],
|
|
42
|
+
memoryCount: 2,
|
|
43
|
+
lastUpdated: builtAt,
|
|
44
|
+
});
|
|
45
|
+
for (let m = 0; m < 2; m++) {
|
|
46
|
+
assigns.push({
|
|
47
|
+
memoryId: `mem_${i}_${m}`,
|
|
48
|
+
sessionId: "s",
|
|
49
|
+
topicId: `topic_${i}`,
|
|
50
|
+
confidence: 0.9 - m * 0.1,
|
|
51
|
+
assignedAt: builtAt,
|
|
52
|
+
method: "kmeans+tfidf" as const,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
topics: tops,
|
|
58
|
+
assignments: assigns,
|
|
59
|
+
k: topics,
|
|
60
|
+
criterion: "silhouette",
|
|
61
|
+
silhouetteScore: 0.5,
|
|
62
|
+
totalChunks: topics * 2,
|
|
63
|
+
builtAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("replaceTopicModel inserts topics + assignments; CRUD round-trips", () => {
|
|
68
|
+
const dir = stateDir();
|
|
69
|
+
const store = createTopicStore(dir);
|
|
70
|
+
store.replaceTopicModel(makeModel(1000, 2));
|
|
71
|
+
const topics = store.getTopics();
|
|
72
|
+
assert.equal(topics.length, 2);
|
|
73
|
+
assert.equal(topics[0].label, "label 0");
|
|
74
|
+
assert.equal(topics[0].clusterModelBuiltAt, 1000);
|
|
75
|
+
assert.deepEqual(topics[0].termScores, [{ term: "t0", score: 1 }]);
|
|
76
|
+
const members = store.getMemoriesForTopic("topic_0");
|
|
77
|
+
assert.equal(members.length, 2);
|
|
78
|
+
assert.equal(members[0].memoryId, "mem_0_0"); // confidence DESC
|
|
79
|
+
const a = store.getTopicForMemory("mem_1_1");
|
|
80
|
+
assert.equal(a?.topicId, "topic_1");
|
|
81
|
+
assert.ok(Math.abs((a?.confidence ?? 0) - 0.8) < 1e-9);
|
|
82
|
+
closeTurnStore(dir);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("replaceTopicModel is atomic — old model fully replaced", () => {
|
|
86
|
+
const dir = stateDir();
|
|
87
|
+
const store = createTopicStore(dir);
|
|
88
|
+
store.replaceTopicModel(makeModel(1000, 3)); // 3 topics, 6 assignments
|
|
89
|
+
store.replaceTopicModel(makeModel(2000, 1)); // 1 topic, 2 assignments
|
|
90
|
+
const stats = store.getTopicStats();
|
|
91
|
+
assert.equal(stats.totalTopics, 1);
|
|
92
|
+
assert.equal(stats.totalAssigned, 2);
|
|
93
|
+
assert.equal(stats.lastRebuildAt, 2000);
|
|
94
|
+
// Old topic ids gone.
|
|
95
|
+
assert.equal(store.getTopicForMemory("mem_2_0"), null);
|
|
96
|
+
closeTurnStore(dir);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("getTopics sorted by memory_count DESC; stats roll up", () => {
|
|
100
|
+
const dir = stateDir();
|
|
101
|
+
const store = createTopicStore(dir);
|
|
102
|
+
const model = makeModel(500, 2);
|
|
103
|
+
model.topics[1].memoryCount = 99; // make topic_1 biggest
|
|
104
|
+
// give topic_1 more assignments to match memoryCount semantics loosely
|
|
105
|
+
store.replaceTopicModel(model);
|
|
106
|
+
const topics = store.getTopics();
|
|
107
|
+
assert.equal(topics[0].id, "topic_1"); // highest count first
|
|
108
|
+
const stats = store.getTopicStats();
|
|
109
|
+
assert.equal(stats.totalTopics, 2);
|
|
110
|
+
closeTurnStore(dir);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("wiki compact counter increments + persists; default 0", () => {
|
|
114
|
+
const dir = stateDir();
|
|
115
|
+
const db = openTurnStore(dir);
|
|
116
|
+
assert.equal(getWikiCompactCounter(db), 0);
|
|
117
|
+
assert.equal(bumpWikiCompactCounter(db), 1);
|
|
118
|
+
assert.equal(bumpWikiCompactCounter(db), 2);
|
|
119
|
+
// Reopen handle (same cached conn) — value persists.
|
|
120
|
+
assert.equal(getWikiCompactCounter(openTurnStore(dir)), 2);
|
|
121
|
+
closeTurnStore(dir);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("topics + memory_topics shells exist with no seed data (S49 schema)", () => {
|
|
125
|
+
const dir = stateDir();
|
|
126
|
+
const db = openTurnStore(dir);
|
|
127
|
+
const topics = db.prepare("SELECT COUNT(*) AS c FROM topics").get() as { c: number };
|
|
128
|
+
const assigns = db.prepare("SELECT COUNT(*) AS c FROM memory_topics").get() as { c: number };
|
|
129
|
+
assert.equal(topics.c, 0);
|
|
130
|
+
assert.equal(assigns.c, 0);
|
|
131
|
+
closeTurnStore(dir);
|
|
132
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.ts — S51B topic persistence over the turns.db `topics`/`memory_topics`
|
|
3
|
+
* shells (reserved in S49's schema, so NO migration / no schema drift).
|
|
4
|
+
*
|
|
5
|
+
* Host-agnostic (no pi imports). Pure local node:sqlite (PREVENT-PI-004), all
|
|
6
|
+
* queries parameterized (PREVENT-002). This is a derived, read-mostly view:
|
|
7
|
+
* `replaceTopicModel` rewrites ONLY the two topic tables in one transaction and
|
|
8
|
+
* never touches turns/memories/checkpoints (PREVENT-PI-001/002).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
12
|
+
import { openTurnStore, withTx } from "../store/turns/connection.js";
|
|
13
|
+
import { getStateDir } from "../store.js";
|
|
14
|
+
import type { ClusterModel, Topic, TopicAssignment } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** A topic row joined with its computed member count. */
|
|
17
|
+
export interface StoredTopic extends Topic {
|
|
18
|
+
/** Epoch ms the owning model was built (cluster_model_built_at column). */
|
|
19
|
+
clusterModelBuiltAt: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TopicStore {
|
|
23
|
+
/** Atomically replace the entire topic model (clears old topics + assignments). */
|
|
24
|
+
replaceTopicModel(model: ClusterModel): void;
|
|
25
|
+
/** All topics, sorted by memory_count DESC. */
|
|
26
|
+
getTopics(): StoredTopic[];
|
|
27
|
+
/** Member memory ids for a topic (paginated). */
|
|
28
|
+
getMemoriesForTopic(
|
|
29
|
+
topicId: string,
|
|
30
|
+
limit?: number,
|
|
31
|
+
offset?: number,
|
|
32
|
+
): TopicAssignment[];
|
|
33
|
+
/** The assignment for one memory, or null. */
|
|
34
|
+
getTopicForMemory(memoryId: string): TopicAssignment | null;
|
|
35
|
+
/** Rollup stats for the wiki index header. */
|
|
36
|
+
getTopicStats(): {
|
|
37
|
+
totalTopics: number;
|
|
38
|
+
totalAssigned: number;
|
|
39
|
+
lastRebuildAt: number | null;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TopicRow {
|
|
44
|
+
id: string;
|
|
45
|
+
label: string;
|
|
46
|
+
term_scores: string | null;
|
|
47
|
+
memory_count: number;
|
|
48
|
+
last_updated: number | null;
|
|
49
|
+
cluster_model_built_at: number | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface AssignmentRow {
|
|
53
|
+
memory_id: string;
|
|
54
|
+
topic_id: string;
|
|
55
|
+
confidence: number | null;
|
|
56
|
+
assigned_at: number | null;
|
|
57
|
+
method: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseTermScores(
|
|
61
|
+
json: string | null,
|
|
62
|
+
): Array<{ term: string; score: number }> {
|
|
63
|
+
if (!json) return [];
|
|
64
|
+
try {
|
|
65
|
+
const v = JSON.parse(json) as unknown;
|
|
66
|
+
return Array.isArray(v)
|
|
67
|
+
? (v as Array<{ term: string; score: number }>)
|
|
68
|
+
: [];
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rowToTopic(r: TopicRow): StoredTopic {
|
|
75
|
+
return {
|
|
76
|
+
id: r.id,
|
|
77
|
+
label: r.label,
|
|
78
|
+
termScores: parseTermScores(r.term_scores),
|
|
79
|
+
memoryCount: r.memory_count,
|
|
80
|
+
lastUpdated: r.last_updated ?? 0,
|
|
81
|
+
clusterModelBuiltAt: r.cluster_model_built_at,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rowToAssignment(r: AssignmentRow): TopicAssignment {
|
|
86
|
+
return {
|
|
87
|
+
memoryId: r.memory_id,
|
|
88
|
+
// memory_topics has no session_id column; the chunk id is globally unique
|
|
89
|
+
// enough for the wiki view (context_chunks PK is (session_id, id)).
|
|
90
|
+
// sessionId omitted — memory_topics has no session_id column;
|
|
91
|
+
// the TopicAssignment type marks it optional for this reason.
|
|
92
|
+
topicId: r.topic_id,
|
|
93
|
+
confidence: r.confidence ?? 0,
|
|
94
|
+
assignedAt: r.assigned_at ?? 0,
|
|
95
|
+
method: "kmeans+tfidf",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Open a TopicStore over the same turns.db the S49 TurnStore uses (shared connection cache). */
|
|
100
|
+
export function createTopicStore(stateDir?: string): TopicStore {
|
|
101
|
+
const db: DatabaseSync = openTurnStore(stateDir ?? getStateDir());
|
|
102
|
+
|
|
103
|
+
function replaceTopicModel(model: ClusterModel): void {
|
|
104
|
+
withTx(db, () => {
|
|
105
|
+
db.prepare("DELETE FROM memory_topics").run();
|
|
106
|
+
db.prepare("DELETE FROM topics").run();
|
|
107
|
+
const insTopic = db.prepare(
|
|
108
|
+
`INSERT INTO topics (id, label, term_scores, memory_count, last_updated, cluster_model_built_at)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
110
|
+
);
|
|
111
|
+
for (const t of model.topics) {
|
|
112
|
+
insTopic.run(
|
|
113
|
+
t.id,
|
|
114
|
+
t.label,
|
|
115
|
+
JSON.stringify(t.termScores),
|
|
116
|
+
t.memoryCount,
|
|
117
|
+
t.lastUpdated,
|
|
118
|
+
model.builtAt,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const insAssign = db.prepare(
|
|
122
|
+
`INSERT OR REPLACE INTO memory_topics (memory_id, topic_id, confidence, assigned_at, method)
|
|
123
|
+
VALUES (?, ?, ?, ?, 'kmeans+tfidf')`,
|
|
124
|
+
);
|
|
125
|
+
for (const a of model.assignments) {
|
|
126
|
+
insAssign.run(a.memoryId, a.topicId, a.confidence, a.assignedAt);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
replaceTopicModel,
|
|
133
|
+
|
|
134
|
+
getTopics(): StoredTopic[] {
|
|
135
|
+
const rows = db
|
|
136
|
+
.prepare("SELECT * FROM topics ORDER BY memory_count DESC, id ASC")
|
|
137
|
+
.all() as unknown as TopicRow[];
|
|
138
|
+
return rows.map(rowToTopic);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
getMemoriesForTopic(
|
|
142
|
+
topicId: string,
|
|
143
|
+
limit = 50,
|
|
144
|
+
offset = 0,
|
|
145
|
+
): TopicAssignment[] {
|
|
146
|
+
const rows = db
|
|
147
|
+
.prepare(
|
|
148
|
+
`SELECT memory_id, topic_id, confidence, assigned_at, method
|
|
149
|
+
FROM memory_topics WHERE topic_id = ?
|
|
150
|
+
ORDER BY confidence DESC, memory_id ASC LIMIT ? OFFSET ?`,
|
|
151
|
+
)
|
|
152
|
+
.all(topicId, limit, offset) as unknown as AssignmentRow[];
|
|
153
|
+
return rows.map(rowToAssignment);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
getTopicForMemory(memoryId: string): TopicAssignment | null {
|
|
157
|
+
const r = db
|
|
158
|
+
.prepare(
|
|
159
|
+
`SELECT memory_id, topic_id, confidence, assigned_at, method
|
|
160
|
+
FROM memory_topics WHERE memory_id = ? LIMIT 1`,
|
|
161
|
+
)
|
|
162
|
+
.get(memoryId) as unknown as AssignmentRow | undefined;
|
|
163
|
+
return r ? rowToAssignment(r) : null;
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
getTopicStats(): {
|
|
167
|
+
totalTopics: number;
|
|
168
|
+
totalAssigned: number;
|
|
169
|
+
lastRebuildAt: number | null;
|
|
170
|
+
} {
|
|
171
|
+
const t = db.prepare("SELECT COUNT(*) AS c FROM topics").get() as {
|
|
172
|
+
c: number;
|
|
173
|
+
};
|
|
174
|
+
const a = db.prepare("SELECT COUNT(*) AS c FROM memory_topics").get() as {
|
|
175
|
+
c: number;
|
|
176
|
+
};
|
|
177
|
+
const r = db
|
|
178
|
+
.prepare("SELECT MAX(cluster_model_built_at) AS m FROM topics")
|
|
179
|
+
.get() as { m: number | null };
|
|
180
|
+
return {
|
|
181
|
+
totalTopics: t.c,
|
|
182
|
+
totalAssigned: a.c,
|
|
183
|
+
lastRebuildAt: r.m ?? null,
|
|
184
|
+
};
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Turns-meta counter key for the every-Nth-compaction rebuild trigger. */
|
|
190
|
+
const WIKI_COUNTER_KEY = "wiki_compact_counter";
|
|
191
|
+
|
|
192
|
+
/** Read the rebuild counter (0 when unset). */
|
|
193
|
+
export function getWikiCompactCounter(db: DatabaseSync): number {
|
|
194
|
+
const r = db
|
|
195
|
+
.prepare("SELECT value FROM turns_meta WHERE key = ?")
|
|
196
|
+
.get(WIKI_COUNTER_KEY) as { value: string } | undefined;
|
|
197
|
+
const n = r ? Number(r.value) : 0;
|
|
198
|
+
return Number.isFinite(n) ? n : 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Increment + persist the rebuild counter; returns the new value. */
|
|
202
|
+
export function bumpWikiCompactCounter(db: DatabaseSync): number {
|
|
203
|
+
const next = getWikiCompactCounter(db) + 1;
|
|
204
|
+
db.prepare(
|
|
205
|
+
"INSERT OR REPLACE INTO turns_meta (key, value) VALUES (?, ?)",
|
|
206
|
+
).run(WIKI_COUNTER_KEY, String(next));
|
|
207
|
+
return next;
|
|
208
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — S51A auto-categorizing wiki: shared types.
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic (no pi imports). These describe a derived, read-only topic view
|
|
5
|
+
* over stored memory chunks — see docs/specs/s51-auto-categorizing-wiki.md and
|
|
6
|
+
* the extended s47 spec. No model calls, no network (PREVENT-PI-004); pure local math.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One derived topic cluster. */
|
|
10
|
+
export interface Topic {
|
|
11
|
+
/** Stable cluster id (e.g. "topic_0"). */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Human label = top TF-IDF terms joined (e.g. "sqlite · wal · checkpoint"). */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Full sorted TF-IDF term scores for this cluster (label is the top slice). */
|
|
16
|
+
termScores: Array<{ term: string; score: number }>;
|
|
17
|
+
/** Number of member chunks assigned to this topic. */
|
|
18
|
+
memoryCount: number;
|
|
19
|
+
/** Epoch ms when this topic was (re)built. */
|
|
20
|
+
lastUpdated: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Assignment of one memory chunk to a topic. */
|
|
24
|
+
export interface TopicAssignment {
|
|
25
|
+
/** `context_chunks.id` (per-session). */
|
|
26
|
+
memoryId: string;
|
|
27
|
+
/** Owning session — `context_chunks` PK is (session_id, id).
|
|
28
|
+
* Empty string when reconstructed from `memory_topics` (no session_id column there). */
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
topicId: string;
|
|
31
|
+
/** Normalized cosine membership in [0,1] (closer to centroid → higher). */
|
|
32
|
+
confidence: number;
|
|
33
|
+
/** Epoch ms when assigned. */
|
|
34
|
+
assignedAt: number;
|
|
35
|
+
/** Constant provenance tag. */
|
|
36
|
+
method: "kmeans+tfidf";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The full derived model: k clusters + assignments + the criterion used. */
|
|
40
|
+
export interface ClusterModel {
|
|
41
|
+
topics: Topic[];
|
|
42
|
+
assignments: TopicAssignment[];
|
|
43
|
+
/** Chosen cluster count (1 for a degenerate/small corpus). */
|
|
44
|
+
k: number;
|
|
45
|
+
/** Real criterion used to pick k. */
|
|
46
|
+
criterion: "elbow" | "silhouette";
|
|
47
|
+
/** Mean silhouette across clusters; null when the corpus is too small. */
|
|
48
|
+
silhouetteScore: number | null;
|
|
49
|
+
/** Chunks with usable embeddings that were clustered. */
|
|
50
|
+
totalChunks: number;
|
|
51
|
+
/** Epoch ms when built. */
|
|
52
|
+
builtAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A loaded chunk ready for clustering: its embedding + best-available text. */
|
|
56
|
+
export interface EmbeddedChunk {
|
|
57
|
+
/** `context_chunks.id`. */
|
|
58
|
+
chunkId: string;
|
|
59
|
+
sessionId: string;
|
|
60
|
+
/** Decoded float32 embedding (L2-normalized by the embedder). */
|
|
61
|
+
vec: number[];
|
|
62
|
+
/** COALESCE(normalized_text, summary, topic_summary) — TF-IDF + summary source. */
|
|
63
|
+
text: string;
|
|
64
|
+
}
|