pi-context-engine 0.5.3 → 0.7.0
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 +44 -5
- package/package.json +1 -1
- package/src/config.ts +9 -0
- package/src/dashboard/api.ts +155 -0
- package/src/dashboard/page.ts +403 -0
- package/src/dashboard/server.ts +204 -0
- package/src/engine.ts +95 -6
- package/src/extension.ts +107 -1
- package/src/guidance.ts +1 -0
- package/src/indexer/pipeline.ts +25 -11
- package/src/memory/db.ts +162 -0
package/src/engine.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { SessionCapture } from "./memory/session-capture.js";
|
|
|
20
20
|
import { drainOne, enqueueSessionRollup, enqueueTurn, type CompressionDeps } from "./memory/compressor.js";
|
|
21
21
|
import { compress, expand, compressWithCounts, DEFAULT_LEVEL } from "./memory/grammar.js";
|
|
22
22
|
import { cosine } from "./memory/extractive.js";
|
|
23
|
-
import { AutomaticIndexFileLimitError, runIndexing } from "./indexer/pipeline.js";
|
|
23
|
+
import { AutomaticIndexFileLimitError, runIndexing, withPipelineLock } from "./indexer/pipeline.js";
|
|
24
24
|
import { getModelPricing } from "./pricing.js";
|
|
25
25
|
import type { Chunk } from "./types.js";
|
|
26
26
|
|
|
@@ -125,8 +125,10 @@ export interface SessionStats {
|
|
|
125
125
|
export interface EngineOptions {
|
|
126
126
|
projectDir: string;
|
|
127
127
|
config?: Config;
|
|
128
|
-
/** Test seam: reuse an existing backend/embedder/reranker instead of
|
|
129
|
-
|
|
128
|
+
/** Test seam: reuse an existing backend/embedder/reranker instead of
|
|
129
|
+
* auto-detecting. `backend` may be nulled by resetIndex — the pipeline
|
|
130
|
+
* then constructs a fresh one. */
|
|
131
|
+
runtime?: { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker };
|
|
130
132
|
}
|
|
131
133
|
|
|
132
134
|
/** Issue #11 (upstream warmup): what /context-warmup resolved + cached. */
|
|
@@ -161,8 +163,8 @@ export class ContextEngine {
|
|
|
161
163
|
readonly projectName: string;
|
|
162
164
|
readonly storageBase: string;
|
|
163
165
|
config: Config;
|
|
164
|
-
private
|
|
165
|
-
| { backend
|
|
166
|
+
private injectedRuntime:
|
|
167
|
+
| { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker }
|
|
166
168
|
| undefined;
|
|
167
169
|
private backendInstance: LocalBackend | null = null;
|
|
168
170
|
private embedderInstance: Embedder | null = null;
|
|
@@ -188,7 +190,7 @@ export class ContextEngine {
|
|
|
188
190
|
this.config = options.config ?? loadConfigForProject(this.projectDir);
|
|
189
191
|
this.injectedRuntime = options.runtime;
|
|
190
192
|
if (options.runtime) {
|
|
191
|
-
this.backendInstance = options.runtime.backend;
|
|
193
|
+
this.backendInstance = options.runtime.backend ?? null;
|
|
192
194
|
this.embedderInstance = options.runtime.embedder;
|
|
193
195
|
}
|
|
194
196
|
this.storageBase = projectStorageDir(this.config.storagePath, this.projectDir);
|
|
@@ -358,6 +360,93 @@ export class ContextEngine {
|
|
|
358
360
|
return this.backend.countChunks();
|
|
359
361
|
}
|
|
360
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Delete this project's index (manifest, vector/FTS/graph stores, embedding
|
|
365
|
+
* cache) while keeping memory, sessions and the savings ledger — those are
|
|
366
|
+
* user history, not index state. Handles are closed BEFORE deletion:
|
|
367
|
+
* Windows refuses to delete files with open sqlite handles, which is why
|
|
368
|
+
* manually deleting the storage dir fails while a session is running.
|
|
369
|
+
* Queues behind in-flight and queued pipeline runs, and holds the same lock
|
|
370
|
+
* during deletion so a later reindex cannot race the reset.
|
|
371
|
+
* Errors are reported per target (e.g. another session holding a handle)
|
|
372
|
+
* instead of failing the whole reset.
|
|
373
|
+
*/
|
|
374
|
+
async resetIndex(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
|
|
375
|
+
return withPipelineLock(this.storageBase, () => this.resetIndexLocked());
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private async resetIndexLocked(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
|
|
379
|
+
try {
|
|
380
|
+
this.backendInstance?.close();
|
|
381
|
+
} catch {
|
|
382
|
+
// already closed
|
|
383
|
+
}
|
|
384
|
+
this.backendInstance = null;
|
|
385
|
+
this.retrieverInstance = null;
|
|
386
|
+
// Mutate the runtime object rather than replacing it: reindex calls that
|
|
387
|
+
// were queued behind this reset captured the same object and must see the
|
|
388
|
+
// closed backend dropped before they execute. Embedder and reranker
|
|
389
|
+
// survive: they hold no files.
|
|
390
|
+
if (this.injectedRuntime) this.injectedRuntime.backend = null;
|
|
391
|
+
// A reset index may be rebuilt by the next automatic kick.
|
|
392
|
+
this.autoIndexDone = false;
|
|
393
|
+
let deletedFiles = 0;
|
|
394
|
+
let freedBytes = 0;
|
|
395
|
+
const errors: string[] = [];
|
|
396
|
+
const countAndDelete = (target: string): void => {
|
|
397
|
+
let stat: fs.Stats;
|
|
398
|
+
try {
|
|
399
|
+
stat = fs.statSync(target);
|
|
400
|
+
} catch {
|
|
401
|
+
return; // not there — nothing to free
|
|
402
|
+
}
|
|
403
|
+
let files = 0;
|
|
404
|
+
let bytes = 0;
|
|
405
|
+
const measure = (p: string, s: fs.Stats): void => {
|
|
406
|
+
if (s.isDirectory()) {
|
|
407
|
+
for (const entry of fs.readdirSync(p)) {
|
|
408
|
+
const child = path.join(p, entry);
|
|
409
|
+
try {
|
|
410
|
+
measure(child, fs.statSync(child));
|
|
411
|
+
} catch {
|
|
412
|
+
// raced away between readdir and stat
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
files++;
|
|
418
|
+
bytes += s.size;
|
|
419
|
+
};
|
|
420
|
+
try {
|
|
421
|
+
measure(target, stat);
|
|
422
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
423
|
+
// Only report bytes actually released; a locked target must not
|
|
424
|
+
// inflate the success report when rmSync fails.
|
|
425
|
+
deletedFiles += files;
|
|
426
|
+
freedBytes += bytes;
|
|
427
|
+
} catch (err) {
|
|
428
|
+
// Locked file (another session?) — report instead of failing the reset.
|
|
429
|
+
errors.push(`${path.relative(this.storageBase, target) || path.basename(target)}: ${(err as Error).message.slice(0, 80)}`);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const graphDb = path.join(this.storageBase, "graph.db");
|
|
433
|
+
const embeddingCache = path.join(this.storageBase, "embedding_cache.db");
|
|
434
|
+
for (const target of [
|
|
435
|
+
path.join(this.storageBase, "manifest.json"),
|
|
436
|
+
path.join(this.storageBase, "vectors"),
|
|
437
|
+
path.join(this.storageBase, "fts"),
|
|
438
|
+
graphDb,
|
|
439
|
+
`${graphDb}-shm`,
|
|
440
|
+
`${graphDb}-wal`,
|
|
441
|
+
embeddingCache,
|
|
442
|
+
`${embeddingCache}-shm`,
|
|
443
|
+
`${embeddingCache}-wal`,
|
|
444
|
+
]) {
|
|
445
|
+
countAndDelete(target);
|
|
446
|
+
}
|
|
447
|
+
return { deletedFiles, freedBytes, errors };
|
|
448
|
+
}
|
|
449
|
+
|
|
361
450
|
async reindex(options: { full?: boolean; targetPath?: string | null; automatic?: boolean; onProgress?: (msg: string) => void } = {}): Promise<{
|
|
362
451
|
indexed: number;
|
|
363
452
|
chunks: number;
|
package/src/extension.ts
CHANGED
|
@@ -10,10 +10,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
10
10
|
import { Type } from "typebox";
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
13
14
|
import { ContextEngine, isPceConfig } from "./engine.js";
|
|
14
15
|
import { applyContextSearchGuidance } from "./guidance.js";
|
|
15
16
|
import { ProjectWatcher } from "./indexer/watcher.js";
|
|
16
17
|
import { AutomaticIndexFileLimitError } from "./indexer/pipeline.js";
|
|
18
|
+
import { DashboardServer } from "./dashboard/server.js";
|
|
17
19
|
|
|
18
20
|
/** Tools whose payloads are worth capturing for session timelines. */
|
|
19
21
|
const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
|
|
@@ -66,6 +68,14 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
66
68
|
let watcherKickPending = false;
|
|
67
69
|
let watcherNotify: ((msg: string, level: "info" | "warning") => void) | undefined;
|
|
68
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Read-only web dashboard (config `dashboard.port`, 0 = free port). Owned
|
|
73
|
+
* by this closure like the watcher: it must outlive individual command
|
|
74
|
+
* invocations, reference the LIVE engine on every request (engine
|
|
75
|
+
* instances are replaced per session), and stop on session_shutdown.
|
|
76
|
+
*/
|
|
77
|
+
let dashboard: DashboardServer | null = null;
|
|
78
|
+
|
|
69
79
|
/**
|
|
70
80
|
* Watcher-triggered incremental reindex. Unlike `kickBackgroundIndex` this
|
|
71
81
|
* calls `reindex({ automatic: true })` directly — the watcher must re-fire
|
|
@@ -245,6 +255,20 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
245
255
|
return engine;
|
|
246
256
|
};
|
|
247
257
|
|
|
258
|
+
/** Human-readable byte count for the reset report (explicit user action —
|
|
259
|
+
* the automatic guard never measures bytes by design). */
|
|
260
|
+
const formatBytes = (n: number): string => {
|
|
261
|
+
if (n < 1024) return `${n} B`;
|
|
262
|
+
const units = ["KiB", "MiB", "GiB"];
|
|
263
|
+
let v = n / 1024;
|
|
264
|
+
let u = 0;
|
|
265
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
266
|
+
v /= 1024;
|
|
267
|
+
u++;
|
|
268
|
+
}
|
|
269
|
+
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[u]}`;
|
|
270
|
+
};
|
|
271
|
+
|
|
248
272
|
const text = (body: string, details?: unknown) => ({
|
|
249
273
|
content: [{ type: "text" as const, text: body }],
|
|
250
274
|
details: details ?? {},
|
|
@@ -495,9 +519,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
495
519
|
/* ── Commands ────────────────────────────────────────────────────────── */
|
|
496
520
|
|
|
497
521
|
pi.registerCommand("context-index", {
|
|
498
|
-
description: "Build or refresh the semantic code index: /context-index [--full]",
|
|
522
|
+
description: "Build or refresh the semantic code index: /context-index [--full] [--reset]",
|
|
499
523
|
handler: async (args, ctx) => {
|
|
500
524
|
const eng = getEngine(ctx.cwd);
|
|
525
|
+
// --reset: delete the index through the engine (handles closed first, so
|
|
526
|
+
// it works while THIS session runs — manual deletion hits locked-file
|
|
527
|
+
// errors). Memory, sessions and savings survive. No auto-rebuild: the
|
|
528
|
+
// next search or /context-index decides, so an oversized workspace is
|
|
529
|
+
// not ambushed with a multi-minute build it just escaped.
|
|
530
|
+
if (args.includes("--reset")) {
|
|
531
|
+
if (indexInFlight) {
|
|
532
|
+
ctx.ui.notify(
|
|
533
|
+
"Context index: indexing is in progress — run /context-index --reset again once it finishes.",
|
|
534
|
+
"warning",
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// Reset also cancels the delayed startup kick. Otherwise its timer
|
|
539
|
+
// would rebuild the index immediately after the explicit delete.
|
|
540
|
+
if (autoIndexTimer) {
|
|
541
|
+
clearTimeout(autoIndexTimer);
|
|
542
|
+
autoIndexTimer = null;
|
|
543
|
+
}
|
|
544
|
+
const report = await eng.resetIndex();
|
|
545
|
+
// Free the lazy kick so the next search can rebuild (guarded by the
|
|
546
|
+
// same file budget as every automatic run).
|
|
547
|
+
lazyIndexStarted = false;
|
|
548
|
+
if (report.errors.length > 0) {
|
|
549
|
+
ctx.ui.notify(
|
|
550
|
+
`⚠ Index reset incomplete: ${report.errors.length} target(s) locked (${report.errors[0]}). Close other sessions on this project and run /context-index --reset again.`,
|
|
551
|
+
"warning",
|
|
552
|
+
);
|
|
553
|
+
} else {
|
|
554
|
+
ctx.ui.notify(
|
|
555
|
+
`✓ Index deleted: ${report.deletedFiles} file(s), ${formatBytes(report.freedBytes)} freed — memory, sessions and savings kept. ` +
|
|
556
|
+
"Run /context-index to build a fresh index.",
|
|
557
|
+
"info",
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
501
562
|
const full = args.includes("--full");
|
|
502
563
|
ctx.ui.notify(full ? "Re-indexing everything…" : "Indexing (incremental)…", "info");
|
|
503
564
|
const result = await eng.reindex({ full });
|
|
@@ -553,6 +614,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
553
614
|
},
|
|
554
615
|
});
|
|
555
616
|
|
|
617
|
+
pi.registerCommand("context-dashboard", {
|
|
618
|
+
description: "Open the read-only web dashboard (index health, files, sessions, memory, savings) in your browser",
|
|
619
|
+
handler: async (_args, ctx) => {
|
|
620
|
+
const openBrowser = (url: string): void => {
|
|
621
|
+
const cmd = process.platform === "win32" ? "cmd.exe" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
622
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
623
|
+
try {
|
|
624
|
+
spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
|
|
625
|
+
} catch {
|
|
626
|
+
// best-effort: the URL is printed either way
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
let url = dashboard?.url ?? null;
|
|
630
|
+
if (!dashboard || !url) {
|
|
631
|
+
const eng = getEngine(ctx.cwd);
|
|
632
|
+
dashboard = new DashboardServer();
|
|
633
|
+
try {
|
|
634
|
+
url = await dashboard.start({
|
|
635
|
+
engine: () => engine,
|
|
636
|
+
indexing: () => indexInFlight,
|
|
637
|
+
port: eng.config.dashboardPort,
|
|
638
|
+
});
|
|
639
|
+
} catch (err) {
|
|
640
|
+
dashboard = null;
|
|
641
|
+
ctx.ui.notify(
|
|
642
|
+
`⚠ Dashboard failed to start: ${(err as Error).message}. ` +
|
|
643
|
+
`If dashboard.port is set, another process may be using it.`,
|
|
644
|
+
"warning",
|
|
645
|
+
);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
openBrowser(url);
|
|
650
|
+
ctx.ui.notify(
|
|
651
|
+
`✓ Dashboard: ${url} (read-only, localhost only — stops when this session ends)`,
|
|
652
|
+
"info",
|
|
653
|
+
);
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
|
|
556
657
|
/* ── Lifecycle hooks (replace upstream's curl-based hooks) ───────────── */
|
|
557
658
|
|
|
558
659
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -654,6 +755,11 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
|
|
|
654
755
|
stopWatcher();
|
|
655
756
|
watcherNotify = undefined;
|
|
656
757
|
automaticIndexBlock = null;
|
|
758
|
+
if (dashboard) {
|
|
759
|
+
const d = dashboard;
|
|
760
|
+
dashboard = null;
|
|
761
|
+
await d.stop().catch(() => undefined);
|
|
762
|
+
}
|
|
657
763
|
await engine?.shutdown().catch(() => undefined);
|
|
658
764
|
engine = null;
|
|
659
765
|
});
|
package/src/guidance.ts
CHANGED
|
@@ -100,6 +100,7 @@ If the first search misses:
|
|
|
100
100
|
- If a search says the index is empty, a background index is already running - retry shortly, or run \`/context-index\` for a synchronous build
|
|
101
101
|
- After large refactors, \`reindex\` (or \`/context-index\`) keeps results current; \`index_status\` reports freshness and measured token savings
|
|
102
102
|
- Incremental re-index only re-reads changed files, so it is cheap
|
|
103
|
+
- To wipe the index (stale or corrupted results), \`/context-index --reset\` deletes it safely mid-session - memory and sessions survive; the next search rebuilds
|
|
103
104
|
`;
|
|
104
105
|
|
|
105
106
|
/**
|
package/src/indexer/pipeline.ts
CHANGED
|
@@ -407,6 +407,22 @@ export async function indexCommits(
|
|
|
407
407
|
*/
|
|
408
408
|
const pipelineLocks = new Map<string, Promise<unknown>>();
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Queue work behind every indexing run already registered for this storage
|
|
412
|
+
* dir, and keep later runs queued until the work finishes. Reset uses this
|
|
413
|
+
* instead of a one-shot idle wait: a reindex queued during that wait must not
|
|
414
|
+
* open the stores while deletion is in progress.
|
|
415
|
+
*/
|
|
416
|
+
export async function withPipelineLock<T>(storageBase: string, work: () => T | Promise<T>): Promise<T> {
|
|
417
|
+
const previous = pipelineLocks.get(storageBase) ?? Promise.resolve();
|
|
418
|
+
const current = previous.then(() => work());
|
|
419
|
+
pipelineLocks.set(
|
|
420
|
+
storageBase,
|
|
421
|
+
current.catch(() => undefined),
|
|
422
|
+
);
|
|
423
|
+
return current;
|
|
424
|
+
}
|
|
425
|
+
|
|
410
426
|
export async function runIndexing(
|
|
411
427
|
config: Config,
|
|
412
428
|
projectDirInput: string,
|
|
@@ -416,23 +432,18 @@ export async function runIndexing(
|
|
|
416
432
|
/** Automatic full-project runs only. Stop before migrations when the workspace exceeds this budget. */
|
|
417
433
|
maxAutoFiles?: number;
|
|
418
434
|
callbacks?: IndexCallbacks;
|
|
419
|
-
/** Reuse an already-constructed backend/embedder (the long-lived server path)
|
|
420
|
-
|
|
435
|
+
/** Reuse an already-constructed backend/embedder (the long-lived server path);
|
|
436
|
+
* a null backend falls through to a fresh LocalBackend (used by resetIndex). */
|
|
437
|
+
runtime?: { backend?: LocalBackend | null; embedder: Embedder };
|
|
421
438
|
} = {},
|
|
422
439
|
): Promise<IndexResult> {
|
|
423
440
|
const projectDir = fs.realpathSync(path.resolve(projectDirInput));
|
|
424
441
|
const storageBase = projectStorageDir(config.storagePath, projectDir);
|
|
425
442
|
fs.mkdirSync(storageBase, { recursive: true });
|
|
426
443
|
|
|
427
|
-
|
|
428
|
-
const current = previous.then(() =>
|
|
444
|
+
return withPipelineLock(storageBase, () =>
|
|
429
445
|
runIndexingLocked(config, projectDir, storageBase, options, options.runtime),
|
|
430
446
|
);
|
|
431
|
-
pipelineLocks.set(
|
|
432
|
-
storageBase,
|
|
433
|
-
current.catch(() => undefined),
|
|
434
|
-
);
|
|
435
|
-
return current;
|
|
436
447
|
}
|
|
437
448
|
|
|
438
449
|
async function runIndexingLocked(
|
|
@@ -440,13 +451,16 @@ async function runIndexingLocked(
|
|
|
440
451
|
projectDir: string,
|
|
441
452
|
storageBase: string,
|
|
442
453
|
options: { full?: boolean; targetPath?: string | null; maxAutoFiles?: number; callbacks?: IndexCallbacks },
|
|
443
|
-
runtime?: { backend
|
|
454
|
+
runtime?: { backend?: LocalBackend | null; embedder: Embedder },
|
|
444
455
|
): Promise<IndexResult> {
|
|
445
456
|
let { full = false, targetPath = null } = options;
|
|
446
457
|
const { callbacks = {} } = options;
|
|
447
458
|
const { log, progress, phase } = callbacks;
|
|
448
459
|
|
|
449
|
-
|
|
460
|
+
// The caller only owns the backend it actually passed in; a null backend
|
|
461
|
+
// (resetIndex dropped its closed handle) means the pipeline built its own
|
|
462
|
+
// and must close it — otherwise its sqlite handles leak until exit.
|
|
463
|
+
const ownsBackend = !runtime?.backend;
|
|
450
464
|
const chunker = new Chunker();
|
|
451
465
|
const manifest = new Manifest(path.join(storageBase, "manifest.json"));
|
|
452
466
|
const result: IndexResult = {
|
package/src/memory/db.ts
CHANGED
|
@@ -571,6 +571,168 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
|
|
|
571
571
|
return out;
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
// ── Read-only views (dashboard) ───────────────────────────────────────
|
|
575
|
+
// These never write and never touch the savings ledger — they exist so the
|
|
576
|
+
// web dashboard can render history without going through the text renderers
|
|
577
|
+
// (which record progressive_disclosure savings as a side effect).
|
|
578
|
+
|
|
579
|
+
/** Recent sessions, newest first, with prompt/turn counts. */
|
|
580
|
+
listSessions(limit = 50): {
|
|
581
|
+
id: string;
|
|
582
|
+
project: string;
|
|
583
|
+
started_at: string;
|
|
584
|
+
ended_at: string | null;
|
|
585
|
+
status: string;
|
|
586
|
+
promptCount: number;
|
|
587
|
+
rollup: string | null;
|
|
588
|
+
}[] {
|
|
589
|
+
try {
|
|
590
|
+
const rows = this.db
|
|
591
|
+
.prepare(
|
|
592
|
+
`SELECT s.id, s.project, s.started_at, s.ended_at, s.status, s.rollup_summary,
|
|
593
|
+
(SELECT COUNT(*) FROM prompts p WHERE p.session_id = s.id) AS prompt_count
|
|
594
|
+
FROM sessions s ORDER BY s.started_at_epoch DESC LIMIT ?`,
|
|
595
|
+
)
|
|
596
|
+
.all(Math.max(1, Math.min(limit, 500))) as {
|
|
597
|
+
id: string;
|
|
598
|
+
project: string;
|
|
599
|
+
started_at: string;
|
|
600
|
+
ended_at: string | null;
|
|
601
|
+
status: string;
|
|
602
|
+
rollup_summary: string | null;
|
|
603
|
+
prompt_count: number | null;
|
|
604
|
+
}[];
|
|
605
|
+
return rows.map((r) => ({
|
|
606
|
+
id: r.id,
|
|
607
|
+
project: r.project,
|
|
608
|
+
started_at: r.started_at,
|
|
609
|
+
ended_at: r.ended_at,
|
|
610
|
+
status: r.status,
|
|
611
|
+
promptCount: Number(r.prompt_count ?? 0),
|
|
612
|
+
rollup: r.rollup_summary,
|
|
613
|
+
}));
|
|
614
|
+
} catch {
|
|
615
|
+
return [];
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Turn summaries + tool-event activity for one session, in turn order. */
|
|
620
|
+
sessionDetail(
|
|
621
|
+
sessionId: string,
|
|
622
|
+
turnLimit = 200,
|
|
623
|
+
): {
|
|
624
|
+
turns: { promptNumber: number; tier: string; summary: string }[];
|
|
625
|
+
events: { id: number; tool: string; promptNumber: number; createdAt: string; hasPayload: boolean }[];
|
|
626
|
+
} {
|
|
627
|
+
const empty = { turns: [], events: [] as { id: number; tool: string; promptNumber: number; createdAt: string; hasPayload: boolean }[] };
|
|
628
|
+
try {
|
|
629
|
+
const turns = this.db
|
|
630
|
+
.prepare(
|
|
631
|
+
"SELECT prompt_number, tier, summary FROM turn_summaries WHERE session_id = ? ORDER BY prompt_number ASC LIMIT ?",
|
|
632
|
+
)
|
|
633
|
+
.all(sessionId, Math.max(1, Math.min(turnLimit, 500))) as {
|
|
634
|
+
prompt_number: number;
|
|
635
|
+
tier: string;
|
|
636
|
+
summary: string;
|
|
637
|
+
}[];
|
|
638
|
+
const events = this.db
|
|
639
|
+
.prepare(
|
|
640
|
+
`SELECT te.id, te.tool_name, te.prompt_number, te.created_at, (te.payload_id IS NOT NULL) AS has_payload
|
|
641
|
+
FROM tool_events te WHERE te.session_id = ? ORDER BY te.id ASC LIMIT ?`,
|
|
642
|
+
)
|
|
643
|
+
.all(sessionId, Math.max(1, Math.min(turnLimit * 20, 2000))) as {
|
|
644
|
+
id: number;
|
|
645
|
+
tool_name: string;
|
|
646
|
+
prompt_number: number;
|
|
647
|
+
created_at: string;
|
|
648
|
+
has_payload: number;
|
|
649
|
+
}[];
|
|
650
|
+
return {
|
|
651
|
+
turns: turns.map((t) => ({ promptNumber: t.prompt_number, tier: t.tier, summary: t.summary })),
|
|
652
|
+
events: events.map((e) => ({
|
|
653
|
+
id: e.id,
|
|
654
|
+
tool: e.tool_name,
|
|
655
|
+
promptNumber: e.prompt_number,
|
|
656
|
+
createdAt: e.created_at,
|
|
657
|
+
hasPayload: Number(e.has_payload) === 1,
|
|
658
|
+
})),
|
|
659
|
+
};
|
|
660
|
+
} catch {
|
|
661
|
+
return empty;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Recent decisions, newest first. */
|
|
666
|
+
listDecisions(limit = 100): { id: number; decision: string; reason: string; source: string; createdAt: string }[] {
|
|
667
|
+
try {
|
|
668
|
+
const rows = this.db
|
|
669
|
+
.prepare("SELECT id, decision, reason, source, created_at FROM decisions ORDER BY created_at_epoch DESC LIMIT ?")
|
|
670
|
+
.all(Math.max(1, Math.min(limit, 500))) as {
|
|
671
|
+
id: number;
|
|
672
|
+
decision: string;
|
|
673
|
+
reason: string;
|
|
674
|
+
source: string;
|
|
675
|
+
created_at: string;
|
|
676
|
+
}[];
|
|
677
|
+
return rows.map((r) => ({ id: r.id, decision: r.decision, reason: r.reason, source: r.source, createdAt: r.created_at }));
|
|
678
|
+
} catch {
|
|
679
|
+
return [];
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Recent code areas, newest first. */
|
|
684
|
+
listCodeAreas(limit = 100): { id: number; filePath: string; description: string; source: string; createdAt: string }[] {
|
|
685
|
+
try {
|
|
686
|
+
const rows = this.db
|
|
687
|
+
.prepare("SELECT id, file_path, description, source, created_at FROM code_areas ORDER BY created_at_epoch DESC LIMIT ?")
|
|
688
|
+
.all(Math.max(1, Math.min(limit, 500))) as {
|
|
689
|
+
id: number;
|
|
690
|
+
file_path: string;
|
|
691
|
+
description: string;
|
|
692
|
+
source: string;
|
|
693
|
+
created_at: string;
|
|
694
|
+
}[];
|
|
695
|
+
return rows.map((r) => ({ id: r.id, filePath: r.file_path, description: r.description, source: r.source, createdAt: r.created_at }));
|
|
696
|
+
} catch {
|
|
697
|
+
return [];
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** Raw savings events, newest first. */
|
|
702
|
+
recentSavings(limit = 200): { bucket: string; baseline: number; served: number; ts: number }[] {
|
|
703
|
+
try {
|
|
704
|
+
const rows = this.db
|
|
705
|
+
.prepare("SELECT bucket, baseline, served, ts FROM savings_log ORDER BY ts DESC, id DESC LIMIT ?")
|
|
706
|
+
.all(Math.max(1, Math.min(limit, 2000))) as { bucket: string; baseline: number; served: number; ts: number }[];
|
|
707
|
+
return rows.map((r) => ({ bucket: r.bucket, baseline: r.baseline, served: r.served, ts: r.ts }));
|
|
708
|
+
} catch {
|
|
709
|
+
return [];
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Savings per local calendar day (oldest → newest) for trend charts. */
|
|
714
|
+
savingsByDay(days = 30): { day: string; baseline: number; served: number }[] {
|
|
715
|
+
const out: { day: string; baseline: number; served: number }[] = [];
|
|
716
|
+
try {
|
|
717
|
+
const cutoff = epochSeconds() - Math.max(1, Math.min(days, 365)) * 86400;
|
|
718
|
+
const rows = this.db
|
|
719
|
+
.prepare("SELECT ts, baseline, served FROM savings_log WHERE ts >= ? ORDER BY ts ASC")
|
|
720
|
+
.all(cutoff) as { ts: number; baseline: number; served: number }[];
|
|
721
|
+
const byDay = new Map<string, { baseline: number; served: number }>();
|
|
722
|
+
for (const r of rows) {
|
|
723
|
+
const day = new Date(r.ts * 1000).toISOString().slice(0, 10);
|
|
724
|
+
const acc = byDay.get(day) ?? { baseline: 0, served: 0 };
|
|
725
|
+
acc.baseline += r.baseline;
|
|
726
|
+
acc.served += r.served;
|
|
727
|
+
byDay.set(day, acc);
|
|
728
|
+
}
|
|
729
|
+
for (const [day, v] of [...byDay.entries()].sort()) out.push({ day, ...v });
|
|
730
|
+
} catch {
|
|
731
|
+
// empty series
|
|
732
|
+
}
|
|
733
|
+
return out;
|
|
734
|
+
}
|
|
735
|
+
|
|
574
736
|
// ── Retention ─────────────────────────────────────────────────────────
|
|
575
737
|
|
|
576
738
|
pruneOldPayloads(days = 30): { payloadsPruned: number; bytesFreedEstimate: number } {
|