pi-mega-compact 0.7.8 → 0.7.9
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 +11 -12
- package/dist/extensions/dashboard-server/helpers.js +37 -0
- package/dist/extensions/dashboard-server/html/all-repos-tab.js +26 -0
- package/dist/extensions/dashboard-server/html/body-open.js +23 -0
- package/dist/extensions/dashboard-server/html/current-repo-tab.js +130 -0
- package/dist/extensions/dashboard-server/html/head-open.js +16 -0
- package/dist/extensions/dashboard-server/html/high-score-tab.js +25 -0
- package/dist/extensions/dashboard-server/html/repo-detail-modal.js +26 -0
- package/dist/extensions/dashboard-server/html/script.js +259 -0
- package/dist/extensions/dashboard-server/html/styles.js +103 -0
- package/dist/extensions/dashboard-server/html/summary-tab.js +19 -0
- package/dist/extensions/dashboard-server/html-template.js +41 -0
- package/dist/extensions/dashboard-server/html.js +756 -0
- package/dist/extensions/dashboard-server/index-reader.js +133 -0
- package/dist/extensions/dashboard-server/server.js +370 -0
- package/dist/extensions/dashboard-server/snapshot.js +43 -0
- package/dist/extensions/dashboard-server/state.js +30 -0
- package/dist/extensions/dashboard-server/types.js +5 -0
- package/dist/extensions/dashboard-server.js +7 -1315
- package/dist/extensions/mega-commands.js +162 -134
- package/dist/extensions/mega-compact.test.js +90 -21
- package/dist/extensions/mega-conflict-cmds.js +5 -1
- package/dist/extensions/mega-dashboard-cmds.js +29 -22
- package/dist/extensions/mega-db-cmds.js +11 -2
- package/dist/extensions/mega-events/agent-handlers.js +173 -0
- package/dist/extensions/mega-events/compact-handlers.js +133 -0
- package/dist/extensions/mega-events/context-handler.js +249 -0
- package/dist/extensions/mega-events/register.js +21 -0
- package/dist/extensions/mega-events/session-handlers.js +142 -0
- package/dist/extensions/mega-events.js +15 -699
- package/dist/extensions/mega-pipeline/compact.js +324 -0
- package/dist/extensions/mega-pipeline/memory-review.js +38 -0
- package/dist/extensions/mega-pipeline/recall.js +147 -0
- package/dist/extensions/mega-pipeline.js +9 -480
- package/dist/extensions/mega-runtime/helpers.js +40 -0
- package/dist/extensions/mega-runtime/query.js +29 -0
- package/dist/extensions/mega-runtime/state.js +711 -0
- package/dist/extensions/mega-runtime/widget.js +197 -0
- package/dist/extensions/mega-runtime.js +15 -947
- package/dist/src/store/sqlite/checkpoints.js +145 -0
- package/dist/src/store/sqlite/connection.js +35 -0
- package/dist/src/store/sqlite/dedup-mirror.js +64 -0
- package/dist/src/store/sqlite/foundation.js +38 -0
- package/dist/src/store/sqlite/global-index.js +224 -0
- package/dist/src/store/sqlite/index-store.js +167 -0
- package/dist/src/store/sqlite/maintenance.js +235 -0
- package/dist/src/store/sqlite/memories.js +164 -0
- package/dist/src/store/sqlite/memory.js +54 -0
- package/dist/src/store/sqlite/meta.js +82 -0
- package/dist/src/store/sqlite/minhash-lsh.js +47 -0
- package/dist/src/store/sqlite/model-snapshots.js +47 -0
- package/dist/src/store/sqlite/raptor.js +57 -0
- package/dist/src/store/sqlite/raw-transcript.js +134 -0
- package/dist/src/store/sqlite/schema.js +250 -0
- package/dist/src/store/sqlite/session-state.js +28 -0
- package/dist/src/store/sqlite/sessions.js +39 -0
- package/dist/src/store/sqlite/stats.js +66 -0
- package/dist/src/store/sqlite/transaction.js +19 -0
- package/dist/src/store/sqlite/utils.js +120 -0
- package/dist/src/store/sqlite.js +20 -1607
- package/dist/src/vectorStore/add.js +260 -0
- package/dist/src/vectorStore/dedup.js +52 -0
- package/dist/src/vectorStore/index.js +10 -0
- package/dist/src/vectorStore/queries.js +83 -0
- package/dist/src/vectorStore/search.js +95 -0
- package/dist/src/vectorStore/session.js +19 -0
- package/dist/src/vectorStore/store.js +105 -0
- package/dist/src/vectorStore/types.js +6 -0
- package/dist/src/vectorStore/utils.js +23 -0
- package/extensions/dashboard-server/html.ts +758 -0
- package/extensions/dashboard-server/index-reader.ts +130 -0
- package/extensions/dashboard-server/server.ts +358 -0
- package/extensions/dashboard-server/snapshot.ts +44 -0
- package/extensions/dashboard-server/state.ts +33 -0
- package/extensions/dashboard-server/types.ts +134 -0
- package/extensions/dashboard-server.ts +7 -1431
- package/extensions/mega-commands.ts +33 -10
- package/extensions/mega-compact.test.ts +198 -43
- package/extensions/mega-conflict-cmds.ts +6 -2
- package/extensions/mega-dashboard-cmds.ts +30 -23
- package/extensions/mega-db-cmds.ts +11 -3
- package/extensions/mega-events/agent-handlers.ts +214 -0
- package/extensions/mega-events/compact-handlers.ts +164 -0
- package/extensions/mega-events/context-handler.ts +290 -0
- package/extensions/mega-events/register.ts +37 -0
- package/extensions/mega-events/session-handlers.ts +165 -0
- package/extensions/mega-events.ts +15 -780
- package/extensions/mega-pipeline/compact.ts +366 -0
- package/extensions/mega-pipeline/memory-review.ts +46 -0
- package/extensions/mega-pipeline/recall.ts +165 -0
- package/extensions/mega-pipeline.ts +9 -537
- package/extensions/mega-runtime/helpers.ts +68 -0
- package/extensions/mega-runtime/query.ts +29 -0
- package/extensions/mega-runtime/state.ts +797 -0
- package/extensions/mega-runtime/widget.ts +258 -0
- package/extensions/mega-runtime.ts +15 -1093
- package/package.json +4 -3
- package/src/store/sqlite/checkpoints.ts +204 -0
- package/src/store/sqlite/dedup-mirror.ts +114 -0
- package/src/store/sqlite/foundation.ts +63 -0
- package/src/store/sqlite/global-index.ts +305 -0
- package/src/store/sqlite/maintenance.ts +294 -0
- package/src/store/sqlite/memories.ts +217 -0
- package/src/store/sqlite/meta.ts +108 -0
- package/src/store/sqlite/model-snapshots.ts +83 -0
- package/src/store/sqlite/raptor.ts +107 -0
- package/src/store/sqlite/raw-transcript.ts +221 -0
- package/src/store/sqlite/schema.ts +258 -0
- package/src/store/sqlite/session-state.ts +38 -0
- package/src/store/sqlite/stats.ts +127 -0
- package/src/store/sqlite/utils.ts +125 -0
- package/src/store/sqlite.ts +20 -2204
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maintenance.ts — S27 Task 10 DB maintenance / housekeeping primitives.
|
|
3
|
+
*
|
|
4
|
+
* All pi-agnostic, all parameterized (PREVENT-002), all local (PREVENT-PI-004).
|
|
5
|
+
* Exposed via the /mega-db-* slash commands in extensions/mega-db-cmds.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { getStateDir } from "../../store.js";
|
|
10
|
+
import { openStore, withTx } from "./utils.js";
|
|
11
|
+
const DB_TABLE_NAMES = [
|
|
12
|
+
"context_chunks",
|
|
13
|
+
"session_state",
|
|
14
|
+
"raw_transcript",
|
|
15
|
+
"checkpoint_epochs",
|
|
16
|
+
"dedup_mirror",
|
|
17
|
+
"memories",
|
|
18
|
+
"dedup_stats",
|
|
19
|
+
"daily_log",
|
|
20
|
+
];
|
|
21
|
+
function fileSizeIfExists(path) {
|
|
22
|
+
try {
|
|
23
|
+
const st = statSync(path);
|
|
24
|
+
return st.size;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Gather DB stats for /mega-db-stats: per-table row counts, disk footprint
|
|
32
|
+
* (main + WAL + SHM), page count, freelist, WAL frame count.
|
|
33
|
+
*
|
|
34
|
+
* Read-only: no PRAGMA writes, no VACUUM. Safe to call any time.
|
|
35
|
+
*/
|
|
36
|
+
export function getDbStats(stateDir = getStateDir()) {
|
|
37
|
+
const db = openStore(stateDir);
|
|
38
|
+
const tableCounts = {};
|
|
39
|
+
for (const t of DB_TABLE_NAMES) {
|
|
40
|
+
try {
|
|
41
|
+
const row = db.prepare(`SELECT COUNT(*) AS c FROM ${t}`).get();
|
|
42
|
+
if (row)
|
|
43
|
+
tableCounts[t] = row.c;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Table doesn't exist on this DB (e.g. raw_transcript on a pre-S27 store).
|
|
47
|
+
// Skip silently — /mega-db-stats lists only tables that exist.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const pageStat = db.prepare("PRAGMA page_count").get();
|
|
51
|
+
const freelistStat = db.prepare("PRAGMA freelist_count").get();
|
|
52
|
+
const pageSizeStat = db.prepare("PRAGMA page_size").get();
|
|
53
|
+
let walFrames = 0;
|
|
54
|
+
try {
|
|
55
|
+
const walInfo = db.prepare("PRAGMA wal_info").get();
|
|
56
|
+
walFrames = walInfo?.frames ?? 0;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// node:sqlite may not expose wal_info on all versions; not fatal.
|
|
60
|
+
}
|
|
61
|
+
const dbPath = join(stateDir, "sqlite.db");
|
|
62
|
+
return {
|
|
63
|
+
tableCounts,
|
|
64
|
+
dbBytes: fileSizeIfExists(dbPath),
|
|
65
|
+
walBytes: fileSizeIfExists(`${dbPath}-wal`),
|
|
66
|
+
shmBytes: fileSizeIfExists(`${dbPath}-shm`),
|
|
67
|
+
pageSize: pageSizeStat?.page_size ?? 0,
|
|
68
|
+
pageCount: pageStat?.page_count ?? 0,
|
|
69
|
+
freelistPages: freelistStat?.freelist_count ?? 0,
|
|
70
|
+
walFrames,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Prune raw_transcript + checkpoint_epochs rows older than `daysOld`.
|
|
75
|
+
* Uses `message_timestamp` (raw_transcript) and `created_at` (epochs), both
|
|
76
|
+
* epoch-ms. Returns the total deleted rows + reclaimed disk bytes.
|
|
77
|
+
*
|
|
78
|
+
* PREVENT-002: parameterized. PREVENT-PI-004: local SQLite only.
|
|
79
|
+
*/
|
|
80
|
+
export function pruneOldRows(stateDir = getStateDir(), daysOld = 30) {
|
|
81
|
+
const db = openStore(stateDir);
|
|
82
|
+
const cutoff = Date.now() - daysOld * 86_400_000;
|
|
83
|
+
const beforeBytes = fileSizeIfExists(join(stateDir, "sqlite.db"));
|
|
84
|
+
// raw_transcript: message_timestamp may be NULL (pre-S27 rows); those use
|
|
85
|
+
// the row's insertion order implicitly via seq, so we prune NULL-ts rows
|
|
86
|
+
// only when the whole session is older than the cutoff (join via session_id
|
|
87
|
+
// to checkpoint_epochs.created_at). Simpler: prune NULL-ts rows older than
|
|
88
|
+
// cutoff by falling back to the MIN(created_at) of their epoch.
|
|
89
|
+
// Delete raw_transcript rows whose message_timestamp is older than cutoff,
|
|
90
|
+
// OR whose message_timestamp is NULL and the session's latest epoch is older.
|
|
91
|
+
const delRt = db.prepare(`DELETE FROM raw_transcript
|
|
92
|
+
WHERE message_timestamp IS NOT NULL AND message_timestamp < ?
|
|
93
|
+
OR (message_timestamp IS NULL
|
|
94
|
+
AND session_id IN (
|
|
95
|
+
SELECT session_id FROM checkpoint_epochs
|
|
96
|
+
GROUP BY session_id HAVING MAX(created_at) < ?
|
|
97
|
+
))`).run(cutoff, cutoff);
|
|
98
|
+
const rtDeleted = delRt?.changes ?? 0;
|
|
99
|
+
// checkpoint_epochs: created_at is NOT NULL.
|
|
100
|
+
const delEp = db.prepare(`DELETE FROM checkpoint_epochs WHERE created_at < ?`).run(cutoff);
|
|
101
|
+
const epDeleted = delEp?.changes ?? 0;
|
|
102
|
+
// dedup_mirror: cascade-delete orphan rows whose ref_count has dropped to 0
|
|
103
|
+
// after the raw_transcript deletes. Safe even if FK is off (raw_transcript has
|
|
104
|
+
// no FK to dedup_mirror; ref_count is maintained by the dedup pipeline).
|
|
105
|
+
const delDedup = db.prepare(`DELETE FROM dedup_mirror WHERE ref_count <= 0`).run();
|
|
106
|
+
const dedupDeleted = delDedup?.changes ?? 0;
|
|
107
|
+
const afterBytes = fileSizeIfExists(join(stateDir, "sqlite.db"));
|
|
108
|
+
const total = rtDeleted + epDeleted + dedupDeleted;
|
|
109
|
+
return {
|
|
110
|
+
affected: total,
|
|
111
|
+
reclaimedBytes: Math.max(0, beforeBytes - afterBytes),
|
|
112
|
+
summary: `pruned ${rtDeleted} raw_transcript + ${epDeleted} epochs + ${dedupDeleted} dedup_mirror rows older than ${daysOld}d`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Force a WAL checkpoint (TRUNCATE mode) so the -wal sidecar is reclaimed.
|
|
117
|
+
* Returns the WAL bytes reclaimed (pre-wal size minus post-wal size).
|
|
118
|
+
*/
|
|
119
|
+
export function checkpointWal(stateDir = getStateDir()) {
|
|
120
|
+
const db = openStore(stateDir);
|
|
121
|
+
const dbPath = join(stateDir, "sqlite.db");
|
|
122
|
+
const beforeWal = fileSizeIfExists(`${dbPath}-wal`);
|
|
123
|
+
// PRAGMA wal_checkpoint(TRUNCATE) blocks until all frames are folded into the
|
|
124
|
+
// main db and the WAL file is truncated to 0 bytes.
|
|
125
|
+
const res = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
126
|
+
const afterWal = fileSizeIfExists(`${dbPath}-wal`);
|
|
127
|
+
const reclaimed = Math.max(0, beforeWal - afterWal);
|
|
128
|
+
return {
|
|
129
|
+
affected: res?.checkpointed ?? 0,
|
|
130
|
+
reclaimedBytes: reclaimed,
|
|
131
|
+
summary: `wal_checkpoint(TRUNCATE): ${res?.checkpointed ?? 0} frames folded, WAL ${beforeWal}→${afterWal} bytes${res?.busy ? " (busy: " + res.busy + ")" : ""}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* VACUUM the main DB file (rebuilds pages, reclaims freelist space).
|
|
136
|
+
* Heavy: briefly doubles disk usage. Run only when freelist is large or the
|
|
137
|
+
* user explicitly invokes /mega-db-vacuum.
|
|
138
|
+
*/
|
|
139
|
+
export function vacuumDb(stateDir = getStateDir()) {
|
|
140
|
+
const db = openStore(stateDir);
|
|
141
|
+
const dbPath = join(stateDir, "sqlite.db");
|
|
142
|
+
const beforeBytes = fileSizeIfExists(dbPath);
|
|
143
|
+
db.exec("VACUUM"); // VACUUM cannot be parameterized; it rewrites the whole DB.
|
|
144
|
+
const afterBytes = fileSizeIfExists(dbPath);
|
|
145
|
+
const reclaimed = Math.max(0, beforeBytes - afterBytes);
|
|
146
|
+
return {
|
|
147
|
+
affected: 0,
|
|
148
|
+
reclaimedBytes: reclaimed,
|
|
149
|
+
summary: `VACUUM: db ${beforeBytes}→${afterBytes} bytes (reclaimed ${reclaimed})`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Run `PRAGMA integrity_check` and return the result lines.
|
|
154
|
+
* Returns ["ok"] when the DB is healthy; otherwise returns the error lines.
|
|
155
|
+
*/
|
|
156
|
+
export function integrityCheck(stateDir = getStateDir()) {
|
|
157
|
+
const db = openStore(stateDir);
|
|
158
|
+
const rows = db.prepare("PRAGMA integrity_check").all();
|
|
159
|
+
return (rows ?? []).map((r) => r.integrity_check);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Reconcile dedup_mirror vs raw_transcript after pruning or crashes:
|
|
163
|
+
* 1. Recompute ref_count = COUNT(raw_transcript rows pointing at this hash).
|
|
164
|
+
* 2. Delete orphan dedup_mirror rows whose recomputed ref_count is 0.
|
|
165
|
+
* 3. Backfill raw_transcript.content_ref for rows still storing inline bytes.
|
|
166
|
+
*
|
|
167
|
+
* Idempotent. Read-modify-write within a single transaction (withTx).
|
|
168
|
+
*/
|
|
169
|
+
export function reconcileDedupMirror(stateDir = getStateDir()) {
|
|
170
|
+
const db = openStore(stateDir);
|
|
171
|
+
const result = { fixedRefCount: 0, orphansDeleted: 0, refsBackfilled: 0 };
|
|
172
|
+
withTx(db, () => {
|
|
173
|
+
// 1. Recompute ref_count for every dedup_mirror row from the actual
|
|
174
|
+
// raw_transcript references.
|
|
175
|
+
const recompute = db.prepare(`UPDATE dedup_mirror AS dm
|
|
176
|
+
SET ref_count = COALESCE((
|
|
177
|
+
SELECT COUNT(*) FROM raw_transcript rt WHERE rt.content_ref = dm.content_hash
|
|
178
|
+
), 0)
|
|
179
|
+
WHERE dm.ref_count != COALESCE((
|
|
180
|
+
SELECT COUNT(*) FROM raw_transcript rt WHERE rt.content_ref = dm.content_hash
|
|
181
|
+
), 0)`).run();
|
|
182
|
+
result.fixedRefCount = recompute?.changes ?? 0;
|
|
183
|
+
// 2. Delete orphan dedup_mirror rows (no raw_transcript refs).
|
|
184
|
+
const delOrphans = db.prepare(`DELETE FROM dedup_mirror
|
|
185
|
+
WHERE content_hash NOT IN (SELECT DISTINCT content_ref FROM raw_transcript WHERE content_ref IS NOT NULL)`).run();
|
|
186
|
+
result.orphansDeleted = delOrphans?.changes ?? 0;
|
|
187
|
+
// 3. Backfill content_ref for rows still storing inline content_bytes (no
|
|
188
|
+
// ref yet). Only safe when a matching dedup_mirror row exists; otherwise
|
|
189
|
+
// we'd need to insert one, which is the dedup pipeline's job, not the
|
|
190
|
+
// reconciler's.
|
|
191
|
+
const backfill = db.prepare(`UPDATE raw_transcript AS rt
|
|
192
|
+
SET content_ref = (
|
|
193
|
+
SELECT dm.content_hash FROM dedup_mirror dm WHERE dm.content_bytes = rt.content_bytes
|
|
194
|
+
)
|
|
195
|
+
WHERE rt.content_ref IS NULL
|
|
196
|
+
AND EXISTS (SELECT 1 FROM dedup_mirror dm WHERE dm.content_bytes = rt.content_bytes)`).run();
|
|
197
|
+
result.refsBackfilled = backfill?.changes ?? 0;
|
|
198
|
+
});
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* One-shot auto-maintenance pass for the session_start hook: prune old rows,
|
|
203
|
+
* checkpoint the WAL if it's grown large, and (only if the DB is huge) VACUUM.
|
|
204
|
+
* Best-effort: swallows errors so a session never fails to start over a
|
|
205
|
+
* housekeeping hiccup. Returns a short summary for the diagnostic log.
|
|
206
|
+
*/
|
|
207
|
+
export function autoMaintain(stateDir = getStateDir()) {
|
|
208
|
+
try {
|
|
209
|
+
const stats = getDbStats(stateDir);
|
|
210
|
+
const parts = [];
|
|
211
|
+
// Prune rows older than 30d (default retention).
|
|
212
|
+
const prune = pruneOldRows(stateDir, 30);
|
|
213
|
+
if (prune.affected > 0)
|
|
214
|
+
parts.push(`pruned ${prune.affected}`);
|
|
215
|
+
// Checkpoint the WAL if it's over 10 MB (avoid pathological WAL growth).
|
|
216
|
+
if (stats.walBytes > 10 * 1024 * 1024) {
|
|
217
|
+
const ck = checkpointWal(stateDir);
|
|
218
|
+
if (ck.reclaimedBytes > 0)
|
|
219
|
+
parts.push(`wal -${ck.reclaimedBytes}B`);
|
|
220
|
+
}
|
|
221
|
+
// VACUUM only if the DB is over 100 MB AND freelist is >20% of pages.
|
|
222
|
+
if (stats.dbBytes > 100 * 1024 * 1024 &&
|
|
223
|
+
stats.pageCount > 0 &&
|
|
224
|
+
stats.freelistPages / stats.pageCount > 0.2) {
|
|
225
|
+
const v = vacuumDb(stateDir);
|
|
226
|
+
if (v.reclaimedBytes > 0)
|
|
227
|
+
parts.push(`vacuum -${v.reclaimedBytes}B`);
|
|
228
|
+
}
|
|
229
|
+
return parts.length ? `auto-maintain: ${parts.join(", ")}` : "auto-maintain: nothing to do";
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// Never block session start over housekeeping.
|
|
233
|
+
return `auto-maintain: skipped (${err.message})`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memories.ts — durable "save to memory" store (taken over from memory extensions).
|
|
3
|
+
*
|
|
4
|
+
* One SQLite store for user-saved memories, scoped by repo. Mirrors the
|
|
5
|
+
* lessons/sessions pattern: all state lives in SQLite from day one.
|
|
6
|
+
*/
|
|
7
|
+
import { getStateDir } from "../../store.js";
|
|
8
|
+
import { openStore } from "./utils.js";
|
|
9
|
+
// S24 storage hardening: keep each memory row bounded so the durable store can
|
|
10
|
+
// never blow a downstream consumer's per-entry buffer (e.g. pi's native
|
|
11
|
+
// file-backed memory caps a single entry at ~5k chars). We truncate content at
|
|
12
|
+
// MEMORY_MAX_CHARS and evict the least-recently-referenced rows past
|
|
13
|
+
// MEMORY_MAX_ROWS per repo via LRU. Both are SQLite-only (PREVENT-PI-004): no
|
|
14
|
+
// file-backed memory is written anywhere. Defaults are overridable via env
|
|
15
|
+
// (MEGACOMPACT_MEMORY_MAX_CHARS / MEGACOMPACT_MEMORY_MAX_ROWS).
|
|
16
|
+
export const MEMORY_MAX_CHARS = 4000;
|
|
17
|
+
export const MEMORY_MAX_ROWS = 500;
|
|
18
|
+
/** Read an env override as a positive int, falling back to `fallback`. */
|
|
19
|
+
function envInt(name, fallback) {
|
|
20
|
+
const v = process.env[name];
|
|
21
|
+
if (v == null || v === "")
|
|
22
|
+
return fallback;
|
|
23
|
+
const n = Number(v);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
25
|
+
}
|
|
26
|
+
/** Effective per-entry char cap (env-overridable, default MEMORY_MAX_CHARS). */
|
|
27
|
+
export function memoryMaxChars() {
|
|
28
|
+
return envInt("MEGACOMPACT_MEMORY_MAX_CHARS", MEMORY_MAX_CHARS);
|
|
29
|
+
}
|
|
30
|
+
/** Effective per-repo row cap (env-overridable, default MEMORY_MAX_ROWS). */
|
|
31
|
+
export function memoryMaxRows() {
|
|
32
|
+
return envInt("MEGACOMPACT_MEMORY_MAX_ROWS", MEMORY_MAX_ROWS);
|
|
33
|
+
}
|
|
34
|
+
/** Truncate memory content to the per-entry cap, preserving a trailing marker. */
|
|
35
|
+
function capMemoryContent(content) {
|
|
36
|
+
const cap = memoryMaxChars();
|
|
37
|
+
if (content.length <= cap)
|
|
38
|
+
return content;
|
|
39
|
+
return content.slice(0, cap) + "…[truncated]";
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Evict the least-recently-referenced rows for a repo past MEMORY_MAX_ROWS.
|
|
43
|
+
* LRU key = COALESCE(last_referenced, last_recalled_at, created_at) so a memory
|
|
44
|
+
* that is recalled/referenced survives over a stale one. Best-effort: any error
|
|
45
|
+
* is swallowed by the caller. Repo-scoped so one noisy repo can't evict another.
|
|
46
|
+
*/
|
|
47
|
+
function evictMemoryLru(repo, stateDir) {
|
|
48
|
+
const db = openStore(stateDir);
|
|
49
|
+
const maxRows = memoryMaxRows();
|
|
50
|
+
// SQLite `= NULL` is never true, so the null-repo scope (memories are
|
|
51
|
+
// stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
|
|
52
|
+
const where = repo == null ? "repo IS NULL" : "repo = ?";
|
|
53
|
+
const countRow = repo == null
|
|
54
|
+
? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
|
|
55
|
+
: db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
|
|
56
|
+
const count = countRow.n;
|
|
57
|
+
const over = count - maxRows;
|
|
58
|
+
if (over <= 0)
|
|
59
|
+
return;
|
|
60
|
+
// Delete the `over` least-recently-used rows. ORDER BY the LRU key ASC, id ASC
|
|
61
|
+
// (id ASC breaks ties deterministically — oldest created first). The `where`
|
|
62
|
+
// clause is a code-controlled constant (never user input) → PREVENT-002 OK.
|
|
63
|
+
const sql = `DELETE FROM memories WHERE ${where} AND id IN (
|
|
64
|
+
SELECT id FROM memories WHERE ${where}
|
|
65
|
+
ORDER BY COALESCE(last_referenced, last_recalled_at, created_at) ASC, id ASC
|
|
66
|
+
LIMIT ?
|
|
67
|
+
)`;
|
|
68
|
+
if (repo == null)
|
|
69
|
+
db.prepare(sql).run(over);
|
|
70
|
+
else
|
|
71
|
+
db.prepare(sql).run(repo, repo, over);
|
|
72
|
+
}
|
|
73
|
+
/** Save a memory to the current repo's store. Returns the new row id.
|
|
74
|
+
* S24 hardening: content is truncated to MEMORY_MAX_CHARS and, once the per-repo
|
|
75
|
+
* row count exceeds MEMORY_MAX_ROWS, the least-recently-used rows are evicted
|
|
76
|
+
* (LRU) so the store stays bounded. */
|
|
77
|
+
export function addMemory(memory, repo, stateDir = getStateDir()) {
|
|
78
|
+
const db = openStore(stateDir);
|
|
79
|
+
const now = Math.floor(Date.now() / 1000);
|
|
80
|
+
const res = db
|
|
81
|
+
.prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at, category, target, source_turn)
|
|
82
|
+
VALUES(?, ?, ?, ?, ?, NULL, ?, ?, ?)`)
|
|
83
|
+
.run(repo ?? null, memory.kind ?? "note", capMemoryContent(memory.content), JSON.stringify(memory.tags ?? []), now, memory.category ?? null, memory.target ?? null, memory.sourceTurn ?? null);
|
|
84
|
+
try {
|
|
85
|
+
evictMemoryLru(repo, stateDir);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* non-fatal: eviction must never fail an add */
|
|
89
|
+
}
|
|
90
|
+
return Number(res.lastInsertRowid);
|
|
91
|
+
}
|
|
92
|
+
/** List recent memories for a repo (or all repos when repo is null). */
|
|
93
|
+
export function listMemories(repo, limit = 50, stateDir = getStateDir()) {
|
|
94
|
+
const db = openStore(stateDir);
|
|
95
|
+
const rows = repo
|
|
96
|
+
? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
|
|
97
|
+
: db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
98
|
+
return rows.map(mapMemoryRow);
|
|
99
|
+
}
|
|
100
|
+
/** Substring search across content + tags. */
|
|
101
|
+
export function searchMemories(query, repo = null, limit = 50, stateDir = getStateDir()) {
|
|
102
|
+
const db = openStore(stateDir);
|
|
103
|
+
const like = `%${query}%`;
|
|
104
|
+
const rows = repo
|
|
105
|
+
? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
|
|
106
|
+
: db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
|
|
107
|
+
return rows.map(mapMemoryRow);
|
|
108
|
+
}
|
|
109
|
+
/** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
|
|
110
|
+
export function recallMemory(id, stateDir = getStateDir()) {
|
|
111
|
+
const db = openStore(stateDir);
|
|
112
|
+
const now = Math.floor(Date.now() / 1000);
|
|
113
|
+
const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
|
|
114
|
+
return res.changes > 0;
|
|
115
|
+
}
|
|
116
|
+
/** Mark a memory as referenced (updates last_referenced). Returns true if found. */
|
|
117
|
+
export function referenceMemory(id, stateDir = getStateDir()) {
|
|
118
|
+
const db = openStore(stateDir);
|
|
119
|
+
const now = Math.floor(Date.now() / 1000);
|
|
120
|
+
const res = db.prepare("UPDATE memories SET last_referenced = ? WHERE id = ?").run(now, id);
|
|
121
|
+
return res.changes > 0;
|
|
122
|
+
}
|
|
123
|
+
/** Replace a memory's mutable fields by id. Returns true if a row was updated. */
|
|
124
|
+
export function replaceMemory(id, patch, stateDir = getStateDir()) {
|
|
125
|
+
const db = openStore(stateDir);
|
|
126
|
+
const res = db
|
|
127
|
+
.prepare(`UPDATE memories
|
|
128
|
+
SET kind = COALESCE(?, kind),
|
|
129
|
+
content = COALESCE(?, content),
|
|
130
|
+
tags = COALESCE(?, tags),
|
|
131
|
+
category = COALESCE(?, category),
|
|
132
|
+
target = COALESCE(?, target),
|
|
133
|
+
source_turn = COALESCE(?, source_turn)
|
|
134
|
+
WHERE id = ?`)
|
|
135
|
+
.run(patch.kind ?? null, patch.content != null ? capMemoryContent(patch.content) : null, patch.tags ? JSON.stringify(patch.tags) : null, "category" in patch ? (patch.category ?? null) : null, "target" in patch ? (patch.target ?? null) : null, "sourceTurn" in patch ? (patch.sourceTurn ?? null) : null, id);
|
|
136
|
+
return res.changes > 0;
|
|
137
|
+
}
|
|
138
|
+
/** Remove a memory by id. Returns true if a row was deleted. */
|
|
139
|
+
export function removeMemory(id, stateDir = getStateDir()) {
|
|
140
|
+
const db = openStore(stateDir);
|
|
141
|
+
const res = db.prepare("DELETE FROM memories WHERE id = ?").run(id);
|
|
142
|
+
return res.changes > 0;
|
|
143
|
+
}
|
|
144
|
+
/** Look up a single memory by id (or undefined). */
|
|
145
|
+
export function getMemory(id, stateDir = getStateDir()) {
|
|
146
|
+
const db = openStore(stateDir);
|
|
147
|
+
const row = db.prepare("SELECT * FROM memories WHERE id = ?").get(id);
|
|
148
|
+
return row ? mapMemoryRow(row) : undefined;
|
|
149
|
+
}
|
|
150
|
+
function mapMemoryRow(row) {
|
|
151
|
+
return {
|
|
152
|
+
id: row.id,
|
|
153
|
+
repo: row.repo ?? null,
|
|
154
|
+
kind: row.kind ?? "note",
|
|
155
|
+
content: row.content ?? "",
|
|
156
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
157
|
+
createdAt: row.created_at ?? 0,
|
|
158
|
+
lastRecalledAt: row.last_recalled_at ?? null,
|
|
159
|
+
category: row.category ?? null,
|
|
160
|
+
target: row.target ?? null,
|
|
161
|
+
lastReferenced: row.last_referenced ?? null,
|
|
162
|
+
sourceTurn: row.source_turn ?? null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable "save to memory" store (taken over from memory extensions).
|
|
3
|
+
*
|
|
4
|
+
* One SQLite store for user-saved memories, scoped by repo. Mirrors the
|
|
5
|
+
* lessons/sessions pattern: all state lives in SQLite from day one. All queries
|
|
6
|
+
* are parameterized (PREVENT-002).
|
|
7
|
+
*/
|
|
8
|
+
import { getStateDir } from "../../store.js";
|
|
9
|
+
import { openStore } from "./connection.js";
|
|
10
|
+
/** Save a memory to the current repo's store. Returns the new row id. */
|
|
11
|
+
export function addMemory(memory, repo, stateDir = getStateDir()) {
|
|
12
|
+
const db = openStore(stateDir);
|
|
13
|
+
const now = Math.floor(Date.now() / 1000);
|
|
14
|
+
const res = db
|
|
15
|
+
.prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
|
|
16
|
+
VALUES(?, ?, ?, ?, ?, NULL)`)
|
|
17
|
+
.run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
|
|
18
|
+
return Number(res.lastInsertRowid);
|
|
19
|
+
}
|
|
20
|
+
/** List recent memories for a repo (or all repos when repo is null). */
|
|
21
|
+
export function listMemories(repo, limit = 50, stateDir = getStateDir()) {
|
|
22
|
+
const db = openStore(stateDir);
|
|
23
|
+
const rows = repo
|
|
24
|
+
? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
|
|
25
|
+
: db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
26
|
+
return rows.map(mapMemoryRow);
|
|
27
|
+
}
|
|
28
|
+
/** Substring search across content + tags. */
|
|
29
|
+
export function searchMemories(query, repo = null, limit = 50, stateDir = getStateDir()) {
|
|
30
|
+
const db = openStore(stateDir);
|
|
31
|
+
const like = `%${query}%`;
|
|
32
|
+
const rows = repo
|
|
33
|
+
? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
|
|
34
|
+
: db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
|
|
35
|
+
return rows.map(mapMemoryRow);
|
|
36
|
+
}
|
|
37
|
+
/** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
|
|
38
|
+
export function recallMemory(id, stateDir = getStateDir()) {
|
|
39
|
+
const db = openStore(stateDir);
|
|
40
|
+
const now = Math.floor(Date.now() / 1000);
|
|
41
|
+
const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
|
|
42
|
+
return res.changes > 0;
|
|
43
|
+
}
|
|
44
|
+
function mapMemoryRow(row) {
|
|
45
|
+
return {
|
|
46
|
+
id: row.id,
|
|
47
|
+
repo: row.repo ?? null,
|
|
48
|
+
kind: row.kind ?? "note",
|
|
49
|
+
content: row.content ?? "",
|
|
50
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
51
|
+
createdAt: row.created_at ?? 0,
|
|
52
|
+
lastRecalledAt: row.last_recalled_at ?? null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* meta.ts — `meta` table key/value helpers + cumulative counters
|
|
3
|
+
* (tokens_saved, dedup stats, compact count, recall injected, cache-hit tokens).
|
|
4
|
+
*/
|
|
5
|
+
import { getStateDir } from "../../store.js";
|
|
6
|
+
import { openStore } from "./utils.js";
|
|
7
|
+
/** Read a string-valued meta key (or undefined). Used for cumulative counters. */
|
|
8
|
+
export function getMeta(key, stateDir = getStateDir()) {
|
|
9
|
+
const db = openStore(stateDir);
|
|
10
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key);
|
|
11
|
+
return row?.value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Cumulative "tokens saved" — the sum of stored checkpoint token estimates across
|
|
15
|
+
* all compactions in this store (one per repo). Persisted in the SQLite `meta`
|
|
16
|
+
* table so it survives session restarts and travels with the repo's state dir,
|
|
17
|
+
* mirroring how `storageDedupRate` is cumulative. Incremented in VectorStore.add()
|
|
18
|
+
* when a new (non-deduped) checkpoint is persisted.
|
|
19
|
+
*/
|
|
20
|
+
export function getTokensSaved(stateDir = getStateDir()) {
|
|
21
|
+
const raw = getMeta("tokens_saved", stateDir);
|
|
22
|
+
const n = raw == null ? 0 : Number(raw);
|
|
23
|
+
return Number.isFinite(n) ? n : 0;
|
|
24
|
+
}
|
|
25
|
+
/** Add `delta` (>=0) to the cumulative tokens-saved counter. */
|
|
26
|
+
export function addTokensSaved(delta, stateDir = getStateDir()) {
|
|
27
|
+
if (!(delta > 0))
|
|
28
|
+
return;
|
|
29
|
+
const db = openStore(stateDir);
|
|
30
|
+
db.prepare(`INSERT INTO meta(key, value) VALUES('tokens_saved', ?)
|
|
31
|
+
ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(String(delta), delta);
|
|
32
|
+
}
|
|
33
|
+
/** Read a store-wide integer counter from the meta table (0 if absent). */
|
|
34
|
+
export function getMetaNumber(key, stateDir = getStateDir()) {
|
|
35
|
+
const raw = getMeta(key, stateDir);
|
|
36
|
+
const n = raw == null ? 0 : Number(raw);
|
|
37
|
+
return Number.isFinite(n) ? n : 0;
|
|
38
|
+
}
|
|
39
|
+
/** Atomically add `delta` to an integer meta counter. */
|
|
40
|
+
function incMeta(key, delta, stateDir = getStateDir()) {
|
|
41
|
+
if (!(delta > 0))
|
|
42
|
+
return;
|
|
43
|
+
const db = openStore(stateDir);
|
|
44
|
+
db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?)
|
|
45
|
+
ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + ? AS TEXT)`).run(key, String(delta), delta);
|
|
46
|
+
}
|
|
47
|
+
/** Read the cumulative store-wide dedup counters. */
|
|
48
|
+
export function getDedupStats(stateDir = getStateDir()) {
|
|
49
|
+
return {
|
|
50
|
+
attempts: getMetaNumber("dedup_attempts", stateDir),
|
|
51
|
+
deduped: getMetaNumber("deduped", stateDir),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Increment the store-wide dedup counters for one add() call. */
|
|
55
|
+
export function bumpDedupStats(deduped, stateDir = getStateDir()) {
|
|
56
|
+
incMeta("dedup_attempts", 1, stateDir);
|
|
57
|
+
if (deduped)
|
|
58
|
+
incMeta("deduped", 1, stateDir);
|
|
59
|
+
}
|
|
60
|
+
// --- Live dashboard counters (schemaless meta key/value — NO migration) -----
|
|
61
|
+
// These reuse the private `incMeta` atomically-incrementing integer counter so
|
|
62
|
+
// all cumulative tallies live in the same `meta` table as tokens_saved etc.
|
|
63
|
+
export function incCompactCount(stateDir = getStateDir()) {
|
|
64
|
+
incMeta("compact_count", 1, stateDir);
|
|
65
|
+
}
|
|
66
|
+
export function getCompactCount(stateDir = getStateDir()) {
|
|
67
|
+
return getMetaNumber("compact_count", stateDir);
|
|
68
|
+
}
|
|
69
|
+
export function incRecallInjected(n, stateDir = getStateDir()) {
|
|
70
|
+
if (n > 0)
|
|
71
|
+
incMeta("recall_injected", n, stateDir);
|
|
72
|
+
}
|
|
73
|
+
export function getRecallInjected(stateDir = getStateDir()) {
|
|
74
|
+
return getMetaNumber("recall_injected", stateDir);
|
|
75
|
+
}
|
|
76
|
+
export function incCacheHitTokens(delta, stateDir = getStateDir()) {
|
|
77
|
+
if (delta > 0)
|
|
78
|
+
incMeta("cache_hit_tokens_saved", delta, stateDir);
|
|
79
|
+
}
|
|
80
|
+
export function getCacheHitTokensSaved(stateDir = getStateDir()) {
|
|
81
|
+
return getMetaNumber("cache_hit_tokens_saved", stateDir);
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprint 11: MinHash signatures + LSH bucket persistence and candidate lookup.
|
|
3
|
+
*
|
|
4
|
+
* All queries are parameterized (PREVENT-002) — never string-concatenated.
|
|
5
|
+
*/
|
|
6
|
+
import { getStateDir, normalizeSessionId } from "../../store.js";
|
|
7
|
+
import { openStore } from "./connection.js";
|
|
8
|
+
import { withTx } from "./transaction.js";
|
|
9
|
+
/** Persist a checkpoint's MinHash signature (idempotent by chunk_id + version). */
|
|
10
|
+
export function upsertMinhashSignature(chunkId, sessionId, signatureVersion, signatures, stateDir = getStateDir()) {
|
|
11
|
+
const db = openStore(stateDir);
|
|
12
|
+
const sid = normalizeSessionId(sessionId);
|
|
13
|
+
db.prepare(`INSERT INTO minhash_signatures(chunk_id, session_id, signature_version, signatures)
|
|
14
|
+
VALUES(?, ?, ?, ?)
|
|
15
|
+
ON CONFLICT(chunk_id, signature_version) DO UPDATE SET
|
|
16
|
+
session_id=excluded.session_id, signatures=excluded.signatures`).run(chunkId, sid, signatureVersion, JSON.stringify(signatures));
|
|
17
|
+
}
|
|
18
|
+
/** Persist LSH bucket memberships for a chunk (one row per bucket key). */
|
|
19
|
+
export function insertLshBuckets(chunkId, sessionId, signatureVersion, bucketKeys, stateDir = getStateDir()) {
|
|
20
|
+
const db = openStore(stateDir);
|
|
21
|
+
const sid = normalizeSessionId(sessionId);
|
|
22
|
+
const del = db.prepare("DELETE FROM dedup_lsh_buckets WHERE chunk_id = ?");
|
|
23
|
+
const ins = db.prepare("INSERT OR IGNORE INTO dedup_lsh_buckets(bucket_key, chunk_id, session_id, signature_version) VALUES(?, ?, ?, ?)");
|
|
24
|
+
withTx(db, () => {
|
|
25
|
+
del.run(chunkId);
|
|
26
|
+
for (const key of bucketKeys)
|
|
27
|
+
ins.run(key, chunkId, sid, signatureVersion);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Candidate chunk_ids sharing any LSH bucket with `bucketKeys`, scoped to the
|
|
32
|
+
* session, capped at `limit`. Single query (no N loops) — QA #15 amplification
|
|
33
|
+
* guard. Returns DISTINCT chunk_ids excluding `excludeChunkId` (the new row).
|
|
34
|
+
*/
|
|
35
|
+
export function lshCandidateChunks(bucketKeys, sessionId, excludeChunkId, stateDir = getStateDir(), limit = 100) {
|
|
36
|
+
if (bucketKeys.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const db = openStore(stateDir);
|
|
39
|
+
const sid = normalizeSessionId(sessionId);
|
|
40
|
+
const placeholders = bucketKeys.map(() => "?").join(",");
|
|
41
|
+
const rows = db
|
|
42
|
+
.prepare(`SELECT DISTINCT chunk_id FROM dedup_lsh_buckets
|
|
43
|
+
WHERE bucket_key IN (${placeholders}) AND session_id = ? AND chunk_id != ?
|
|
44
|
+
LIMIT ?`)
|
|
45
|
+
.all(...bucketKeys, sid, excludeChunkId, limit);
|
|
46
|
+
return rows.map((r) => r.chunk_id);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-snapshots.ts — `model_snapshots` table (active model/provider per repo).
|
|
3
|
+
*/
|
|
4
|
+
import { getStateDir } from "../../store.js";
|
|
5
|
+
import { openStore } from "./utils.js";
|
|
6
|
+
/** Persist the active model/provider for a repo (latest row wins per repo). */
|
|
7
|
+
export function recordModelSnapshot(repoRoot, snap, stateDir = getStateDir()) {
|
|
8
|
+
const db = openStore(stateDir);
|
|
9
|
+
db.prepare(`INSERT INTO model_snapshots
|
|
10
|
+
(repo_root, provider, provider_name, model_id, model_name, input_rate,
|
|
11
|
+
output_rate, context_window, max_tokens, reasoning, captured_at)
|
|
12
|
+
VALUES (@repo_root, @provider, @provider_name, @model_id, @model_name,
|
|
13
|
+
@input_rate, @output_rate, @context_window, @max_tokens, @reasoning, @captured_at)`).run({
|
|
14
|
+
repo_root: repoRoot,
|
|
15
|
+
provider: snap.provider,
|
|
16
|
+
provider_name: snap.providerName,
|
|
17
|
+
model_id: snap.modelId,
|
|
18
|
+
model_name: snap.modelName,
|
|
19
|
+
input_rate: snap.inputRate,
|
|
20
|
+
output_rate: snap.outputRate,
|
|
21
|
+
context_window: snap.contextWindow,
|
|
22
|
+
max_tokens: snap.maxTokens,
|
|
23
|
+
reasoning: snap.reasoning ? 1 : 0,
|
|
24
|
+
captured_at: Date.now(),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Most recent model/provider snapshot for a repo, or undefined. */
|
|
28
|
+
export function latestModelSnapshot(stateDir = getStateDir()) {
|
|
29
|
+
const db = openStore(stateDir);
|
|
30
|
+
const row = db
|
|
31
|
+
.prepare(`SELECT * FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`)
|
|
32
|
+
.get();
|
|
33
|
+
if (!row)
|
|
34
|
+
return undefined;
|
|
35
|
+
return {
|
|
36
|
+
provider: row.provider,
|
|
37
|
+
providerName: row.provider_name,
|
|
38
|
+
modelId: row.model_id,
|
|
39
|
+
modelName: row.model_name,
|
|
40
|
+
inputRate: row.input_rate,
|
|
41
|
+
outputRate: row.output_rate,
|
|
42
|
+
contextWindow: row.context_window,
|
|
43
|
+
maxTokens: row.max_tokens,
|
|
44
|
+
reasoning: row.reasoning === 1,
|
|
45
|
+
capturedAt: row.captured_at,
|
|
46
|
+
};
|
|
47
|
+
}
|