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,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";
|