pi-mega-compact 0.8.21 → 0.8.23
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/LICENSE +6 -2
- package/README.md +1 -1
- package/dist/extensions/dashboard-server/dashboard-client-core.js +201 -0
- package/dist/extensions/dashboard-server/dashboard-client-game.js +241 -0
- package/dist/extensions/dashboard-server/dashboard-client-repos.js +212 -0
- package/dist/extensions/dashboard-server/dashboard-client.js +19 -0
- package/dist/extensions/dashboard-server/html.js +2 -621
- package/dist/extensions/dashboard-server/routes-core.js +62 -0
- package/dist/extensions/dashboard-server/routes-game.js +323 -0
- package/dist/extensions/dashboard-server/routes-repo.js +170 -0
- package/dist/extensions/dashboard-server/routes-sessions.js +159 -0
- package/dist/extensions/dashboard-server/routes.js +10 -0
- package/dist/extensions/dashboard-server/server.js +26 -623
- package/dist/extensions/mega-commands.js +4 -3
- package/dist/extensions/mega-events/agent-handlers.js +2 -1
- package/dist/extensions/mega-events/compact-handlers.js +26 -0
- package/dist/extensions/mega-events/session-handlers.js +2 -1
- package/dist/extensions/mega-pipeline/compact.js +3 -2
- package/dist/extensions/mega-runtime/state.js +7 -7
- package/dist/src/dedup/raptor/multilevel.js +172 -0
- package/dist/src/dedup/raptor/multilevel.test.js +203 -0
- package/dist/src/dedup/raptor/promote.test.js +5 -5
- package/dist/src/dedup/raptor/retrieval.js +1 -1
- package/dist/src/dedup/sprint12.test.js +7 -7
- package/dist/src/dedup-engine.test.js +29 -29
- package/dist/src/e2e.test.js +38 -38
- package/dist/src/engine.js +3 -3
- package/dist/src/engine.test.js +6 -6
- package/dist/src/importance.js +197 -0
- package/dist/src/importance.test.js +372 -0
- package/dist/src/ratio.bench.test.js +18 -18
- package/dist/src/recall.js +6 -5
- package/dist/src/recall.test.js +85 -27
- package/dist/src/sprint14.test.js +2 -2
- package/dist/src/store/migrate.test.js +5 -5
- package/dist/src/store/sprint10.test.js +5 -5
- package/dist/src/store/sqlite/global-index.js +5 -174
- package/dist/src/store/sqlite/global-sessions.js +190 -0
- package/dist/src/vector-read.js +168 -0
- package/dist/src/vector-search.js +191 -0
- package/dist/src/vectorStore.js +10 -297
- package/dist/src/vectorStore.test.js +32 -32
- package/extensions/dashboard-server/dashboard-client-core.ts +202 -0
- package/extensions/dashboard-server/dashboard-client-game.ts +242 -0
- package/extensions/dashboard-server/dashboard-client-repos.ts +213 -0
- package/extensions/dashboard-server/dashboard-client.ts +21 -0
- package/extensions/dashboard-server/html.ts +2 -621
- package/extensions/dashboard-server/routes-core.ts +113 -0
- package/extensions/dashboard-server/routes-game.ts +386 -0
- package/extensions/dashboard-server/routes-repo.ts +212 -0
- package/extensions/dashboard-server/routes-sessions.ts +195 -0
- package/extensions/dashboard-server/routes.ts +13 -0
- package/extensions/dashboard-server/server.ts +37 -700
- package/extensions/mega-commands.ts +4 -3
- package/extensions/mega-events/agent-handlers.ts +2 -1
- package/extensions/mega-events/compact-handlers.ts +28 -0
- package/extensions/mega-events/session-handlers.ts +2 -1
- package/extensions/mega-pipeline/compact.ts +3 -2
- package/extensions/mega-runtime/state.ts +7 -7
- package/extensions/openclaw-mega-compact.ts +2 -2
- package/package.json +2 -2
- package/src/dedup/raptor/multilevel.test.ts +278 -0
- package/src/dedup/raptor/multilevel.ts +246 -0
- package/src/dedup/raptor/promote.test.ts +5 -5
- package/src/dedup/raptor/retrieval.ts +1 -1
- package/src/dedup/sprint12.test.ts +7 -7
- package/src/dedup-engine.test.ts +30 -30
- package/src/e2e.test.ts +38 -38
- package/src/engine.test.ts +6 -6
- package/src/engine.ts +3 -3
- package/src/importance.test.ts +538 -0
- package/src/importance.ts +312 -0
- package/src/ratio.bench.test.ts +18 -18
- package/src/recall.test.ts +101 -29
- package/src/recall.ts +9 -9
- package/src/sprint14.test.ts +2 -2
- package/src/store/migrate.test.ts +5 -5
- package/src/store/sprint10.test.ts +5 -5
- package/src/store/sqlite/global-index.ts +18 -290
- package/src/store/sqlite/global-sessions.ts +291 -0
- package/src/vector-read.ts +237 -0
- package/src/vector-search.ts +231 -0
- package/src/vectorStore.test.ts +32 -32
- package/src/vectorStore.ts +29 -356
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* global-sessions.ts — session heartbeats + token time-series (S39).
|
|
3
|
+
*
|
|
4
|
+
* Split out of global-index.ts so each file stays under 500 lines. Shares the
|
|
5
|
+
* same machine-wide index DB (`indexCache` in global-index.ts) via
|
|
6
|
+
* `openIndexStore()`; no schema or behavior change — pure structural move.
|
|
7
|
+
*
|
|
8
|
+
* `session_heartbeats`: one row per (pid, session_id) live session, upserted on
|
|
9
|
+
* every material snapshot. `token_samples`: append-only rows with
|
|
10
|
+
* (session_id, tokens, percent, ts) for the stacked-memory graph. Garbage-
|
|
11
|
+
* collected by pruneTokenSamples.
|
|
12
|
+
*
|
|
13
|
+
* All queries use @named/$named bind parameters (PREVENT-002); local
|
|
14
|
+
* node:sqlite + WAL (PREVENT-PI-004), multi-process safe.
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { openIndexStore, getIndexDir } from "./global-index.js";
|
|
18
|
+
|
|
19
|
+
/** A live active session row (joined heartbeat + latest token sample). */
|
|
20
|
+
export interface ActiveSessionRow {
|
|
21
|
+
pid: number;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
repoRoot: string | null;
|
|
24
|
+
stateDir: string | null;
|
|
25
|
+
ctxWindow: number;
|
|
26
|
+
lastSeen: number;
|
|
27
|
+
tokens: number | null;
|
|
28
|
+
percent: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A single time-series data point for a session. */
|
|
32
|
+
export interface TokenSamplePoint {
|
|
33
|
+
ts: number;
|
|
34
|
+
tokens: number;
|
|
35
|
+
percent: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A recharts-ready per-session series with a stable color. */
|
|
39
|
+
export interface SessionSeries {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
label: string;
|
|
42
|
+
color: string;
|
|
43
|
+
data: TokenSamplePoint[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Timeseries response shape (recharts-ready). */
|
|
47
|
+
export interface SessionTimeseriesResult {
|
|
48
|
+
series: SessionSeries[];
|
|
49
|
+
totals: { ts: number; tokens: number }[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Stable color palette for per-session series (hash-based, no randomness). */
|
|
53
|
+
const SESSION_COLORS = [
|
|
54
|
+
"#60a5fa", // blue-400
|
|
55
|
+
"#34d399", // emerald-400
|
|
56
|
+
"#fbbf24", // amber-400
|
|
57
|
+
"#f87171", // red-400
|
|
58
|
+
"#a78bfa", // violet-400
|
|
59
|
+
"#f472b6", // pink-400
|
|
60
|
+
"#22d3ee", // cyan-400
|
|
61
|
+
"#a3e635", // lime-400
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Hash a sessionId to a stable color index. */
|
|
65
|
+
function sessionColor(sessionId: string): string {
|
|
66
|
+
let h = 0;
|
|
67
|
+
for (let i = 0; i < sessionId.length; i++) {
|
|
68
|
+
h = (h * 31 + sessionId.charCodeAt(i)) | 0;
|
|
69
|
+
}
|
|
70
|
+
return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record (upsert) a session heartbeat. Called from snapshot() on every material
|
|
75
|
+
* change. PRIMARY KEY (pid, session_id) means concurrent pi processes each get
|
|
76
|
+
* their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
|
|
77
|
+
*/
|
|
78
|
+
export function recordSessionHeartbeat(
|
|
79
|
+
pid: number,
|
|
80
|
+
sessionId: string,
|
|
81
|
+
repoRoot: string,
|
|
82
|
+
stateDir: string,
|
|
83
|
+
ctxWindow: number,
|
|
84
|
+
indexDir: string = getIndexDir(),
|
|
85
|
+
): void {
|
|
86
|
+
const db = openIndexStore(indexDir);
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
db.prepare(
|
|
89
|
+
`INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
|
|
90
|
+
VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
|
|
91
|
+
ON CONFLICT(pid, session_id) DO UPDATE SET
|
|
92
|
+
repo_root = excluded.repo_root,
|
|
93
|
+
state_dir = excluded.state_dir,
|
|
94
|
+
ctx_window = excluded.ctx_window,
|
|
95
|
+
last_seen = excluded.last_seen`,
|
|
96
|
+
).run({
|
|
97
|
+
pid,
|
|
98
|
+
session_id: sessionId,
|
|
99
|
+
repo_root: repoRoot,
|
|
100
|
+
state_dir: stateDir,
|
|
101
|
+
ctx_window: ctxWindow,
|
|
102
|
+
last_seen: now,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Append a token sample row + optionally a session_sample line to events.log
|
|
108
|
+
* (for SSE real-time push via /api/events). The eventsLogPath is optional —
|
|
109
|
+
* callers without an events.log (e.g. tests) can omit it.
|
|
110
|
+
*/
|
|
111
|
+
export function appendTokenSample(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
repoRoot: string,
|
|
114
|
+
tokens: number,
|
|
115
|
+
percent: number,
|
|
116
|
+
ctxWindow: number,
|
|
117
|
+
eventsLogPath: string | null,
|
|
118
|
+
indexDir: string = getIndexDir(),
|
|
119
|
+
): void {
|
|
120
|
+
const db = openIndexStore(indexDir);
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
db.prepare(
|
|
123
|
+
`INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
|
|
124
|
+
VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`,
|
|
125
|
+
).run({
|
|
126
|
+
session_id: sessionId,
|
|
127
|
+
repo_root: repoRoot,
|
|
128
|
+
tokens,
|
|
129
|
+
percent,
|
|
130
|
+
ctx_window: ctxWindow,
|
|
131
|
+
ts: now,
|
|
132
|
+
});
|
|
133
|
+
// Step 5: also append a session_sample JSON line to events.log so the
|
|
134
|
+
// existing /api/events SSE tail streams it for free (real-time chart push).
|
|
135
|
+
// Mirrors the DashboardEmitter events.log append pattern in
|
|
136
|
+
// extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
|
|
137
|
+
// `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
|
|
138
|
+
// timestamp matches the shape of every other SSE variant (SseSessionSample
|
|
139
|
+
// contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
|
|
140
|
+
// violate the contract union's "every SSE variant has a ts field of type
|
|
141
|
+
// string" invariant and break DashboardEmitter consumers.
|
|
142
|
+
if (eventsLogPath) {
|
|
143
|
+
try {
|
|
144
|
+
const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
|
|
145
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
146
|
+
appendFileSync(
|
|
147
|
+
eventsLogPath,
|
|
148
|
+
JSON.stringify({
|
|
149
|
+
ts: new Date(now).toISOString(),
|
|
150
|
+
type: "session_sample",
|
|
151
|
+
sessionId,
|
|
152
|
+
tokens,
|
|
153
|
+
percent,
|
|
154
|
+
}) + "\n",
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
/* non-fatal: SSE push is best-effort */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Prune stale session heartbeats (sessions not seen within maxAgeMs).
|
|
164
|
+
* Default 30-min retention. Called by /api/sessions.
|
|
165
|
+
*/
|
|
166
|
+
export function pruneStaleSessions(
|
|
167
|
+
maxAgeMs: number = 1_800_000,
|
|
168
|
+
indexDir: string = getIndexDir(),
|
|
169
|
+
): number {
|
|
170
|
+
const db = openIndexStore(indexDir);
|
|
171
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
172
|
+
const result = db.prepare(
|
|
173
|
+
"DELETE FROM session_heartbeats WHERE last_seen < @cutoff",
|
|
174
|
+
).run({ cutoff });
|
|
175
|
+
return Number(result.changes);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Prune old token samples older than maxAgeMs. Default 30-min retention.
|
|
180
|
+
* Called by /api/sessions/timeseries.
|
|
181
|
+
*/
|
|
182
|
+
export function pruneTokenSamples(
|
|
183
|
+
maxAgeMs: number = 1_800_000,
|
|
184
|
+
indexDir: string = getIndexDir(),
|
|
185
|
+
): number {
|
|
186
|
+
const db = openIndexStore(indexDir);
|
|
187
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
188
|
+
const result = db.prepare(
|
|
189
|
+
"DELETE FROM token_samples WHERE ts < @cutoff",
|
|
190
|
+
).run({ cutoff });
|
|
191
|
+
return Number(result.changes);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read all active sessions with their latest token sample (if any).
|
|
196
|
+
* JOINs session_heartbeats with the latest token_samples per session_id
|
|
197
|
+
* via a correlated subquery. Returns rows sorted by last_seen descending.
|
|
198
|
+
*/
|
|
199
|
+
export function readActiveSessions(
|
|
200
|
+
indexDir: string = getIndexDir(),
|
|
201
|
+
): ActiveSessionRow[] {
|
|
202
|
+
const db = openIndexStore(indexDir);
|
|
203
|
+
const rows = db.prepare(
|
|
204
|
+
`SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
|
|
205
|
+
s.tokens, s.percent
|
|
206
|
+
FROM session_heartbeats h
|
|
207
|
+
LEFT JOIN token_samples s ON s.id = (
|
|
208
|
+
SELECT id FROM token_samples t
|
|
209
|
+
WHERE t.session_id = h.session_id
|
|
210
|
+
ORDER BY t.ts DESC LIMIT 1
|
|
211
|
+
)
|
|
212
|
+
ORDER BY h.last_seen DESC`,
|
|
213
|
+
).all() as Array<{
|
|
214
|
+
pid: number;
|
|
215
|
+
session_id: string;
|
|
216
|
+
repo_root: string | null;
|
|
217
|
+
state_dir: string | null;
|
|
218
|
+
ctx_window: number;
|
|
219
|
+
last_seen: number;
|
|
220
|
+
tokens: number | null;
|
|
221
|
+
percent: number | null;
|
|
222
|
+
}>;
|
|
223
|
+
return rows.map((r) => ({
|
|
224
|
+
pid: r.pid,
|
|
225
|
+
sessionId: r.session_id,
|
|
226
|
+
repoRoot: r.repo_root,
|
|
227
|
+
stateDir: r.state_dir,
|
|
228
|
+
ctxWindow: r.ctx_window ?? 0,
|
|
229
|
+
lastSeen: r.last_seen,
|
|
230
|
+
tokens: r.tokens,
|
|
231
|
+
percent: r.percent,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read token samples since sinceMs, returning a recharts-ready stacked shape:
|
|
237
|
+
* per-session `SessionSeries` (with stable color) + a `totals` array
|
|
238
|
+
* [{ts, tokens}] (sum of all sessions at each timestamp).
|
|
239
|
+
*/
|
|
240
|
+
export function readSessionTimeseries(
|
|
241
|
+
sinceMs: number,
|
|
242
|
+
indexDir: string = getIndexDir(),
|
|
243
|
+
): SessionTimeseriesResult {
|
|
244
|
+
const db = openIndexStore(indexDir);
|
|
245
|
+
const rows = db.prepare(
|
|
246
|
+
`SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`,
|
|
247
|
+
).all({ since: sinceMs }) as Array<{
|
|
248
|
+
session_id: string;
|
|
249
|
+
tokens: number;
|
|
250
|
+
percent: number;
|
|
251
|
+
ts: number;
|
|
252
|
+
}>;
|
|
253
|
+
// Group by session_id → series; + accumulate totals per timestamp.
|
|
254
|
+
const seriesMap = new Map<string, TokenSamplePoint[]>();
|
|
255
|
+
const totalsMap = new Map<number, number>();
|
|
256
|
+
for (const r of rows) {
|
|
257
|
+
let pts = seriesMap.get(r.session_id);
|
|
258
|
+
if (!pts) {
|
|
259
|
+
pts = [];
|
|
260
|
+
seriesMap.set(r.session_id, pts);
|
|
261
|
+
}
|
|
262
|
+
pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
|
|
263
|
+
totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
|
|
264
|
+
}
|
|
265
|
+
const series: SessionSeries[] = [];
|
|
266
|
+
for (const [sessionId, data] of seriesMap) {
|
|
267
|
+
const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
|
|
268
|
+
series.push({ sessionId, label, color: sessionColor(sessionId), data });
|
|
269
|
+
}
|
|
270
|
+
// Sort series by first-timestamp for stable legend order.
|
|
271
|
+
series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
|
|
272
|
+
const totals = Array.from(totalsMap.entries())
|
|
273
|
+
.sort((a, b) => a[0] - b[0])
|
|
274
|
+
.map(([ts, tokens]) => ({ ts, tokens }));
|
|
275
|
+
return { series, totals };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Clear a session's heartbeat row (e.g. on clean shutdown / session reset).
|
|
280
|
+
* Non-fatal: no-op if the row doesn't exist.
|
|
281
|
+
*/
|
|
282
|
+
export function clearSessionHeartbeat(
|
|
283
|
+
pid: number,
|
|
284
|
+
sessionId: string,
|
|
285
|
+
indexDir: string = getIndexDir(),
|
|
286
|
+
): void {
|
|
287
|
+
const db = openIndexStore(indexDir);
|
|
288
|
+
db.prepare(
|
|
289
|
+
"DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id",
|
|
290
|
+
).run({ pid, session_id: sessionId });
|
|
291
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector-read.ts — free functions for VectorStore read-only operations.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from vectorStore.ts (PR0 split) to bring it under 500 lines.
|
|
5
|
+
* All functions take `store: VectorStore` as first param and access store
|
|
6
|
+
* fields/props directly via type-cast to access private fields (acceptable
|
|
7
|
+
* since these fields were effectively public via the original methods).
|
|
8
|
+
*
|
|
9
|
+
* Call sites in src/ and extensions/ are rewritten from `store.stats(sid)`
|
|
10
|
+
* → `vectorStats(store, sid)`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Vector } from "./embedder.js";
|
|
14
|
+
import { cosineSimilarity } from "./embedder.js";
|
|
15
|
+
import type { StoredCheckpoint, SessionState } from "./store.js";
|
|
16
|
+
import {
|
|
17
|
+
listCheckpoints,
|
|
18
|
+
loadSessionState,
|
|
19
|
+
saveSessionState,
|
|
20
|
+
setDedupStatus,
|
|
21
|
+
getDedupStats,
|
|
22
|
+
repoStats as repoStatsFromStore,
|
|
23
|
+
dataInvariantStats,
|
|
24
|
+
} from "./store/sqlite.js";
|
|
25
|
+
import { normalizeSessionId } from "./store.js";
|
|
26
|
+
import { computeRegionHash } from "./vectorStore.js";
|
|
27
|
+
import type { VectorStore } from "./vectorStore.js";
|
|
28
|
+
import type { SearchHit } from "./vectorStore.js";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Cosine
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Convenience wrapper for raw vector cosine similarity (exposed for tests). */
|
|
35
|
+
export function vectorSimilarity(_store: VectorStore, a: Vector, b: Vector): number {
|
|
36
|
+
return cosineSimilarity(a, b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// SemDeDup
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* SemDeDup offline cleanup (Sprint 12, QA #17): within a session, mark the
|
|
45
|
+
* lower-quality row of any pair scoring cosine > `threshold` as
|
|
46
|
+
* `dedup_status='removed'` (kept, not deleted — retrieval excludes it). Keeps
|
|
47
|
+
* the row with the higher `tokenEstimate` (more context preserved). Runs as a
|
|
48
|
+
* single scan; idempotent (re-running skips already-removed rows).
|
|
49
|
+
*
|
|
50
|
+
* Returns the number of rows marked removed.
|
|
51
|
+
*/
|
|
52
|
+
export function vectorSemDedup(
|
|
53
|
+
store: VectorStore,
|
|
54
|
+
sessionId: string,
|
|
55
|
+
threshold?: number,
|
|
56
|
+
): number {
|
|
57
|
+
const sid = normalizeSessionId(sessionId);
|
|
58
|
+
const stateDir = store.stateDir;
|
|
59
|
+
const cfg = store.cfg;
|
|
60
|
+
const thr = threshold ?? cfg.SEMDEDUP_COSINE;
|
|
61
|
+
const cps = listCheckpoints(sid, stateDir).filter(
|
|
62
|
+
(c) => c.dedupStatus !== "removed",
|
|
63
|
+
);
|
|
64
|
+
let removed = 0;
|
|
65
|
+
for (let i = 0; i < cps.length; i++) {
|
|
66
|
+
for (let j = i + 1; j < cps.length; j++) {
|
|
67
|
+
const a = cps[i];
|
|
68
|
+
const b = cps[j];
|
|
69
|
+
if (a.dedupStatus === "removed" || b.dedupStatus === "removed") continue;
|
|
70
|
+
if (cosineSimilarity(a.embedding, b.embedding) > thr) {
|
|
71
|
+
const keep = a.tokenEstimate >= b.tokenEstimate ? a : b;
|
|
72
|
+
const drop = keep === a ? b : a;
|
|
73
|
+
setDedupStatus(drop.checkpointId, sid, "removed", stateDir);
|
|
74
|
+
drop.dedupStatus = "removed";
|
|
75
|
+
removed++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return removed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Dedup sentinel
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dedup sentinel check: has this region already been stored/represented?
|
|
88
|
+
* Consulted by both the persist path and the recall/inline path.
|
|
89
|
+
*/
|
|
90
|
+
export function vectorDedupe(
|
|
91
|
+
store: VectorStore,
|
|
92
|
+
sessionId: string,
|
|
93
|
+
regionHashOrText: string,
|
|
94
|
+
isText = false,
|
|
95
|
+
): boolean {
|
|
96
|
+
const stateDir = store.stateDir;
|
|
97
|
+
const sid = normalizeSessionId(sessionId);
|
|
98
|
+
const hash = isText
|
|
99
|
+
? computeRegionHash(regionHashOrText)
|
|
100
|
+
: regionHashOrText;
|
|
101
|
+
const state = loadSessionState(sid, stateDir);
|
|
102
|
+
if (state.storedRegionHashes.includes(hash)) return true;
|
|
103
|
+
return listCheckpoints(sid, stateDir).some(
|
|
104
|
+
(c) => c.regionHash === hash,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Injection tracking
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/** Mark a checkpoint as injected into the window (recall dedup). */
|
|
113
|
+
export function vectorMarkInjected(store: VectorStore, sessionId: string, checkpointId: string): void {
|
|
114
|
+
const stateDir = store.stateDir;
|
|
115
|
+
const sid = normalizeSessionId(sessionId);
|
|
116
|
+
const state = loadSessionState(sid, stateDir);
|
|
117
|
+
if (!state.injectedCheckpointIds.includes(checkpointId)) {
|
|
118
|
+
state.injectedCheckpointIds.push(checkpointId);
|
|
119
|
+
saveSessionState(sid, state, stateDir);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True if this checkpoint was already injected this session. */
|
|
124
|
+
export function vectorWasInjected(store: VectorStore, sessionId: string, checkpointId: string): boolean {
|
|
125
|
+
const stateDir = store.stateDir;
|
|
126
|
+
const state: SessionState = loadSessionState(
|
|
127
|
+
normalizeSessionId(sessionId),
|
|
128
|
+
stateDir,
|
|
129
|
+
);
|
|
130
|
+
return state.injectedCheckpointIds.includes(checkpointId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// List & TopSimilar
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** All checkpoints for a session (sorted by checkpointId). */
|
|
138
|
+
export function vectorList(store: VectorStore, sessionId: string): StoredCheckpoint[] {
|
|
139
|
+
const stateDir = store.stateDir;
|
|
140
|
+
return listCheckpoints(normalizeSessionId(sessionId), stateDir);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Return the n most similar checkpoints to the current (most recent) checkpoint
|
|
145
|
+
* by cosine similarity. Returns fewer than n if the session has fewer checkpoints.
|
|
146
|
+
* The current checkpoint itself is excluded from results.
|
|
147
|
+
*/
|
|
148
|
+
export function vectorTopSimilar(store: VectorStore, sessionId: string, n: number): SearchHit[] {
|
|
149
|
+
const stateDir = store.stateDir;
|
|
150
|
+
const sid = normalizeSessionId(sessionId);
|
|
151
|
+
const checkpoints = listCheckpoints(sid, stateDir);
|
|
152
|
+
if (checkpoints.length <= 1) return [];
|
|
153
|
+
|
|
154
|
+
const ordered = [...checkpoints].sort((a, b) =>
|
|
155
|
+
a.checkpointId.localeCompare(b.checkpointId),
|
|
156
|
+
);
|
|
157
|
+
const current = ordered[ordered.length - 1];
|
|
158
|
+
|
|
159
|
+
const scored: SearchHit[] = ordered
|
|
160
|
+
.filter((cp) => cp.checkpointId !== current.checkpointId)
|
|
161
|
+
.map((cp) => ({
|
|
162
|
+
checkpoint: cp,
|
|
163
|
+
score: cosineSimilarity(current.embedding, cp.embedding),
|
|
164
|
+
}))
|
|
165
|
+
.sort((a, b) => b.score - a.score);
|
|
166
|
+
|
|
167
|
+
return scored.slice(0, n);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Stats
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export interface VectorStats {
|
|
175
|
+
checkpointCount: number;
|
|
176
|
+
totalTokenEstimate: number;
|
|
177
|
+
lastCheckpointId: string | undefined;
|
|
178
|
+
lastSummary: string | undefined;
|
|
179
|
+
injectedCount: number;
|
|
180
|
+
dedupHitRate: number;
|
|
181
|
+
storageDedupRate: number;
|
|
182
|
+
tokensSaved: number;
|
|
183
|
+
originalTokens: number;
|
|
184
|
+
dedupAttempts: number;
|
|
185
|
+
dedupCollapsed: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Store statistics for status reporting / logging. Returns counts + the last
|
|
190
|
+
* (highest-numbered) checkpoint, or nulls when the session is empty.
|
|
191
|
+
*/
|
|
192
|
+
export function vectorStats(store: VectorStore, sessionId: string): VectorStats {
|
|
193
|
+
const stateDir = store.stateDir;
|
|
194
|
+
const sid = normalizeSessionId(sessionId);
|
|
195
|
+
const cps = listCheckpoints(sid, stateDir);
|
|
196
|
+
const state = loadSessionState(sid, stateDir);
|
|
197
|
+
const ordered = [...cps].sort((a, b) =>
|
|
198
|
+
a.checkpointId.localeCompare(b.checkpointId),
|
|
199
|
+
);
|
|
200
|
+
const last = ordered[ordered.length - 1];
|
|
201
|
+
const injected = state.injectedCheckpointIds.length;
|
|
202
|
+
const ds = getDedupStats(stateDir);
|
|
203
|
+
const sessionTok = cps.reduce((s, c) => s + (c.tokenEstimate ?? 0), 0);
|
|
204
|
+
const sessionOrig = cps.reduce((s, c) => s + (c.originalTokenEstimate ?? 0), 0);
|
|
205
|
+
const sessionSaved = cps.reduce(
|
|
206
|
+
(s, c) => s + Math.max(0, (c.originalTokenEstimate ?? 0) - (c.tokenEstimate ?? 0)),
|
|
207
|
+
0,
|
|
208
|
+
);
|
|
209
|
+
return {
|
|
210
|
+
checkpointCount: cps.length,
|
|
211
|
+
totalTokenEstimate: sessionTok,
|
|
212
|
+
lastCheckpointId: last?.checkpointId,
|
|
213
|
+
lastSummary: last?.summary,
|
|
214
|
+
injectedCount: injected,
|
|
215
|
+
dedupHitRate: cps.length === 0 ? 0 : injected / cps.length,
|
|
216
|
+
storageDedupRate: ds.attempts === 0 ? 0 : ds.deduped / ds.attempts,
|
|
217
|
+
tokensSaved: sessionSaved,
|
|
218
|
+
originalTokens: sessionOrig,
|
|
219
|
+
dedupAttempts: ds.attempts,
|
|
220
|
+
dedupCollapsed: ds.deduped,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Repo-wide stats — aggregates every session in this store (one per repo).
|
|
226
|
+
* Cumulative, resumable, cross-device.
|
|
227
|
+
*/
|
|
228
|
+
export function vectorRepoStats(store: VectorStore): ReturnType<typeof repoStatsFromStore> {
|
|
229
|
+
const stateDir = store.stateDir;
|
|
230
|
+
return repoStatsFromStore(stateDir);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Data-safety invariant: regions retained vs bytes permanently deleted. */
|
|
234
|
+
export function vectorDataInvariant(store: VectorStore): ReturnType<typeof dataInvariantStats> {
|
|
235
|
+
const stateDir = store.stateDir;
|
|
236
|
+
return dataInvariantStats(stateDir);
|
|
237
|
+
}
|