smart-context-mcp 1.0.3 → 1.0.4
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 +139 -26
- package/package.json +8 -3
- package/scripts/check-repo-safety.js +84 -0
- package/scripts/claude-hook.js +33 -0
- package/scripts/headless-wrapper.js +106 -0
- package/scripts/init-clients.js +137 -6
- package/scripts/report-metrics.js +22 -121
- package/src/hooks/claude-hooks.js +424 -0
- package/src/metrics.js +218 -8
- package/src/orchestration/headless-wrapper.js +314 -0
- package/src/repo-safety.js +166 -0
- package/src/server.js +83 -4
- package/src/storage/sqlite.js +1092 -0
- package/src/tools/smart-metrics.js +249 -0
- package/src/tools/smart-summary.js +1230 -324
- package/src/tools/smart-turn.js +307 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { projectRoot } from '../utils/runtime-config.js';
|
|
6
|
+
|
|
7
|
+
export const STATE_DB_FILENAME = 'state.sqlite';
|
|
8
|
+
export const SQLITE_SCHEMA_VERSION = 3;
|
|
9
|
+
export const ACTIVE_SESSION_SCOPE = 'project';
|
|
10
|
+
export const EXPECTED_TABLES = [
|
|
11
|
+
'active_session',
|
|
12
|
+
'hook_turn_state',
|
|
13
|
+
'meta',
|
|
14
|
+
'metrics_events',
|
|
15
|
+
'session_events',
|
|
16
|
+
'sessions',
|
|
17
|
+
'summary_cache',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const MIGRATIONS = [
|
|
21
|
+
{
|
|
22
|
+
version: 1,
|
|
23
|
+
statements: [
|
|
24
|
+
`CREATE TABLE IF NOT EXISTS meta (
|
|
25
|
+
key TEXT PRIMARY KEY,
|
|
26
|
+
value TEXT NOT NULL
|
|
27
|
+
)`,
|
|
28
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
29
|
+
session_id TEXT PRIMARY KEY,
|
|
30
|
+
goal TEXT NOT NULL DEFAULT '',
|
|
31
|
+
status TEXT NOT NULL DEFAULT 'in_progress',
|
|
32
|
+
current_focus TEXT NOT NULL DEFAULT '',
|
|
33
|
+
why_blocked TEXT NOT NULL DEFAULT '',
|
|
34
|
+
next_step TEXT NOT NULL DEFAULT '',
|
|
35
|
+
pinned_context_json TEXT NOT NULL DEFAULT '[]',
|
|
36
|
+
unresolved_questions_json TEXT NOT NULL DEFAULT '[]',
|
|
37
|
+
blockers_json TEXT NOT NULL DEFAULT '[]',
|
|
38
|
+
snapshot_json TEXT NOT NULL DEFAULT '{}',
|
|
39
|
+
completed_count INTEGER NOT NULL DEFAULT 0,
|
|
40
|
+
decisions_count INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
touched_files_count INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
created_at TEXT NOT NULL,
|
|
43
|
+
updated_at TEXT NOT NULL
|
|
44
|
+
)`,
|
|
45
|
+
`CREATE TABLE IF NOT EXISTS session_events (
|
|
46
|
+
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
session_id TEXT NOT NULL,
|
|
48
|
+
event_type TEXT NOT NULL,
|
|
49
|
+
payload_json TEXT NOT NULL,
|
|
50
|
+
token_cost INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
created_at TEXT NOT NULL,
|
|
52
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
53
|
+
)`,
|
|
54
|
+
`CREATE TABLE IF NOT EXISTS metrics_events (
|
|
55
|
+
metric_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
tool TEXT NOT NULL,
|
|
57
|
+
action TEXT,
|
|
58
|
+
session_id TEXT,
|
|
59
|
+
target TEXT,
|
|
60
|
+
raw_tokens INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
compressed_tokens INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
saved_tokens INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
savings_pct REAL NOT NULL DEFAULT 0,
|
|
64
|
+
latency_ms INTEGER,
|
|
65
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
66
|
+
created_at TEXT NOT NULL
|
|
67
|
+
)`,
|
|
68
|
+
`CREATE TABLE IF NOT EXISTS active_session (
|
|
69
|
+
scope TEXT PRIMARY KEY,
|
|
70
|
+
session_id TEXT NOT NULL,
|
|
71
|
+
updated_at TEXT NOT NULL,
|
|
72
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
73
|
+
)`,
|
|
74
|
+
`CREATE TABLE IF NOT EXISTS summary_cache (
|
|
75
|
+
session_id TEXT PRIMARY KEY,
|
|
76
|
+
summary_json TEXT NOT NULL,
|
|
77
|
+
tokens INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
compression_level TEXT NOT NULL DEFAULT 'none',
|
|
79
|
+
omitted_json TEXT NOT NULL DEFAULT '[]',
|
|
80
|
+
updated_at TEXT NOT NULL,
|
|
81
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
82
|
+
)`,
|
|
83
|
+
`CREATE INDEX IF NOT EXISTS idx_session_events_session_created
|
|
84
|
+
ON session_events(session_id, created_at DESC)`,
|
|
85
|
+
`CREATE INDEX IF NOT EXISTS idx_metrics_events_tool_created
|
|
86
|
+
ON metrics_events(tool, created_at DESC)`,
|
|
87
|
+
`CREATE INDEX IF NOT EXISTS idx_metrics_events_session_created
|
|
88
|
+
ON metrics_events(session_id, created_at DESC)`,
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
version: 2,
|
|
93
|
+
statements: [
|
|
94
|
+
'ALTER TABLE session_events ADD COLUMN legacy_key TEXT',
|
|
95
|
+
'ALTER TABLE metrics_events ADD COLUMN legacy_key TEXT',
|
|
96
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_session_events_legacy_key
|
|
97
|
+
ON session_events(legacy_key)
|
|
98
|
+
WHERE legacy_key IS NOT NULL`,
|
|
99
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_metrics_events_legacy_key
|
|
100
|
+
ON metrics_events(legacy_key)
|
|
101
|
+
WHERE legacy_key IS NOT NULL`,
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
version: 3,
|
|
106
|
+
statements: [
|
|
107
|
+
`CREATE TABLE IF NOT EXISTS hook_turn_state (
|
|
108
|
+
hook_key TEXT PRIMARY KEY,
|
|
109
|
+
client TEXT NOT NULL,
|
|
110
|
+
claude_session_id TEXT NOT NULL,
|
|
111
|
+
project_session_id TEXT,
|
|
112
|
+
turn_id TEXT NOT NULL,
|
|
113
|
+
prompt_preview TEXT NOT NULL DEFAULT '',
|
|
114
|
+
continuity_state TEXT NOT NULL DEFAULT '',
|
|
115
|
+
require_checkpoint INTEGER NOT NULL DEFAULT 0,
|
|
116
|
+
prompt_meaningful INTEGER NOT NULL DEFAULT 0,
|
|
117
|
+
checkpointed INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
checkpoint_event TEXT,
|
|
119
|
+
touched_files_json TEXT NOT NULL DEFAULT '[]',
|
|
120
|
+
meaningful_write_count INTEGER NOT NULL DEFAULT 0,
|
|
121
|
+
started_at TEXT NOT NULL,
|
|
122
|
+
updated_at TEXT NOT NULL
|
|
123
|
+
)`,
|
|
124
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_turn_state_claude_session
|
|
125
|
+
ON hook_turn_state(claude_session_id, updated_at DESC)`,
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
let sqliteModulePromise = null;
|
|
131
|
+
|
|
132
|
+
export const getStateDir = () => path.join(projectRoot, '.devctx');
|
|
133
|
+
export const getStateDbPath = () => path.join(getStateDir(), STATE_DB_FILENAME);
|
|
134
|
+
export const getLegacySessionsDir = () => path.join(getStateDir(), 'sessions');
|
|
135
|
+
export const getLegacyMetricsPath = () => path.join(getStateDir(), 'metrics.jsonl');
|
|
136
|
+
export const getLegacyActiveSessionPath = () => path.join(getLegacySessionsDir(), 'active.json');
|
|
137
|
+
|
|
138
|
+
const ensureStateDir = (filePath) => {
|
|
139
|
+
if (filePath === ':memory:') {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const loadSqliteModule = async () => {
|
|
146
|
+
if (!sqliteModulePromise) {
|
|
147
|
+
sqliteModulePromise = import('node:sqlite')
|
|
148
|
+
.catch(() => {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'SQLite storage requires a Node.js runtime with node:sqlite support. Use Node 22+ for the SQLite-backed workflow.',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return sqliteModulePromise;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const setMeta = (db, key, value) => {
|
|
159
|
+
db.prepare(`
|
|
160
|
+
INSERT INTO meta(key, value)
|
|
161
|
+
VALUES(?, ?)
|
|
162
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
163
|
+
`).run(key, String(value));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const getMeta = (db, key) => {
|
|
167
|
+
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
|
|
168
|
+
return row?.value ?? null;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const getSchemaVersion = (db) => Number(getMeta(db, 'schema_version') ?? 0);
|
|
172
|
+
const VALID_STATUSES = new Set(['planning', 'in_progress', 'blocked', 'completed']);
|
|
173
|
+
|
|
174
|
+
const applyPragmas = (db) => {
|
|
175
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
176
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
177
|
+
db.exec('PRAGMA synchronous = NORMAL');
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const safeExec = (db, statement) => {
|
|
181
|
+
try {
|
|
182
|
+
db.exec(statement);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (!/duplicate column name/i.test(error.message)) {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const runStateMigrations = (db) => {
|
|
191
|
+
db.exec('BEGIN');
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
db.exec(`
|
|
195
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
196
|
+
key TEXT PRIMARY KEY,
|
|
197
|
+
value TEXT NOT NULL
|
|
198
|
+
)
|
|
199
|
+
`);
|
|
200
|
+
|
|
201
|
+
let currentVersion = getSchemaVersion(db);
|
|
202
|
+
for (const migration of MIGRATIONS) {
|
|
203
|
+
if (migration.version <= currentVersion) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const statement of migration.statements) {
|
|
208
|
+
safeExec(db, statement);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
currentVersion = migration.version;
|
|
212
|
+
setMeta(db, 'schema_version', currentVersion);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
setMeta(db, 'project_root', projectRoot);
|
|
216
|
+
db.exec('COMMIT');
|
|
217
|
+
} catch (error) {
|
|
218
|
+
db.exec('ROLLBACK');
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return getSchemaVersion(db);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const listStateTables = (db) =>
|
|
226
|
+
db.prepare(`
|
|
227
|
+
SELECT name
|
|
228
|
+
FROM sqlite_master
|
|
229
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
230
|
+
ORDER BY name
|
|
231
|
+
`).all().map((row) => row.name);
|
|
232
|
+
|
|
233
|
+
export const openStateDb = async ({ filePath = getStateDbPath(), readOnly = false } = {}) => {
|
|
234
|
+
const { DatabaseSync } = await loadSqliteModule();
|
|
235
|
+
if (!readOnly) {
|
|
236
|
+
ensureStateDir(filePath);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const db = new DatabaseSync(filePath, readOnly ? { readOnly: true } : {});
|
|
240
|
+
if (!readOnly) {
|
|
241
|
+
applyPragmas(db);
|
|
242
|
+
runStateMigrations(db);
|
|
243
|
+
}
|
|
244
|
+
return db;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export const initializeStateDb = async ({ filePath = getStateDbPath() } = {}) => {
|
|
248
|
+
const db = await openStateDb({ filePath });
|
|
249
|
+
try {
|
|
250
|
+
return {
|
|
251
|
+
filePath,
|
|
252
|
+
schemaVersion: getSchemaVersion(db),
|
|
253
|
+
tables: listStateTables(db),
|
|
254
|
+
};
|
|
255
|
+
} finally {
|
|
256
|
+
db.close();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const withStateDb = async (callback, { filePath = getStateDbPath(), readOnly = false } = {}) => {
|
|
261
|
+
const db = await openStateDb({ filePath, readOnly });
|
|
262
|
+
try {
|
|
263
|
+
return await callback(db);
|
|
264
|
+
} finally {
|
|
265
|
+
db.close();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const copyIfExists = (sourcePath, targetPath) => {
|
|
270
|
+
if (fs.existsSync(sourcePath)) {
|
|
271
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export const withStateDbSnapshot = async (callback, { filePath = getStateDbPath() } = {}) => {
|
|
276
|
+
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devctx-sqlite-snapshot-'));
|
|
277
|
+
const snapshotPath = path.join(snapshotDir, path.basename(filePath));
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
copyIfExists(filePath, snapshotPath);
|
|
281
|
+
copyIfExists(`${filePath}-wal`, `${snapshotPath}-wal`);
|
|
282
|
+
copyIfExists(`${filePath}-shm`, `${snapshotPath}-shm`);
|
|
283
|
+
|
|
284
|
+
return await withStateDb(callback, { filePath: snapshotPath, readOnly: true });
|
|
285
|
+
} finally {
|
|
286
|
+
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const normalizeStringArray = (items) => {
|
|
291
|
+
if (!Array.isArray(items)) {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return [...new Set(items.filter((item) => typeof item === 'string' && item.trim().length > 0))];
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const normalizeStatus = (status) => (VALID_STATUSES.has(status) ? status : 'in_progress');
|
|
299
|
+
|
|
300
|
+
const readJsonFile = (filePath) => {
|
|
301
|
+
try {
|
|
302
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
303
|
+
} catch {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const readMetricsEntries = (filePath) => {
|
|
309
|
+
if (!fs.existsSync(filePath)) {
|
|
310
|
+
throw new Error(`No metrics file found at ${filePath}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const lines = fs.readFileSync(filePath, 'utf8')
|
|
314
|
+
.split('\n')
|
|
315
|
+
.map((line) => line.trim())
|
|
316
|
+
.filter(Boolean);
|
|
317
|
+
|
|
318
|
+
const entries = [];
|
|
319
|
+
const invalidLines = [];
|
|
320
|
+
|
|
321
|
+
lines.forEach((line, index) => {
|
|
322
|
+
try {
|
|
323
|
+
entries.push(JSON.parse(line));
|
|
324
|
+
} catch {
|
|
325
|
+
invalidLines.push(index + 1);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
return { entries, invalidLines };
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const toIsoString = (value, fallback = new Date().toISOString()) => {
|
|
333
|
+
if (typeof value !== 'string') {
|
|
334
|
+
return fallback;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const parsed = Date.parse(value);
|
|
338
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : fallback;
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const getTimestamp = (value, fallback = Date.now()) => {
|
|
342
|
+
const parsed = Date.parse(value ?? '');
|
|
343
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const toJsonText = (value, fallback = {}) => JSON.stringify(value ?? fallback);
|
|
347
|
+
const parseJsonText = (value, fallback = {}) => {
|
|
348
|
+
try {
|
|
349
|
+
return JSON.parse(value ?? '');
|
|
350
|
+
} catch {
|
|
351
|
+
return fallback;
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const hashLegacyPayload = (prefix, payload) =>
|
|
356
|
+
`${prefix}:${createHash('sha1').update(payload).digest('hex')}`;
|
|
357
|
+
|
|
358
|
+
const buildSessionRecord = (sessionId, data) => {
|
|
359
|
+
const completed = normalizeStringArray(data.completed);
|
|
360
|
+
const decisions = normalizeStringArray(data.decisions);
|
|
361
|
+
const touchedFiles = normalizeStringArray(data.touchedFiles);
|
|
362
|
+
const pinnedContext = normalizeStringArray(data.pinnedContext);
|
|
363
|
+
const unresolvedQuestions = normalizeStringArray(data.unresolvedQuestions);
|
|
364
|
+
const blockers = normalizeStringArray(data.blockers);
|
|
365
|
+
const updatedAt = toIsoString(data.updatedAt);
|
|
366
|
+
const createdAt = toIsoString(data.createdAt, updatedAt);
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
sessionId,
|
|
370
|
+
goal: typeof data.goal === 'string' ? data.goal : '',
|
|
371
|
+
status: normalizeStatus(data.status),
|
|
372
|
+
currentFocus: typeof data.currentFocus === 'string' ? data.currentFocus : '',
|
|
373
|
+
whyBlocked: typeof data.whyBlocked === 'string' ? data.whyBlocked : '',
|
|
374
|
+
nextStep: typeof data.nextStep === 'string' ? data.nextStep : '',
|
|
375
|
+
pinnedContext,
|
|
376
|
+
unresolvedQuestions,
|
|
377
|
+
blockers,
|
|
378
|
+
snapshot: data,
|
|
379
|
+
completedCount: Number.isInteger(data.completedCount) ? data.completedCount : completed.length,
|
|
380
|
+
decisionsCount: Number.isInteger(data.decisionsCount) ? data.decisionsCount : decisions.length,
|
|
381
|
+
touchedFilesCount: Number.isInteger(data.touchedFilesCount) ? data.touchedFilesCount : touchedFiles.length,
|
|
382
|
+
createdAt,
|
|
383
|
+
updatedAt,
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const upsertSession = (db, record) => {
|
|
388
|
+
db.prepare(`
|
|
389
|
+
INSERT INTO sessions(
|
|
390
|
+
session_id,
|
|
391
|
+
goal,
|
|
392
|
+
status,
|
|
393
|
+
current_focus,
|
|
394
|
+
why_blocked,
|
|
395
|
+
next_step,
|
|
396
|
+
pinned_context_json,
|
|
397
|
+
unresolved_questions_json,
|
|
398
|
+
blockers_json,
|
|
399
|
+
snapshot_json,
|
|
400
|
+
completed_count,
|
|
401
|
+
decisions_count,
|
|
402
|
+
touched_files_count,
|
|
403
|
+
created_at,
|
|
404
|
+
updated_at
|
|
405
|
+
)
|
|
406
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
407
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
408
|
+
goal = excluded.goal,
|
|
409
|
+
status = excluded.status,
|
|
410
|
+
current_focus = excluded.current_focus,
|
|
411
|
+
why_blocked = excluded.why_blocked,
|
|
412
|
+
next_step = excluded.next_step,
|
|
413
|
+
pinned_context_json = excluded.pinned_context_json,
|
|
414
|
+
unresolved_questions_json = excluded.unresolved_questions_json,
|
|
415
|
+
blockers_json = excluded.blockers_json,
|
|
416
|
+
snapshot_json = excluded.snapshot_json,
|
|
417
|
+
completed_count = excluded.completed_count,
|
|
418
|
+
decisions_count = excluded.decisions_count,
|
|
419
|
+
touched_files_count = excluded.touched_files_count,
|
|
420
|
+
updated_at = excluded.updated_at,
|
|
421
|
+
created_at = sessions.created_at
|
|
422
|
+
`).run(
|
|
423
|
+
record.sessionId,
|
|
424
|
+
record.goal,
|
|
425
|
+
record.status,
|
|
426
|
+
record.currentFocus,
|
|
427
|
+
record.whyBlocked,
|
|
428
|
+
record.nextStep,
|
|
429
|
+
toJsonText(record.pinnedContext, []),
|
|
430
|
+
toJsonText(record.unresolvedQuestions, []),
|
|
431
|
+
toJsonText(record.blockers, []),
|
|
432
|
+
toJsonText(record.snapshot),
|
|
433
|
+
record.completedCount,
|
|
434
|
+
record.decisionsCount,
|
|
435
|
+
record.touchedFilesCount,
|
|
436
|
+
record.createdAt,
|
|
437
|
+
record.updatedAt,
|
|
438
|
+
);
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const insertLegacySessionEvent = (db, record, sourceFile) => {
|
|
442
|
+
const legacyKey = `session:${record.sessionId}`;
|
|
443
|
+
db.prepare(`
|
|
444
|
+
INSERT OR IGNORE INTO session_events(
|
|
445
|
+
session_id,
|
|
446
|
+
event_type,
|
|
447
|
+
payload_json,
|
|
448
|
+
token_cost,
|
|
449
|
+
created_at,
|
|
450
|
+
legacy_key
|
|
451
|
+
)
|
|
452
|
+
VALUES(?, ?, ?, ?, ?, ?)
|
|
453
|
+
`).run(
|
|
454
|
+
record.sessionId,
|
|
455
|
+
'legacy_import',
|
|
456
|
+
JSON.stringify({ source: sourceFile, updatedAt: record.updatedAt }),
|
|
457
|
+
0,
|
|
458
|
+
record.updatedAt,
|
|
459
|
+
legacyKey,
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const normalizeMetricEntry = (entry) => {
|
|
464
|
+
const compressedTokens = Number(entry.compressedTokens ?? entry.finalTokens ?? 0);
|
|
465
|
+
const savedTokens = entry.savedTokens !== undefined
|
|
466
|
+
? Number(entry.savedTokens ?? 0)
|
|
467
|
+
: Math.max(0, Number(entry.rawTokens ?? 0) - compressedTokens);
|
|
468
|
+
const rawTokens = Number(entry.rawTokens ?? 0);
|
|
469
|
+
const savingsPct = rawTokens > 0
|
|
470
|
+
? Number((((savedTokens || 0) / rawTokens) * 100).toFixed(2))
|
|
471
|
+
: Number(entry.savingsPct ?? 0);
|
|
472
|
+
const createdAt = toIsoString(entry.timestamp);
|
|
473
|
+
const {
|
|
474
|
+
tool,
|
|
475
|
+
action = null,
|
|
476
|
+
sessionId = null,
|
|
477
|
+
target = null,
|
|
478
|
+
latencyMs = null,
|
|
479
|
+
metadata: explicitMetadata = {},
|
|
480
|
+
...metadata
|
|
481
|
+
} = entry;
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
tool: tool ?? 'unknown',
|
|
485
|
+
action,
|
|
486
|
+
sessionId,
|
|
487
|
+
target,
|
|
488
|
+
rawTokens,
|
|
489
|
+
compressedTokens,
|
|
490
|
+
savedTokens,
|
|
491
|
+
savingsPct,
|
|
492
|
+
latencyMs,
|
|
493
|
+
metadata: {
|
|
494
|
+
...(explicitMetadata && typeof explicitMetadata === 'object' ? explicitMetadata : {}),
|
|
495
|
+
...metadata,
|
|
496
|
+
},
|
|
497
|
+
createdAt,
|
|
498
|
+
};
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
export const insertMetricEvent = (db, entry, { legacyKey = null, ignoreDuplicates = false } = {}) => {
|
|
502
|
+
const metric = normalizeMetricEntry(entry);
|
|
503
|
+
const insertVerb = ignoreDuplicates ? 'INSERT OR IGNORE' : 'INSERT';
|
|
504
|
+
|
|
505
|
+
db.prepare(`
|
|
506
|
+
${insertVerb} INTO metrics_events(
|
|
507
|
+
tool,
|
|
508
|
+
action,
|
|
509
|
+
session_id,
|
|
510
|
+
target,
|
|
511
|
+
raw_tokens,
|
|
512
|
+
compressed_tokens,
|
|
513
|
+
saved_tokens,
|
|
514
|
+
savings_pct,
|
|
515
|
+
latency_ms,
|
|
516
|
+
metadata_json,
|
|
517
|
+
created_at,
|
|
518
|
+
legacy_key
|
|
519
|
+
)
|
|
520
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
521
|
+
`).run(
|
|
522
|
+
metric.tool,
|
|
523
|
+
metric.action,
|
|
524
|
+
metric.sessionId,
|
|
525
|
+
metric.target,
|
|
526
|
+
metric.rawTokens,
|
|
527
|
+
metric.compressedTokens,
|
|
528
|
+
metric.savedTokens,
|
|
529
|
+
metric.savingsPct,
|
|
530
|
+
metric.latencyMs,
|
|
531
|
+
JSON.stringify(metric.metadata),
|
|
532
|
+
metric.createdAt,
|
|
533
|
+
legacyKey,
|
|
534
|
+
);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const insertLegacyMetric = (db, entry) => {
|
|
538
|
+
const legacyKey = hashLegacyPayload('metric', JSON.stringify(entry));
|
|
539
|
+
insertMetricEvent(db, entry, { legacyKey, ignoreDuplicates: true });
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const upsertActiveSession = (db, sessionId, updatedAt) => {
|
|
543
|
+
db.prepare(`
|
|
544
|
+
INSERT INTO active_session(scope, session_id, updated_at)
|
|
545
|
+
VALUES(?, ?, ?)
|
|
546
|
+
ON CONFLICT(scope) DO UPDATE SET
|
|
547
|
+
session_id = excluded.session_id,
|
|
548
|
+
updated_at = excluded.updated_at
|
|
549
|
+
`).run(ACTIVE_SESSION_SCOPE, sessionId, updatedAt);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
const buildHookTurnRecord = (hookKey, state = {}) => {
|
|
553
|
+
const startedAt = toIsoString(state.startedAt);
|
|
554
|
+
const updatedAt = toIsoString(state.updatedAt, startedAt);
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
hookKey,
|
|
558
|
+
client: typeof state.client === 'string' && state.client.trim().length > 0 ? state.client : 'claude',
|
|
559
|
+
claudeSessionId: typeof state.claudeSessionId === 'string' ? state.claudeSessionId : '',
|
|
560
|
+
projectSessionId: typeof state.projectSessionId === 'string' && state.projectSessionId.trim().length > 0
|
|
561
|
+
? state.projectSessionId
|
|
562
|
+
: null,
|
|
563
|
+
turnId: typeof state.turnId === 'string' && state.turnId.trim().length > 0
|
|
564
|
+
? state.turnId
|
|
565
|
+
: `turn-${Date.now()}`,
|
|
566
|
+
promptPreview: typeof state.promptPreview === 'string' ? state.promptPreview : '',
|
|
567
|
+
continuityState: typeof state.continuityState === 'string' ? state.continuityState : '',
|
|
568
|
+
requireCheckpoint: state.requireCheckpoint ? 1 : 0,
|
|
569
|
+
promptMeaningful: state.promptMeaningful ? 1 : 0,
|
|
570
|
+
checkpointed: state.checkpointed ? 1 : 0,
|
|
571
|
+
checkpointEvent: typeof state.checkpointEvent === 'string' && state.checkpointEvent.trim().length > 0
|
|
572
|
+
? state.checkpointEvent
|
|
573
|
+
: null,
|
|
574
|
+
touchedFiles: normalizeStringArray(state.touchedFiles),
|
|
575
|
+
meaningfulWriteCount: Number.isInteger(state.meaningfulWriteCount)
|
|
576
|
+
? Math.max(0, state.meaningfulWriteCount)
|
|
577
|
+
: 0,
|
|
578
|
+
startedAt,
|
|
579
|
+
updatedAt,
|
|
580
|
+
};
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const normalizeHookTurnRow = (row) => {
|
|
584
|
+
if (!row) {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
hookKey: row.hook_key,
|
|
590
|
+
client: row.client,
|
|
591
|
+
claudeSessionId: row.claude_session_id,
|
|
592
|
+
projectSessionId: row.project_session_id ?? null,
|
|
593
|
+
turnId: row.turn_id,
|
|
594
|
+
promptPreview: row.prompt_preview,
|
|
595
|
+
continuityState: row.continuity_state,
|
|
596
|
+
requireCheckpoint: Boolean(row.require_checkpoint),
|
|
597
|
+
promptMeaningful: Boolean(row.prompt_meaningful),
|
|
598
|
+
checkpointed: Boolean(row.checkpointed),
|
|
599
|
+
checkpointEvent: row.checkpoint_event ?? null,
|
|
600
|
+
touchedFiles: normalizeStringArray(parseJsonText(row.touched_files_json, [])),
|
|
601
|
+
meaningfulWriteCount: Number(row.meaningful_write_count ?? 0),
|
|
602
|
+
startedAt: row.started_at,
|
|
603
|
+
updatedAt: row.updated_at,
|
|
604
|
+
};
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const readHookTurnRow = (db, hookKey) => db.prepare(`
|
|
608
|
+
SELECT
|
|
609
|
+
hook_key,
|
|
610
|
+
client,
|
|
611
|
+
claude_session_id,
|
|
612
|
+
project_session_id,
|
|
613
|
+
turn_id,
|
|
614
|
+
prompt_preview,
|
|
615
|
+
continuity_state,
|
|
616
|
+
require_checkpoint,
|
|
617
|
+
prompt_meaningful,
|
|
618
|
+
checkpointed,
|
|
619
|
+
checkpoint_event,
|
|
620
|
+
touched_files_json,
|
|
621
|
+
meaningful_write_count,
|
|
622
|
+
started_at,
|
|
623
|
+
updated_at
|
|
624
|
+
FROM hook_turn_state
|
|
625
|
+
WHERE hook_key = ?
|
|
626
|
+
`).get(hookKey);
|
|
627
|
+
|
|
628
|
+
const upsertHookTurnRow = (db, record) => {
|
|
629
|
+
db.prepare(`
|
|
630
|
+
INSERT INTO hook_turn_state(
|
|
631
|
+
hook_key,
|
|
632
|
+
client,
|
|
633
|
+
claude_session_id,
|
|
634
|
+
project_session_id,
|
|
635
|
+
turn_id,
|
|
636
|
+
prompt_preview,
|
|
637
|
+
continuity_state,
|
|
638
|
+
require_checkpoint,
|
|
639
|
+
prompt_meaningful,
|
|
640
|
+
checkpointed,
|
|
641
|
+
checkpoint_event,
|
|
642
|
+
touched_files_json,
|
|
643
|
+
meaningful_write_count,
|
|
644
|
+
started_at,
|
|
645
|
+
updated_at
|
|
646
|
+
)
|
|
647
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
648
|
+
ON CONFLICT(hook_key) DO UPDATE SET
|
|
649
|
+
client = excluded.client,
|
|
650
|
+
claude_session_id = excluded.claude_session_id,
|
|
651
|
+
project_session_id = excluded.project_session_id,
|
|
652
|
+
turn_id = excluded.turn_id,
|
|
653
|
+
prompt_preview = excluded.prompt_preview,
|
|
654
|
+
continuity_state = excluded.continuity_state,
|
|
655
|
+
require_checkpoint = excluded.require_checkpoint,
|
|
656
|
+
prompt_meaningful = excluded.prompt_meaningful,
|
|
657
|
+
checkpointed = excluded.checkpointed,
|
|
658
|
+
checkpoint_event = excluded.checkpoint_event,
|
|
659
|
+
touched_files_json = excluded.touched_files_json,
|
|
660
|
+
meaningful_write_count = excluded.meaningful_write_count,
|
|
661
|
+
updated_at = excluded.updated_at,
|
|
662
|
+
started_at = hook_turn_state.started_at
|
|
663
|
+
`).run(
|
|
664
|
+
record.hookKey,
|
|
665
|
+
record.client,
|
|
666
|
+
record.claudeSessionId,
|
|
667
|
+
record.projectSessionId,
|
|
668
|
+
record.turnId,
|
|
669
|
+
record.promptPreview,
|
|
670
|
+
record.continuityState,
|
|
671
|
+
record.requireCheckpoint,
|
|
672
|
+
record.promptMeaningful,
|
|
673
|
+
record.checkpointed,
|
|
674
|
+
record.checkpointEvent,
|
|
675
|
+
toJsonText(record.touchedFiles, []),
|
|
676
|
+
record.meaningfulWriteCount,
|
|
677
|
+
record.startedAt,
|
|
678
|
+
record.updatedAt,
|
|
679
|
+
);
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
export const getHookTurnState = async ({ filePath = getStateDbPath(), hookKey } = {}) => withStateDb((db) =>
|
|
683
|
+
normalizeHookTurnRow(readHookTurnRow(db, hookKey))
|
|
684
|
+
, { filePath });
|
|
685
|
+
|
|
686
|
+
export const setHookTurnState = async ({ filePath = getStateDbPath(), hookKey, state } = {}) => withStateDb((db) => {
|
|
687
|
+
const record = buildHookTurnRecord(hookKey, state);
|
|
688
|
+
upsertHookTurnRow(db, record);
|
|
689
|
+
return normalizeHookTurnRow(readHookTurnRow(db, hookKey));
|
|
690
|
+
}, { filePath });
|
|
691
|
+
|
|
692
|
+
export const deleteHookTurnState = async ({ filePath = getStateDbPath(), hookKey } = {}) => withStateDb((db) => {
|
|
693
|
+
const existing = normalizeHookTurnRow(readHookTurnRow(db, hookKey));
|
|
694
|
+
db.prepare('DELETE FROM hook_turn_state WHERE hook_key = ?').run(hookKey);
|
|
695
|
+
return existing;
|
|
696
|
+
}, { filePath });
|
|
697
|
+
|
|
698
|
+
const listLegacySessionFiles = (sessionsDir) => {
|
|
699
|
+
if (!fs.existsSync(sessionsDir)) {
|
|
700
|
+
return [];
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return fs.readdirSync(sessionsDir)
|
|
704
|
+
.filter((file) => file.endsWith('.json') && file !== 'active.json')
|
|
705
|
+
.sort();
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
export const importLegacyState = async ({
|
|
709
|
+
filePath = getStateDbPath(),
|
|
710
|
+
sessionsDir = getLegacySessionsDir(),
|
|
711
|
+
metricsFile = getLegacyMetricsPath(),
|
|
712
|
+
activeSessionFile = getLegacyActiveSessionPath(),
|
|
713
|
+
} = {}) => withStateDb((db) => {
|
|
714
|
+
const report = {
|
|
715
|
+
filePath,
|
|
716
|
+
sessions: { imported: 0, skipped: 0, invalid: 0 },
|
|
717
|
+
metrics: { imported: 0, skipped: 0, invalid: 0 },
|
|
718
|
+
activeSession: { imported: false, sessionId: null },
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const sessionFiles = listLegacySessionFiles(sessionsDir);
|
|
722
|
+
for (const fileName of sessionFiles) {
|
|
723
|
+
const payload = readJsonFile(path.join(sessionsDir, fileName));
|
|
724
|
+
if (!payload || typeof payload !== 'object') {
|
|
725
|
+
report.sessions.invalid += 1;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const sessionId = typeof payload.sessionId === 'string' && payload.sessionId.trim().length > 0
|
|
730
|
+
? payload.sessionId
|
|
731
|
+
: fileName.replace(/\.json$/i, '');
|
|
732
|
+
const exists = db.prepare('SELECT 1 FROM sessions WHERE session_id = ?').get(sessionId);
|
|
733
|
+
const record = buildSessionRecord(sessionId, payload);
|
|
734
|
+
|
|
735
|
+
upsertSession(db, record);
|
|
736
|
+
insertLegacySessionEvent(db, record, fileName);
|
|
737
|
+
report.sessions[exists ? 'skipped' : 'imported'] += 1;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (fs.existsSync(activeSessionFile)) {
|
|
741
|
+
const active = readJsonFile(activeSessionFile);
|
|
742
|
+
const activeSessionId = typeof active?.sessionId === 'string' ? active.sessionId : null;
|
|
743
|
+
if (activeSessionId) {
|
|
744
|
+
const exists = db.prepare('SELECT 1 FROM sessions WHERE session_id = ?').get(activeSessionId);
|
|
745
|
+
if (exists) {
|
|
746
|
+
upsertActiveSession(db, activeSessionId, toIsoString(active.updatedAt));
|
|
747
|
+
report.activeSession = { imported: true, sessionId: activeSessionId };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (fs.existsSync(metricsFile)) {
|
|
753
|
+
const { entries, invalidLines } = readMetricsEntries(metricsFile);
|
|
754
|
+
report.metrics.invalid = invalidLines.length;
|
|
755
|
+
|
|
756
|
+
for (const entry of entries) {
|
|
757
|
+
const legacyKey = hashLegacyPayload('metric', JSON.stringify(entry));
|
|
758
|
+
const exists = db.prepare('SELECT 1 FROM metrics_events WHERE legacy_key = ?').get(legacyKey);
|
|
759
|
+
insertLegacyMetric(db, entry);
|
|
760
|
+
report.metrics[exists ? 'skipped' : 'imported'] += 1;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
setMeta(db, 'legacy_sessions_imported_at', new Date().toISOString());
|
|
765
|
+
setMeta(db, 'legacy_sessions_import_count', report.sessions.imported + report.sessions.skipped);
|
|
766
|
+
setMeta(db, 'legacy_metrics_imported_at', new Date().toISOString());
|
|
767
|
+
setMeta(db, 'legacy_metrics_import_count', report.metrics.imported + report.metrics.skipped);
|
|
768
|
+
|
|
769
|
+
return report;
|
|
770
|
+
}, { filePath });
|
|
771
|
+
|
|
772
|
+
const getActiveSessionRow = (db) => db.prepare(`
|
|
773
|
+
SELECT session_id, updated_at
|
|
774
|
+
FROM active_session
|
|
775
|
+
WHERE scope = ?
|
|
776
|
+
`).get(ACTIVE_SESSION_SCOPE);
|
|
777
|
+
|
|
778
|
+
const getSessionIdFromLegacyPayload = (fileName, payload) =>
|
|
779
|
+
typeof payload?.sessionId === 'string' && payload.sessionId.trim().length > 0
|
|
780
|
+
? payload.sessionId
|
|
781
|
+
: fileName.replace(/\.json$/i, '');
|
|
782
|
+
|
|
783
|
+
const buildSessionCleanupCandidate = (db, sessionsDir, fileName) => {
|
|
784
|
+
const filePath = path.join(sessionsDir, fileName);
|
|
785
|
+
const payload = readJsonFile(filePath);
|
|
786
|
+
|
|
787
|
+
if (!payload || typeof payload !== 'object') {
|
|
788
|
+
return {
|
|
789
|
+
type: 'session',
|
|
790
|
+
path: filePath,
|
|
791
|
+
fileName,
|
|
792
|
+
eligible: false,
|
|
793
|
+
reason: 'invalid_json',
|
|
794
|
+
sessionId: null,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const sessionId = getSessionIdFromLegacyPayload(fileName, payload);
|
|
799
|
+
const sessionRow = db.prepare(`
|
|
800
|
+
SELECT session_id, updated_at
|
|
801
|
+
FROM sessions
|
|
802
|
+
WHERE session_id = ?
|
|
803
|
+
`).get(sessionId);
|
|
804
|
+
if (!sessionRow) {
|
|
805
|
+
return {
|
|
806
|
+
type: 'session',
|
|
807
|
+
path: filePath,
|
|
808
|
+
fileName,
|
|
809
|
+
eligible: false,
|
|
810
|
+
reason: 'missing_in_sqlite',
|
|
811
|
+
sessionId,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const fileUpdatedAt = toIsoString(payload.updatedAt, sessionRow.updated_at);
|
|
816
|
+
const sqliteUpdatedAt = toIsoString(sessionRow.updated_at);
|
|
817
|
+
const eligible = getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
|
|
818
|
+
|
|
819
|
+
return {
|
|
820
|
+
type: 'session',
|
|
821
|
+
path: filePath,
|
|
822
|
+
fileName,
|
|
823
|
+
eligible,
|
|
824
|
+
reason: eligible ? 'imported_and_not_newer_than_sqlite' : 'legacy_file_newer_than_sqlite',
|
|
825
|
+
sessionId,
|
|
826
|
+
fileUpdatedAt,
|
|
827
|
+
sqliteUpdatedAt,
|
|
828
|
+
};
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const buildActiveCleanupCandidate = (db, activeSessionFile) => {
|
|
832
|
+
if (!fs.existsSync(activeSessionFile)) {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const payload = readJsonFile(activeSessionFile);
|
|
837
|
+
if (!payload || typeof payload !== 'object') {
|
|
838
|
+
return {
|
|
839
|
+
type: 'active_session',
|
|
840
|
+
path: activeSessionFile,
|
|
841
|
+
eligible: false,
|
|
842
|
+
reason: 'invalid_json',
|
|
843
|
+
sessionId: null,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const legacySessionId = typeof payload.sessionId === 'string' ? payload.sessionId : null;
|
|
848
|
+
const activeRow = getActiveSessionRow(db);
|
|
849
|
+
if (!legacySessionId) {
|
|
850
|
+
return {
|
|
851
|
+
type: 'active_session',
|
|
852
|
+
path: activeSessionFile,
|
|
853
|
+
eligible: true,
|
|
854
|
+
reason: 'orphaned_legacy_file',
|
|
855
|
+
sessionId: null,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
if (!activeRow) {
|
|
860
|
+
return {
|
|
861
|
+
type: 'active_session',
|
|
862
|
+
path: activeSessionFile,
|
|
863
|
+
eligible: true,
|
|
864
|
+
reason: 'sqlite_has_no_active_session',
|
|
865
|
+
sessionId: legacySessionId,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const fileUpdatedAt = toIsoString(payload.updatedAt, activeRow.updated_at);
|
|
870
|
+
const sqliteUpdatedAt = toIsoString(activeRow.updated_at);
|
|
871
|
+
const eligible = activeRow.session_id === legacySessionId
|
|
872
|
+
&& getTimestamp(sqliteUpdatedAt) >= getTimestamp(fileUpdatedAt);
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
type: 'active_session',
|
|
876
|
+
path: activeSessionFile,
|
|
877
|
+
eligible,
|
|
878
|
+
reason: eligible ? 'sqlite_active_session_matches' : 'sqlite_active_session_differs',
|
|
879
|
+
sessionId: legacySessionId,
|
|
880
|
+
fileUpdatedAt,
|
|
881
|
+
sqliteUpdatedAt,
|
|
882
|
+
};
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const buildMetricsCleanupCandidate = (db, metricsFile) => {
|
|
886
|
+
if (!fs.existsSync(metricsFile)) {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const { entries, invalidLines } = readMetricsEntries(metricsFile);
|
|
891
|
+
if (invalidLines.length > 0) {
|
|
892
|
+
return {
|
|
893
|
+
type: 'metrics',
|
|
894
|
+
path: metricsFile,
|
|
895
|
+
eligible: false,
|
|
896
|
+
reason: 'invalid_jsonl',
|
|
897
|
+
entryCount: entries.length,
|
|
898
|
+
invalidLines,
|
|
899
|
+
missingEntries: [],
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const missingEntries = [];
|
|
904
|
+
for (const entry of entries) {
|
|
905
|
+
const legacyKey = hashLegacyPayload('metric', JSON.stringify(entry));
|
|
906
|
+
const exists = db.prepare('SELECT 1 FROM metrics_events WHERE legacy_key = ?').get(legacyKey);
|
|
907
|
+
if (!exists) {
|
|
908
|
+
missingEntries.push({
|
|
909
|
+
tool: entry.tool ?? 'unknown',
|
|
910
|
+
timestamp: entry.timestamp ?? null,
|
|
911
|
+
legacyKey,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
type: 'metrics',
|
|
918
|
+
path: metricsFile,
|
|
919
|
+
eligible: missingEntries.length === 0,
|
|
920
|
+
reason: missingEntries.length === 0 ? 'all_entries_imported' : 'sqlite_missing_entries',
|
|
921
|
+
entryCount: entries.length,
|
|
922
|
+
invalidLines,
|
|
923
|
+
missingEntries,
|
|
924
|
+
};
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
export const compactState = async ({
|
|
928
|
+
filePath = getStateDbPath(),
|
|
929
|
+
retentionDays = 30,
|
|
930
|
+
keepLatestEventsPerSession = 20,
|
|
931
|
+
keepLatestMetrics = 1000,
|
|
932
|
+
vacuum = false,
|
|
933
|
+
} = {}) => withStateDb((db) => {
|
|
934
|
+
const HOOK_TURN_RETENTION_HOURS = 48;
|
|
935
|
+
const normalizedRetentionDays = Math.max(1, Number(retentionDays) || 30);
|
|
936
|
+
const normalizedKeepLatestEventsPerSession = Math.max(0, Number(keepLatestEventsPerSession) || 0);
|
|
937
|
+
const normalizedKeepLatestMetrics = Math.max(0, Number(keepLatestMetrics) || 0);
|
|
938
|
+
const cutoff = new Date(Date.now() - normalizedRetentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
939
|
+
const hookTurnCutoff = new Date(Date.now() - HOOK_TURN_RETENTION_HOURS * 60 * 60 * 1000).toISOString();
|
|
940
|
+
const activeSessionId = getActiveSessionRow(db)?.session_id ?? null;
|
|
941
|
+
|
|
942
|
+
const report = {
|
|
943
|
+
filePath,
|
|
944
|
+
retentionDays: normalizedRetentionDays,
|
|
945
|
+
cutoff,
|
|
946
|
+
hookTurnRetentionHours: HOOK_TURN_RETENTION_HOURS,
|
|
947
|
+
sessions: { before: 0, deleted: 0, after: 0 },
|
|
948
|
+
sessionEvents: { before: 0, deleted: 0, after: 0, keepLatestPerSession: normalizedKeepLatestEventsPerSession },
|
|
949
|
+
metricsEvents: { before: 0, deleted: 0, after: 0, keepLatest: normalizedKeepLatestMetrics },
|
|
950
|
+
hookTurnState: { before: 0, deleted: 0, after: 0 },
|
|
951
|
+
vacuumed: false,
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
report.sessions.before = db.prepare('SELECT COUNT(*) AS count FROM sessions').get().count;
|
|
955
|
+
report.sessionEvents.before = db.prepare('SELECT COUNT(*) AS count FROM session_events').get().count;
|
|
956
|
+
report.metricsEvents.before = db.prepare('SELECT COUNT(*) AS count FROM metrics_events').get().count;
|
|
957
|
+
report.hookTurnState.before = db.prepare('SELECT COUNT(*) AS count FROM hook_turn_state').get().count;
|
|
958
|
+
|
|
959
|
+
db.exec('BEGIN');
|
|
960
|
+
try {
|
|
961
|
+
const staleSessions = db.prepare(`
|
|
962
|
+
SELECT session_id
|
|
963
|
+
FROM sessions
|
|
964
|
+
WHERE updated_at < ?
|
|
965
|
+
`).all(cutoff);
|
|
966
|
+
|
|
967
|
+
for (const row of staleSessions) {
|
|
968
|
+
if (row.session_id === activeSessionId) {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
db.prepare('DELETE FROM sessions WHERE session_id = ?').run(row.session_id);
|
|
973
|
+
report.sessions.deleted += 1;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const sessionEventRows = db.prepare(`
|
|
977
|
+
SELECT event_id, session_id, created_at
|
|
978
|
+
FROM session_events
|
|
979
|
+
ORDER BY session_id ASC, datetime(created_at) DESC, event_id DESC
|
|
980
|
+
`).all();
|
|
981
|
+
const sessionEventKeepCounts = new Map();
|
|
982
|
+
for (const row of sessionEventRows) {
|
|
983
|
+
const current = sessionEventKeepCounts.get(row.session_id) ?? 0;
|
|
984
|
+
const shouldKeep = current < normalizedKeepLatestEventsPerSession || getTimestamp(row.created_at) >= getTimestamp(cutoff);
|
|
985
|
+
if (shouldKeep) {
|
|
986
|
+
sessionEventKeepCounts.set(row.session_id, current + 1);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
db.prepare('DELETE FROM session_events WHERE event_id = ?').run(row.event_id);
|
|
990
|
+
report.sessionEvents.deleted += 1;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const metricRows = db.prepare(`
|
|
994
|
+
SELECT metric_id, created_at
|
|
995
|
+
FROM metrics_events
|
|
996
|
+
ORDER BY datetime(created_at) DESC, metric_id DESC
|
|
997
|
+
`).all();
|
|
998
|
+
let keptMetrics = 0;
|
|
999
|
+
for (const row of metricRows) {
|
|
1000
|
+
const shouldKeep = keptMetrics < normalizedKeepLatestMetrics || getTimestamp(row.created_at) >= getTimestamp(cutoff);
|
|
1001
|
+
if (shouldKeep) {
|
|
1002
|
+
keptMetrics += 1;
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
db.prepare('DELETE FROM metrics_events WHERE metric_id = ?').run(row.metric_id);
|
|
1006
|
+
report.metricsEvents.deleted += 1;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const deletedHookTurns = db.prepare(`
|
|
1010
|
+
DELETE FROM hook_turn_state
|
|
1011
|
+
WHERE updated_at < ?
|
|
1012
|
+
`).run(hookTurnCutoff);
|
|
1013
|
+
report.hookTurnState.deleted = deletedHookTurns.changes ?? 0;
|
|
1014
|
+
|
|
1015
|
+
setMeta(db, 'state_compacted_at', new Date().toISOString());
|
|
1016
|
+
setMeta(db, 'state_compaction_retention_days', normalizedRetentionDays);
|
|
1017
|
+
db.exec('COMMIT');
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
db.exec('ROLLBACK');
|
|
1020
|
+
throw error;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (vacuum && (report.sessions.deleted > 0 || report.sessionEvents.deleted > 0 || report.metricsEvents.deleted > 0)) {
|
|
1024
|
+
db.exec('VACUUM');
|
|
1025
|
+
report.vacuumed = true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
report.sessions.after = db.prepare('SELECT COUNT(*) AS count FROM sessions').get().count;
|
|
1029
|
+
report.sessionEvents.after = db.prepare('SELECT COUNT(*) AS count FROM session_events').get().count;
|
|
1030
|
+
report.metricsEvents.after = db.prepare('SELECT COUNT(*) AS count FROM metrics_events').get().count;
|
|
1031
|
+
report.hookTurnState.after = db.prepare('SELECT COUNT(*) AS count FROM hook_turn_state').get().count;
|
|
1032
|
+
|
|
1033
|
+
return report;
|
|
1034
|
+
}, { filePath });
|
|
1035
|
+
|
|
1036
|
+
export const cleanupLegacyState = async ({
|
|
1037
|
+
filePath = getStateDbPath(),
|
|
1038
|
+
sessionsDir = getLegacySessionsDir(),
|
|
1039
|
+
metricsFile = getLegacyMetricsPath(),
|
|
1040
|
+
activeSessionFile = getLegacyActiveSessionPath(),
|
|
1041
|
+
apply = false,
|
|
1042
|
+
} = {}) => withStateDb((db) => {
|
|
1043
|
+
const sessionCandidates = listLegacySessionFiles(sessionsDir).map((fileName) =>
|
|
1044
|
+
buildSessionCleanupCandidate(db, sessionsDir, fileName)
|
|
1045
|
+
);
|
|
1046
|
+
const activeCandidate = buildActiveCleanupCandidate(db, activeSessionFile);
|
|
1047
|
+
const metricsCandidate = buildMetricsCleanupCandidate(db, metricsFile);
|
|
1048
|
+
|
|
1049
|
+
const report = {
|
|
1050
|
+
filePath,
|
|
1051
|
+
apply,
|
|
1052
|
+
sessions: {
|
|
1053
|
+
candidates: sessionCandidates,
|
|
1054
|
+
deletable: sessionCandidates.filter((candidate) => candidate.eligible).length,
|
|
1055
|
+
deleted: 0,
|
|
1056
|
+
},
|
|
1057
|
+
activeSession: activeCandidate,
|
|
1058
|
+
metrics: metricsCandidate,
|
|
1059
|
+
deletedPaths: [],
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
if (!apply) {
|
|
1063
|
+
return report;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
for (const candidate of sessionCandidates) {
|
|
1067
|
+
if (!candidate.eligible) {
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
fs.unlinkSync(candidate.path);
|
|
1071
|
+
report.sessions.deleted += 1;
|
|
1072
|
+
report.deletedPaths.push(candidate.path);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (activeCandidate?.eligible) {
|
|
1076
|
+
fs.unlinkSync(activeCandidate.path);
|
|
1077
|
+
report.deletedPaths.push(activeCandidate.path);
|
|
1078
|
+
report.activeSession = { ...activeCandidate, deleted: true };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (metricsCandidate?.eligible) {
|
|
1082
|
+
fs.unlinkSync(metricsCandidate.path);
|
|
1083
|
+
report.deletedPaths.push(metricsCandidate.path);
|
|
1084
|
+
report.metrics = { ...metricsCandidate, deleted: true };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (fs.existsSync(sessionsDir) && fs.readdirSync(sessionsDir).length === 0) {
|
|
1088
|
+
fs.rmdirSync(sessionsDir);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
return report;
|
|
1092
|
+
}, { filePath });
|