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
package/src/sprint14.test.ts
CHANGED
|
@@ -8,7 +8,7 @@ import assert from "node:assert/strict";
|
|
|
8
8
|
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { VectorStore } from "./vectorStore.js";
|
|
11
|
+
import { VectorStore, vectorSearch } from "./vectorStore.js";
|
|
12
12
|
import { defaultEmbedder } from "./embedder.js";
|
|
13
13
|
import { loadDedupConfig, type DedupConfigShape } from "./config/dedup.js";
|
|
14
14
|
import {
|
|
@@ -49,7 +49,7 @@ test("flag matrix: all 16 L0/L1/L2/RAPTOR enable combos are safe", () => {
|
|
|
49
49
|
s.add({ sessionId: "s", summary: "x", regionText: `region A for combo ${combos} about the cache`, timestamp: 1 });
|
|
50
50
|
const r2 = s.add({ sessionId: "s", summary: "x", regionText: `region B for combo ${combos} about the parser`, timestamp: 2 });
|
|
51
51
|
// search must not throw under any combination
|
|
52
|
-
const hits = s
|
|
52
|
+
const hits = vectorSearch(s, "s", "cache", 3);
|
|
53
53
|
assert.ok(Array.isArray(hits));
|
|
54
54
|
// With all tiers off, the second add is always "new" (no collapse).
|
|
55
55
|
if (!l0 && !l1 && !l2) assert.equal(r2.deduped, false);
|
|
@@ -16,7 +16,7 @@ import assert from "node:assert/strict";
|
|
|
16
16
|
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
17
17
|
import { tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
|
-
import { VectorStore } from "../vectorStore.js";
|
|
19
|
+
import { VectorStore, vectorMarkInjected, vectorWasInjected, vectorSearch } from "../vectorStore.js";
|
|
20
20
|
import { writeGzJson } from "../store.js";
|
|
21
21
|
import type { StoredCheckpoint } from "../store.js";
|
|
22
22
|
import { migrateJsonToSqlite, readLegacyCheckpointFile } from "../store/migrate.js";
|
|
@@ -123,7 +123,7 @@ test("cross-process recall: fresh VectorStore over same dir recalls prior checkp
|
|
|
123
123
|
|
|
124
124
|
// Process B: brand-new VectorStore, same stateDir.
|
|
125
125
|
const b = new VectorStore({ stateDir: dir });
|
|
126
|
-
const hits = b
|
|
126
|
+
const hits = vectorSearch(b, sid, "cross process recall proof", 5);
|
|
127
127
|
assert.equal(hits.length, 1, "checkpoint survives cross-process reopen");
|
|
128
128
|
assert.equal(hits[0].checkpoint.checkpointId, "chkpt_001");
|
|
129
129
|
assert.ok(hits[0].score > 0.5, "recall is relevant");
|
|
@@ -142,13 +142,13 @@ test("cross-process recall: injected state persists across reopen", () => {
|
|
|
142
142
|
regionText: "injected state persists across process reopen test",
|
|
143
143
|
timestamp: 1,
|
|
144
144
|
});
|
|
145
|
-
a
|
|
146
|
-
assert.equal(a
|
|
145
|
+
vectorMarkInjected(a, sid, added.checkpoint.checkpointId);
|
|
146
|
+
assert.equal(vectorWasInjected(a, sid, added.checkpoint.checkpointId), true);
|
|
147
147
|
|
|
148
148
|
closeStore(dir);
|
|
149
149
|
|
|
150
150
|
const b = new VectorStore({ stateDir: dir });
|
|
151
|
-
assert.equal(b
|
|
151
|
+
assert.equal(vectorWasInjected(b, sid, added.checkpoint.checkpointId), true, "injection remembered");
|
|
152
152
|
|
|
153
153
|
closeStore(dir);
|
|
154
154
|
});
|
|
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { VectorStore } from "../vectorStore.js";
|
|
6
|
+
import { VectorStore, vectorList, vectorRepoStats } from "../vectorStore.js";
|
|
7
7
|
import { normalize } from "../dedup/normalize.js";
|
|
8
8
|
import { openBloom, closeBloom } from "./bloom.js";
|
|
9
9
|
import { backfillContentHashes, isBackfillComplete } from "./backfill.js";
|
|
@@ -29,7 +29,7 @@ test("Sprint 10 L0: case/whitespace/ANSI variants dedup to one row", () => {
|
|
|
29
29
|
const r = s.add({ sessionId: "sess_norm", summary: "x", regionText: v, timestamp: added + 1 });
|
|
30
30
|
if (!r.deduped) added++;
|
|
31
31
|
}
|
|
32
|
-
assert.equal(s
|
|
32
|
+
assert.equal(vectorList(s, "sess_norm").length, 1);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
test("normalize case-folds so Foo/foo/FOO are equal", () => {
|
|
@@ -74,7 +74,7 @@ test("duplicate add is idempotent (no partial rows, single checkpoint)", () => {
|
|
|
74
74
|
const b = s.add({ sessionId: "sess_atom", summary: "x", regionText: raw, timestamp: 2 });
|
|
75
75
|
assert.equal(a.deduped, false);
|
|
76
76
|
assert.equal(b.deduped, true);
|
|
77
|
-
assert.equal(s
|
|
77
|
+
assert.equal(vectorList(s, "sess_atom").length, 1);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
// --- Backfill orchestrator -------------------------------------------------
|
|
@@ -196,14 +196,14 @@ test("Sprint 10 migration: pre-0.4.2 db gains original_token_estimate and repoSt
|
|
|
196
196
|
originalTokenEstimate: 500,
|
|
197
197
|
timestamp: 1,
|
|
198
198
|
});
|
|
199
|
-
const repo = vs
|
|
199
|
+
const repo = vectorRepoStats(vs);
|
|
200
200
|
// No "no such column" crash = migration succeeded; totals reflect the new col.
|
|
201
201
|
assert.equal(repo.checkpointCount, 1);
|
|
202
202
|
assert.equal(repo.originalTokens, 500, "originalTokens read from migrated column");
|
|
203
203
|
// Re-open a second time to prove idempotency (column already exists).
|
|
204
204
|
closeStore(dir);
|
|
205
205
|
const vs2 = new VectorStore({ dedupSim: 0.9, stateDir: dir });
|
|
206
|
-
assert.equal(vs2
|
|
206
|
+
assert.equal(vectorRepoStats(vs2).originalTokens, 500, "stable across re-open");
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
// --- cleanup ---------------------------------------------------------------
|
|
@@ -12,10 +12,27 @@
|
|
|
12
12
|
* infrequent idempotent upserts (ON CONFLICT). Fully local (PREVENT-PI-004).
|
|
13
13
|
*/
|
|
14
14
|
import { DatabaseSync } from "node:sqlite";
|
|
15
|
-
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
15
|
import { homedir, tmpdir } from "node:os";
|
|
16
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
19
|
+
// Back-compat re-exports: session heartbeat + timeseries helpers moved to
|
|
20
|
+
// global-sessions.ts (file-size split). Existing imports from this module
|
|
21
|
+
// keep resolving; behavior is unchanged.
|
|
22
|
+
export {
|
|
23
|
+
recordSessionHeartbeat,
|
|
24
|
+
appendTokenSample,
|
|
25
|
+
pruneStaleSessions,
|
|
26
|
+
pruneTokenSamples,
|
|
27
|
+
readActiveSessions,
|
|
28
|
+
readSessionTimeseries,
|
|
29
|
+
clearSessionHeartbeat,
|
|
30
|
+
type ActiveSessionRow,
|
|
31
|
+
type TokenSamplePoint,
|
|
32
|
+
type SessionSeries,
|
|
33
|
+
type SessionTimeseriesResult,
|
|
34
|
+
} from "./global-sessions.js";
|
|
35
|
+
|
|
19
36
|
/** Resolve the machine-wide index directory (env-overridable). */
|
|
20
37
|
export function getIndexDir(): string {
|
|
21
38
|
const override = process.env.MEGACOMPACT_INDEX_DIR;
|
|
@@ -329,292 +346,3 @@ export function countInjectedGlobal(indexDir: string = getIndexDir()): number {
|
|
|
329
346
|
return row?.n ?? 0;
|
|
330
347
|
}
|
|
331
348
|
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
// S39: session heartbeats + token time-series (multi-pi stacked memory graph)
|
|
334
|
-
//
|
|
335
|
-
// `session_heartbeats`: one row per (pid, session_id), upserted on every
|
|
336
|
-
// material snapshot() change. This is the reliable liveness signal the
|
|
337
|
-
// dashboard /api/sessions endpoint reads (repo_registry.last_seen only updates
|
|
338
|
-
// on repo-switch / bindRepo, so a long-running single-repo process looks
|
|
339
|
-
// inactive; these heartbeats update on every material change).
|
|
340
|
-
//
|
|
341
|
-
// `token_samples`: append-only rows with (session_id, tokens, percent, ts) for
|
|
342
|
-
// the stacked-memory graph. Garbage-collected by pruneTokenSamples.
|
|
343
|
-
//
|
|
344
|
-
// All queries use @named/$named bind parameters (PREVENT-002); local node:sqlite
|
|
345
|
-
// + WAL (PREVENT-PI-004), multi-process safe.
|
|
346
|
-
// ---------------------------------------------------------------------------
|
|
347
|
-
|
|
348
|
-
/** A live active session row (joined heartbeat + latest token sample). */
|
|
349
|
-
export interface ActiveSessionRow {
|
|
350
|
-
pid: number;
|
|
351
|
-
sessionId: string;
|
|
352
|
-
repoRoot: string | null;
|
|
353
|
-
stateDir: string | null;
|
|
354
|
-
ctxWindow: number;
|
|
355
|
-
lastSeen: number;
|
|
356
|
-
tokens: number | null;
|
|
357
|
-
percent: number | null;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** A single time-series data point for a session. */
|
|
361
|
-
export interface TokenSamplePoint {
|
|
362
|
-
ts: number;
|
|
363
|
-
tokens: number;
|
|
364
|
-
percent: number;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/** A recharts-ready per-session series with a stable color. */
|
|
368
|
-
export interface SessionSeries {
|
|
369
|
-
sessionId: string;
|
|
370
|
-
label: string;
|
|
371
|
-
color: string;
|
|
372
|
-
data: TokenSamplePoint[];
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Timeseries response shape (recharts-ready). */
|
|
376
|
-
export interface SessionTimeseriesResult {
|
|
377
|
-
series: SessionSeries[];
|
|
378
|
-
totals: { ts: number; tokens: number }[];
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** Stable color palette for per-session series (hash-based, no randomness). */
|
|
382
|
-
const SESSION_COLORS = [
|
|
383
|
-
"#60a5fa", // blue-400
|
|
384
|
-
"#34d399", // emerald-400
|
|
385
|
-
"#fbbf24", // amber-400
|
|
386
|
-
"#f87171", // red-400
|
|
387
|
-
"#a78bfa", // violet-400
|
|
388
|
-
"#f472b6", // pink-400
|
|
389
|
-
"#22d3ee", // cyan-400
|
|
390
|
-
"#a3e635", // lime-400
|
|
391
|
-
];
|
|
392
|
-
|
|
393
|
-
/** Hash a sessionId to a stable color index. */
|
|
394
|
-
function sessionColor(sessionId: string): string {
|
|
395
|
-
let h = 0;
|
|
396
|
-
for (let i = 0; i < sessionId.length; i++) {
|
|
397
|
-
h = (h * 31 + sessionId.charCodeAt(i)) | 0;
|
|
398
|
-
}
|
|
399
|
-
return SESSION_COLORS[Math.abs(h) % SESSION_COLORS.length];
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Record (upsert) a session heartbeat. Called from snapshot() on every material
|
|
404
|
-
* change. PRIMARY KEY (pid, session_id) means concurrent pi processes each get
|
|
405
|
-
* their own row. Non-fatal on conflict (INSERT ... ON CONFLICT DO UPDATE).
|
|
406
|
-
*/
|
|
407
|
-
export function recordSessionHeartbeat(
|
|
408
|
-
pid: number,
|
|
409
|
-
sessionId: string,
|
|
410
|
-
repoRoot: string,
|
|
411
|
-
stateDir: string,
|
|
412
|
-
ctxWindow: number,
|
|
413
|
-
indexDir: string = getIndexDir(),
|
|
414
|
-
): void {
|
|
415
|
-
const db = openIndexStore(indexDir);
|
|
416
|
-
const now = Date.now();
|
|
417
|
-
db.prepare(
|
|
418
|
-
`INSERT INTO session_heartbeats (pid, session_id, repo_root, state_dir, ctx_window, last_seen)
|
|
419
|
-
VALUES (@pid, @session_id, @repo_root, @state_dir, @ctx_window, @last_seen)
|
|
420
|
-
ON CONFLICT(pid, session_id) DO UPDATE SET
|
|
421
|
-
repo_root = excluded.repo_root,
|
|
422
|
-
state_dir = excluded.state_dir,
|
|
423
|
-
ctx_window = excluded.ctx_window,
|
|
424
|
-
last_seen = excluded.last_seen`,
|
|
425
|
-
).run({
|
|
426
|
-
pid,
|
|
427
|
-
session_id: sessionId,
|
|
428
|
-
repo_root: repoRoot,
|
|
429
|
-
state_dir: stateDir,
|
|
430
|
-
ctx_window: ctxWindow,
|
|
431
|
-
last_seen: now,
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Append a token sample row + optionally a session_sample line to events.log
|
|
437
|
-
* (for SSE real-time push via /api/events). The eventsLogPath is optional —
|
|
438
|
-
* callers without an events.log (e.g. tests) can omit it.
|
|
439
|
-
*/
|
|
440
|
-
export function appendTokenSample(
|
|
441
|
-
sessionId: string,
|
|
442
|
-
repoRoot: string,
|
|
443
|
-
tokens: number,
|
|
444
|
-
percent: number,
|
|
445
|
-
ctxWindow: number,
|
|
446
|
-
eventsLogPath: string | null,
|
|
447
|
-
indexDir: string = getIndexDir(),
|
|
448
|
-
): void {
|
|
449
|
-
const db = openIndexStore(indexDir);
|
|
450
|
-
const now = Date.now();
|
|
451
|
-
db.prepare(
|
|
452
|
-
`INSERT INTO token_samples (session_id, repo_root, tokens, percent, ctx_window, ts)
|
|
453
|
-
VALUES (@session_id, @repo_root, @tokens, @percent, @ctx_window, @ts)`,
|
|
454
|
-
).run({
|
|
455
|
-
session_id: sessionId,
|
|
456
|
-
repo_root: repoRoot,
|
|
457
|
-
tokens,
|
|
458
|
-
percent,
|
|
459
|
-
ctx_window: ctxWindow,
|
|
460
|
-
ts: now,
|
|
461
|
-
});
|
|
462
|
-
// Step 5: also append a session_sample JSON line to events.log so the
|
|
463
|
-
// existing /api/events SSE tail streams it for free (real-time chart push).
|
|
464
|
-
// Mirrors the DashboardEmitter events.log append pattern in
|
|
465
|
-
// extensions/mega-dashboard.ts:{ event(type, data) }: a JSON object with
|
|
466
|
-
// `ts` (ISO 8601 string), `type`, and the event-specific payload. The ISO
|
|
467
|
-
// timestamp matches the shape of every other SSE variant (SseSessionSample
|
|
468
|
-
// contract; every SSE variant's `ts` is a string); a numeric ms `ts` would
|
|
469
|
-
// violate the contract union's "every SSE variant has a ts field of type
|
|
470
|
-
// string" invariant and break DashboardEmitter consumers.
|
|
471
|
-
if (eventsLogPath) {
|
|
472
|
-
try {
|
|
473
|
-
const dir = eventsLogPath.includes("/") ? eventsLogPath.slice(0, eventsLogPath.lastIndexOf("/")) : ".";
|
|
474
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
475
|
-
appendFileSync(
|
|
476
|
-
eventsLogPath,
|
|
477
|
-
JSON.stringify({
|
|
478
|
-
ts: new Date(now).toISOString(),
|
|
479
|
-
type: "session_sample",
|
|
480
|
-
sessionId,
|
|
481
|
-
tokens,
|
|
482
|
-
percent,
|
|
483
|
-
}) + "\n",
|
|
484
|
-
);
|
|
485
|
-
} catch {
|
|
486
|
-
/* non-fatal: SSE push is best-effort */
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Prune stale session heartbeats (sessions not seen within maxAgeMs).
|
|
493
|
-
* Default 30-min retention. Called by /api/sessions.
|
|
494
|
-
*/
|
|
495
|
-
export function pruneStaleSessions(
|
|
496
|
-
maxAgeMs: number = 1_800_000,
|
|
497
|
-
indexDir: string = getIndexDir(),
|
|
498
|
-
): number {
|
|
499
|
-
const db = openIndexStore(indexDir);
|
|
500
|
-
const cutoff = Date.now() - maxAgeMs;
|
|
501
|
-
const result = db.prepare(
|
|
502
|
-
"DELETE FROM session_heartbeats WHERE last_seen < @cutoff",
|
|
503
|
-
).run({ cutoff });
|
|
504
|
-
return Number(result.changes);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Prune old token samples older than maxAgeMs. Default 30-min retention.
|
|
509
|
-
* Called by /api/sessions/timeseries.
|
|
510
|
-
*/
|
|
511
|
-
export function pruneTokenSamples(
|
|
512
|
-
maxAgeMs: number = 1_800_000,
|
|
513
|
-
indexDir: string = getIndexDir(),
|
|
514
|
-
): number {
|
|
515
|
-
const db = openIndexStore(indexDir);
|
|
516
|
-
const cutoff = Date.now() - maxAgeMs;
|
|
517
|
-
const result = db.prepare(
|
|
518
|
-
"DELETE FROM token_samples WHERE ts < @cutoff",
|
|
519
|
-
).run({ cutoff });
|
|
520
|
-
return Number(result.changes);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/**
|
|
524
|
-
* Read all active sessions with their latest token sample (if any).
|
|
525
|
-
* JOINs session_heartbeats with the latest token_samples per session_id
|
|
526
|
-
* via a correlated subquery. Returns rows sorted by last_seen descending.
|
|
527
|
-
*/
|
|
528
|
-
export function readActiveSessions(
|
|
529
|
-
indexDir: string = getIndexDir(),
|
|
530
|
-
): ActiveSessionRow[] {
|
|
531
|
-
const db = openIndexStore(indexDir);
|
|
532
|
-
const rows = db.prepare(
|
|
533
|
-
`SELECT h.pid, h.session_id, h.repo_root, h.state_dir, h.ctx_window, h.last_seen,
|
|
534
|
-
s.tokens, s.percent
|
|
535
|
-
FROM session_heartbeats h
|
|
536
|
-
LEFT JOIN token_samples s ON s.id = (
|
|
537
|
-
SELECT id FROM token_samples t
|
|
538
|
-
WHERE t.session_id = h.session_id
|
|
539
|
-
ORDER BY t.ts DESC LIMIT 1
|
|
540
|
-
)
|
|
541
|
-
ORDER BY h.last_seen DESC`,
|
|
542
|
-
).all() as Array<{
|
|
543
|
-
pid: number;
|
|
544
|
-
session_id: string;
|
|
545
|
-
repo_root: string | null;
|
|
546
|
-
state_dir: string | null;
|
|
547
|
-
ctx_window: number;
|
|
548
|
-
last_seen: number;
|
|
549
|
-
tokens: number | null;
|
|
550
|
-
percent: number | null;
|
|
551
|
-
}>;
|
|
552
|
-
return rows.map((r) => ({
|
|
553
|
-
pid: r.pid,
|
|
554
|
-
sessionId: r.session_id,
|
|
555
|
-
repoRoot: r.repo_root,
|
|
556
|
-
stateDir: r.state_dir,
|
|
557
|
-
ctxWindow: r.ctx_window ?? 0,
|
|
558
|
-
lastSeen: r.last_seen,
|
|
559
|
-
tokens: r.tokens,
|
|
560
|
-
percent: r.percent,
|
|
561
|
-
}));
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Read token samples since sinceMs, returning a recharts-ready stacked shape:
|
|
566
|
-
* per-session `SessionSeries` (with stable color) + a `totals` array
|
|
567
|
-
* [{ts, tokens}] (sum of all sessions at each timestamp).
|
|
568
|
-
*/
|
|
569
|
-
export function readSessionTimeseries(
|
|
570
|
-
sinceMs: number,
|
|
571
|
-
indexDir: string = getIndexDir(),
|
|
572
|
-
): SessionTimeseriesResult {
|
|
573
|
-
const db = openIndexStore(indexDir);
|
|
574
|
-
const rows = db.prepare(
|
|
575
|
-
`SELECT session_id, tokens, percent, ts FROM token_samples WHERE ts >= @since ORDER BY ts ASC`,
|
|
576
|
-
).all({ since: sinceMs }) as Array<{
|
|
577
|
-
session_id: string;
|
|
578
|
-
tokens: number;
|
|
579
|
-
percent: number;
|
|
580
|
-
ts: number;
|
|
581
|
-
}>;
|
|
582
|
-
// Group by session_id → series; + accumulate totals per timestamp.
|
|
583
|
-
const seriesMap = new Map<string, TokenSamplePoint[]>();
|
|
584
|
-
const totalsMap = new Map<number, number>();
|
|
585
|
-
for (const r of rows) {
|
|
586
|
-
let pts = seriesMap.get(r.session_id);
|
|
587
|
-
if (!pts) {
|
|
588
|
-
pts = [];
|
|
589
|
-
seriesMap.set(r.session_id, pts);
|
|
590
|
-
}
|
|
591
|
-
pts.push({ ts: r.ts, tokens: r.tokens, percent: r.percent });
|
|
592
|
-
totalsMap.set(r.ts, (totalsMap.get(r.ts) ?? 0) + r.tokens);
|
|
593
|
-
}
|
|
594
|
-
const series: SessionSeries[] = [];
|
|
595
|
-
for (const [sessionId, data] of seriesMap) {
|
|
596
|
-
const label = sessionId.length > 12 ? sessionId.slice(0, 12) : sessionId;
|
|
597
|
-
series.push({ sessionId, label, color: sessionColor(sessionId), data });
|
|
598
|
-
}
|
|
599
|
-
// Sort series by first-timestamp for stable legend order.
|
|
600
|
-
series.sort((a, b) => (a.data[0]?.ts ?? 0) - (b.data[0]?.ts ?? 0));
|
|
601
|
-
const totals = Array.from(totalsMap.entries())
|
|
602
|
-
.sort((a, b) => a[0] - b[0])
|
|
603
|
-
.map(([ts, tokens]) => ({ ts, tokens }));
|
|
604
|
-
return { series, totals };
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* Clear a session's heartbeat row (e.g. on clean shutdown / session reset).
|
|
609
|
-
* Non-fatal: no-op if the row doesn't exist.
|
|
610
|
-
*/
|
|
611
|
-
export function clearSessionHeartbeat(
|
|
612
|
-
pid: number,
|
|
613
|
-
sessionId: string,
|
|
614
|
-
indexDir: string = getIndexDir(),
|
|
615
|
-
): void {
|
|
616
|
-
const db = openIndexStore(indexDir);
|
|
617
|
-
db.prepare(
|
|
618
|
-
"DELETE FROM session_heartbeats WHERE pid = @pid AND session_id = @session_id",
|
|
619
|
-
).run({ pid, session_id: sessionId });
|
|
620
|
-
}
|