@massu/core 0.1.1 → 0.1.2
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 +2 -2
- package/dist/hooks/cost-tracker.js +23 -35
- package/dist/hooks/post-edit-context.js +2 -2
- package/dist/hooks/post-tool-use.js +43 -58
- package/dist/hooks/pre-compact.js +23 -38
- package/dist/hooks/pre-delete-check.js +18 -31
- package/dist/hooks/quality-event.js +23 -35
- package/dist/hooks/session-end.js +62 -78
- package/dist/hooks/session-start.js +33 -42
- package/dist/hooks/user-prompt.js +23 -38
- package/package.json +8 -14
- package/src/adr-generator.ts +9 -2
- package/src/analytics.ts +9 -3
- package/src/audit-trail.ts +10 -3
- package/src/cloud-sync.ts +14 -18
- package/src/commands/init.ts +1 -5
- package/src/cost-tracker.ts +11 -6
- package/src/dependency-scorer.ts +9 -2
- package/src/docs-tools.ts +13 -10
- package/src/hooks/post-edit-context.ts +3 -3
- package/src/hooks/session-end.ts +3 -3
- package/src/hooks/session-start.ts +2 -2
- package/src/memory-db.ts +1351 -23
- package/src/memory-tools.ts +14 -15
- package/src/observability-tools.ts +13 -2
- package/src/prompt-analyzer.ts +9 -2
- package/src/regression-detector.ts +9 -3
- package/src/security-scorer.ts +9 -2
- package/src/sentinel-db.ts +43 -88
- package/src/sentinel-tools.ts +8 -11
- package/src/server.ts +1 -2
- package/src/team-knowledge.ts +9 -2
- package/src/tools.ts +771 -35
- package/src/validate-features-runner.ts +0 -1
- package/src/validation-engine.ts +9 -2
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
- package/src/__tests__/adr-generator.test.ts +0 -260
- package/src/__tests__/analytics.test.ts +0 -282
- package/src/__tests__/audit-trail.test.ts +0 -382
- package/src/__tests__/backfill-sessions.test.ts +0 -690
- package/src/__tests__/cli.test.ts +0 -290
- package/src/__tests__/cloud-sync.test.ts +0 -261
- package/src/__tests__/config-sections.test.ts +0 -359
- package/src/__tests__/config.test.ts +0 -732
- package/src/__tests__/cost-tracker.test.ts +0 -348
- package/src/__tests__/db.test.ts +0 -177
- package/src/__tests__/dependency-scorer.test.ts +0 -325
- package/src/__tests__/docs-integration.test.ts +0 -178
- package/src/__tests__/docs-tools.test.ts +0 -199
- package/src/__tests__/domains.test.ts +0 -236
- package/src/__tests__/hooks.test.ts +0 -221
- package/src/__tests__/import-resolver.test.ts +0 -95
- package/src/__tests__/integration/path-traversal.test.ts +0 -134
- package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
- package/src/__tests__/integration/tool-registration.test.ts +0 -146
- package/src/__tests__/memory-db.test.ts +0 -404
- package/src/__tests__/memory-enhancements.test.ts +0 -316
- package/src/__tests__/memory-tools.test.ts +0 -199
- package/src/__tests__/middleware-tree.test.ts +0 -177
- package/src/__tests__/observability-tools.test.ts +0 -595
- package/src/__tests__/observability.test.ts +0 -437
- package/src/__tests__/observation-extractor.test.ts +0 -167
- package/src/__tests__/page-deps.test.ts +0 -60
- package/src/__tests__/prompt-analyzer.test.ts +0 -298
- package/src/__tests__/regression-detector.test.ts +0 -295
- package/src/__tests__/rules.test.ts +0 -87
- package/src/__tests__/schema-mapper.test.ts +0 -29
- package/src/__tests__/security-scorer.test.ts +0 -238
- package/src/__tests__/security-utils.test.ts +0 -175
- package/src/__tests__/sentinel-db.test.ts +0 -491
- package/src/__tests__/sentinel-scanner.test.ts +0 -750
- package/src/__tests__/sentinel-tools.test.ts +0 -324
- package/src/__tests__/sentinel-types.test.ts +0 -750
- package/src/__tests__/server.test.ts +0 -452
- package/src/__tests__/session-archiver.test.ts +0 -524
- package/src/__tests__/session-state-generator.test.ts +0 -900
- package/src/__tests__/team-knowledge.test.ts +0 -327
- package/src/__tests__/tools.test.ts +0 -340
- package/src/__tests__/transcript-parser.test.ts +0 -195
- package/src/__tests__/trpc-index.test.ts +0 -25
- package/src/__tests__/validate-features-runner.test.ts +0 -517
- package/src/__tests__/validation-engine.test.ts +0 -300
- package/src/core-tools.ts +0 -685
- package/src/memory-queries.ts +0 -804
- package/src/memory-schema.ts +0 -546
- package/src/tool-helpers.ts +0 -41
package/src/memory-db.ts
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
2
|
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Memory database connection factory.
|
|
6
|
-
*
|
|
7
|
-
* Split into three files (P3-001 remediation):
|
|
8
|
-
* - memory-db.ts -- Connection factory (this file)
|
|
9
|
-
* - memory-schema.ts -- Schema DDL (initMemorySchema)
|
|
10
|
-
* - memory-queries.ts -- All CRUD query functions
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
4
|
import Database from 'better-sqlite3';
|
|
14
|
-
import { dirname } from 'path';
|
|
5
|
+
import { resolve, dirname, basename } from 'path';
|
|
15
6
|
import { existsSync, mkdirSync } from 'fs';
|
|
16
|
-
import { getResolvedPaths } from './config.ts';
|
|
17
|
-
import { initMemorySchema } from './memory-schema.ts';
|
|
7
|
+
import { getConfig, getResolvedPaths, getProjectRoot } from './config.ts';
|
|
18
8
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Sanitize a user-provided query string for use with SQLite FTS5 MATCH.
|
|
11
|
+
* Wraps each token in double quotes to treat them as literals,
|
|
12
|
+
* preventing FTS5 operator injection (AND, OR, NOT, NEAR, *, etc.).
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeFts5Query(raw: string): string {
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (!trimmed) return '""';
|
|
17
|
+
// Remove any existing double quotes, then wrap each whitespace-separated token
|
|
18
|
+
const tokens = trimmed.replace(/"/g, '').split(/\s+/).filter(Boolean);
|
|
19
|
+
return tokens.map(t => `"${t}"`).join(' ');
|
|
20
|
+
}
|
|
22
21
|
|
|
23
22
|
// ============================================================
|
|
24
|
-
// Memory Database
|
|
23
|
+
// P1-001: Memory Database Schema
|
|
25
24
|
// ============================================================
|
|
26
25
|
|
|
27
|
-
/** Tracks which database paths have already been initialized to avoid redundant DDL. */
|
|
28
|
-
const initializedPaths = new Set<string>();
|
|
29
|
-
|
|
30
26
|
/**
|
|
31
27
|
* Connection to the memory SQLite database.
|
|
32
28
|
* Stores session memory, observations, and observability data.
|
|
@@ -40,9 +36,1341 @@ export function getMemoryDb(): Database.Database {
|
|
|
40
36
|
const db = new Database(dbPath);
|
|
41
37
|
db.pragma('journal_mode = WAL');
|
|
42
38
|
db.pragma('foreign_keys = ON');
|
|
43
|
-
|
|
44
|
-
initMemorySchema(db);
|
|
45
|
-
initializedPaths.add(dbPath);
|
|
46
|
-
}
|
|
39
|
+
initMemorySchema(db);
|
|
47
40
|
return db;
|
|
48
41
|
}
|
|
42
|
+
|
|
43
|
+
function initMemorySchema(db: Database.Database): void {
|
|
44
|
+
db.exec(`
|
|
45
|
+
-- Sessions table (linked to Claude Code session IDs)
|
|
46
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
49
|
+
project TEXT NOT NULL DEFAULT 'my-project',
|
|
50
|
+
git_branch TEXT,
|
|
51
|
+
started_at TEXT NOT NULL,
|
|
52
|
+
started_at_epoch INTEGER NOT NULL,
|
|
53
|
+
ended_at TEXT,
|
|
54
|
+
ended_at_epoch INTEGER,
|
|
55
|
+
status TEXT CHECK(status IN ('active', 'completed', 'abandoned')) NOT NULL DEFAULT 'active',
|
|
56
|
+
plan_file TEXT,
|
|
57
|
+
plan_phase TEXT,
|
|
58
|
+
task_id TEXT
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
|
|
62
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at_epoch DESC);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_task_id ON sessions(task_id);
|
|
64
|
+
|
|
65
|
+
-- Observations table (structured knowledge from tool usage)
|
|
66
|
+
CREATE TABLE IF NOT EXISTS observations (
|
|
67
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
session_id TEXT NOT NULL,
|
|
69
|
+
type TEXT NOT NULL CHECK(type IN (
|
|
70
|
+
'decision', 'bugfix', 'feature', 'refactor', 'discovery',
|
|
71
|
+
'cr_violation', 'vr_check', 'pattern_compliance', 'failed_attempt',
|
|
72
|
+
'file_change', 'incident_near_miss'
|
|
73
|
+
)),
|
|
74
|
+
title TEXT NOT NULL,
|
|
75
|
+
detail TEXT,
|
|
76
|
+
files_involved TEXT DEFAULT '[]',
|
|
77
|
+
plan_item TEXT,
|
|
78
|
+
cr_rule TEXT,
|
|
79
|
+
vr_type TEXT,
|
|
80
|
+
evidence TEXT,
|
|
81
|
+
importance INTEGER NOT NULL DEFAULT 3 CHECK(importance BETWEEN 1 AND 5),
|
|
82
|
+
recurrence_count INTEGER NOT NULL DEFAULT 1,
|
|
83
|
+
original_tokens INTEGER DEFAULT 0,
|
|
84
|
+
created_at TEXT NOT NULL,
|
|
85
|
+
created_at_epoch INTEGER NOT NULL,
|
|
86
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_observations_plan_item ON observations(plan_item);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_observations_cr_rule ON observations(cr_rule);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_observations_importance ON observations(importance DESC);
|
|
95
|
+
`);
|
|
96
|
+
|
|
97
|
+
// FTS5 tables - create separately to handle "already exists" gracefully
|
|
98
|
+
try {
|
|
99
|
+
db.exec(`
|
|
100
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
|
|
101
|
+
title, detail, evidence,
|
|
102
|
+
content='observations',
|
|
103
|
+
content_rowid='id'
|
|
104
|
+
);
|
|
105
|
+
`);
|
|
106
|
+
} catch (_e) {
|
|
107
|
+
// FTS5 table may already exist with different schema - ignore
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// FTS5 sync triggers
|
|
111
|
+
db.exec(`
|
|
112
|
+
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
|
113
|
+
INSERT INTO observations_fts(rowid, title, detail, evidence)
|
|
114
|
+
VALUES (new.id, new.title, new.detail, new.evidence);
|
|
115
|
+
END;
|
|
116
|
+
|
|
117
|
+
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
|
118
|
+
INSERT INTO observations_fts(observations_fts, rowid, title, detail, evidence)
|
|
119
|
+
VALUES ('delete', old.id, old.title, old.detail, old.evidence);
|
|
120
|
+
END;
|
|
121
|
+
|
|
122
|
+
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
|
123
|
+
INSERT INTO observations_fts(observations_fts, rowid, title, detail, evidence)
|
|
124
|
+
VALUES ('delete', old.id, old.title, old.detail, old.evidence);
|
|
125
|
+
INSERT INTO observations_fts(rowid, title, detail, evidence)
|
|
126
|
+
VALUES (new.id, new.title, new.detail, new.evidence);
|
|
127
|
+
END;
|
|
128
|
+
`);
|
|
129
|
+
|
|
130
|
+
// Session summaries
|
|
131
|
+
db.exec(`
|
|
132
|
+
CREATE TABLE IF NOT EXISTS session_summaries (
|
|
133
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
134
|
+
session_id TEXT NOT NULL,
|
|
135
|
+
request TEXT,
|
|
136
|
+
investigated TEXT,
|
|
137
|
+
decisions TEXT,
|
|
138
|
+
completed TEXT,
|
|
139
|
+
failed_attempts TEXT,
|
|
140
|
+
next_steps TEXT,
|
|
141
|
+
files_created TEXT DEFAULT '[]',
|
|
142
|
+
files_modified TEXT DEFAULT '[]',
|
|
143
|
+
verification_results TEXT DEFAULT '{}',
|
|
144
|
+
plan_progress TEXT DEFAULT '{}',
|
|
145
|
+
created_at TEXT NOT NULL,
|
|
146
|
+
created_at_epoch INTEGER NOT NULL,
|
|
147
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_summaries_session ON session_summaries(session_id);
|
|
151
|
+
`);
|
|
152
|
+
|
|
153
|
+
// User prompts
|
|
154
|
+
db.exec(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS user_prompts (
|
|
156
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
157
|
+
session_id TEXT NOT NULL,
|
|
158
|
+
prompt_text TEXT NOT NULL,
|
|
159
|
+
prompt_number INTEGER NOT NULL DEFAULT 1,
|
|
160
|
+
created_at TEXT NOT NULL,
|
|
161
|
+
created_at_epoch INTEGER NOT NULL,
|
|
162
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
163
|
+
);
|
|
164
|
+
`);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
db.exec(`
|
|
168
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS user_prompts_fts USING fts5(
|
|
169
|
+
prompt_text,
|
|
170
|
+
content='user_prompts',
|
|
171
|
+
content_rowid='id'
|
|
172
|
+
);
|
|
173
|
+
`);
|
|
174
|
+
} catch (_e) {
|
|
175
|
+
// FTS5 table may already exist
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
db.exec(`
|
|
179
|
+
CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON user_prompts BEGIN
|
|
180
|
+
INSERT INTO user_prompts_fts(rowid, prompt_text) VALUES (new.id, new.prompt_text);
|
|
181
|
+
END;
|
|
182
|
+
|
|
183
|
+
CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON user_prompts BEGIN
|
|
184
|
+
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
|
|
185
|
+
VALUES ('delete', old.id, old.prompt_text);
|
|
186
|
+
END;
|
|
187
|
+
`);
|
|
188
|
+
|
|
189
|
+
// Metadata
|
|
190
|
+
db.exec(`
|
|
191
|
+
CREATE TABLE IF NOT EXISTS memory_meta (
|
|
192
|
+
key TEXT PRIMARY KEY,
|
|
193
|
+
value TEXT NOT NULL
|
|
194
|
+
);
|
|
195
|
+
`);
|
|
196
|
+
|
|
197
|
+
// ============================================================
|
|
198
|
+
// Observability tables (P1-001, P1-002)
|
|
199
|
+
// ============================================================
|
|
200
|
+
|
|
201
|
+
// P1-001: Conversation turns (full session replay)
|
|
202
|
+
db.exec(`
|
|
203
|
+
CREATE TABLE IF NOT EXISTS conversation_turns (
|
|
204
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
205
|
+
session_id TEXT NOT NULL,
|
|
206
|
+
turn_number INTEGER NOT NULL,
|
|
207
|
+
user_prompt TEXT NOT NULL,
|
|
208
|
+
assistant_response TEXT,
|
|
209
|
+
tool_calls_json TEXT,
|
|
210
|
+
tool_call_count INTEGER DEFAULT 0,
|
|
211
|
+
model_used TEXT,
|
|
212
|
+
duration_ms INTEGER,
|
|
213
|
+
prompt_tokens INTEGER,
|
|
214
|
+
response_tokens INTEGER,
|
|
215
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
216
|
+
created_at_epoch INTEGER DEFAULT (unixepoch()),
|
|
217
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
CREATE INDEX IF NOT EXISTS idx_ct_session ON conversation_turns(session_id);
|
|
221
|
+
CREATE INDEX IF NOT EXISTS idx_ct_created ON conversation_turns(created_at DESC);
|
|
222
|
+
CREATE INDEX IF NOT EXISTS idx_ct_turn ON conversation_turns(session_id, turn_number);
|
|
223
|
+
`);
|
|
224
|
+
|
|
225
|
+
// P1-002: Tool call details (analytics)
|
|
226
|
+
db.exec(`
|
|
227
|
+
CREATE TABLE IF NOT EXISTS tool_call_details (
|
|
228
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
229
|
+
session_id TEXT NOT NULL,
|
|
230
|
+
turn_number INTEGER NOT NULL,
|
|
231
|
+
tool_name TEXT NOT NULL,
|
|
232
|
+
tool_input_summary TEXT,
|
|
233
|
+
tool_input_size INTEGER,
|
|
234
|
+
tool_output_size INTEGER,
|
|
235
|
+
tool_success INTEGER DEFAULT 1,
|
|
236
|
+
duration_ms INTEGER,
|
|
237
|
+
files_involved TEXT,
|
|
238
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
239
|
+
created_at_epoch INTEGER DEFAULT (unixepoch()),
|
|
240
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
CREATE INDEX IF NOT EXISTS idx_tcd_session ON tool_call_details(session_id);
|
|
244
|
+
CREATE INDEX IF NOT EXISTS idx_tcd_tool ON tool_call_details(tool_name);
|
|
245
|
+
CREATE INDEX IF NOT EXISTS idx_tcd_created ON tool_call_details(created_at DESC);
|
|
246
|
+
`);
|
|
247
|
+
|
|
248
|
+
// P1-003: FTS5 index for conversation turns
|
|
249
|
+
try {
|
|
250
|
+
db.exec(`
|
|
251
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS conversation_turns_fts USING fts5(
|
|
252
|
+
user_prompt,
|
|
253
|
+
assistant_response,
|
|
254
|
+
content=conversation_turns,
|
|
255
|
+
content_rowid=id
|
|
256
|
+
);
|
|
257
|
+
`);
|
|
258
|
+
} catch (_e) {
|
|
259
|
+
// FTS5 table may already exist with different schema
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// FTS5 sync triggers for conversation_turns
|
|
263
|
+
db.exec(`
|
|
264
|
+
CREATE TRIGGER IF NOT EXISTS ct_fts_insert AFTER INSERT ON conversation_turns BEGIN
|
|
265
|
+
INSERT INTO conversation_turns_fts(rowid, user_prompt, assistant_response)
|
|
266
|
+
VALUES (new.id, new.user_prompt, new.assistant_response);
|
|
267
|
+
END;
|
|
268
|
+
|
|
269
|
+
CREATE TRIGGER IF NOT EXISTS ct_fts_delete AFTER DELETE ON conversation_turns BEGIN
|
|
270
|
+
INSERT INTO conversation_turns_fts(conversation_turns_fts, rowid, user_prompt, assistant_response)
|
|
271
|
+
VALUES ('delete', old.id, old.user_prompt, old.assistant_response);
|
|
272
|
+
END;
|
|
273
|
+
|
|
274
|
+
CREATE TRIGGER IF NOT EXISTS ct_fts_update AFTER UPDATE ON conversation_turns BEGIN
|
|
275
|
+
INSERT INTO conversation_turns_fts(conversation_turns_fts, rowid, user_prompt, assistant_response)
|
|
276
|
+
VALUES ('delete', old.id, old.user_prompt, old.assistant_response);
|
|
277
|
+
INSERT INTO conversation_turns_fts(rowid, user_prompt, assistant_response)
|
|
278
|
+
VALUES (new.id, new.user_prompt, new.assistant_response);
|
|
279
|
+
END;
|
|
280
|
+
`);
|
|
281
|
+
|
|
282
|
+
// ============================================================
|
|
283
|
+
// PLAN-02 Enhancement Tables (Analytics, Governance, Security, Team, Regression)
|
|
284
|
+
// ============================================================
|
|
285
|
+
|
|
286
|
+
// P1-001: Quality scores per session
|
|
287
|
+
db.exec(`
|
|
288
|
+
CREATE TABLE IF NOT EXISTS session_quality_scores (
|
|
289
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
290
|
+
session_id TEXT NOT NULL UNIQUE,
|
|
291
|
+
project TEXT NOT NULL DEFAULT 'my-project',
|
|
292
|
+
score INTEGER NOT NULL DEFAULT 100,
|
|
293
|
+
security_score INTEGER NOT NULL DEFAULT 100,
|
|
294
|
+
architecture_score INTEGER NOT NULL DEFAULT 100,
|
|
295
|
+
coupling_score INTEGER NOT NULL DEFAULT 100,
|
|
296
|
+
test_score INTEGER NOT NULL DEFAULT 100,
|
|
297
|
+
rule_compliance_score INTEGER NOT NULL DEFAULT 100,
|
|
298
|
+
observations_total INTEGER NOT NULL DEFAULT 0,
|
|
299
|
+
bugs_found INTEGER NOT NULL DEFAULT 0,
|
|
300
|
+
bugs_fixed INTEGER NOT NULL DEFAULT 0,
|
|
301
|
+
vr_checks_passed INTEGER NOT NULL DEFAULT 0,
|
|
302
|
+
vr_checks_failed INTEGER NOT NULL DEFAULT 0,
|
|
303
|
+
incidents_triggered INTEGER NOT NULL DEFAULT 0,
|
|
304
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
305
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
306
|
+
);
|
|
307
|
+
CREATE INDEX IF NOT EXISTS idx_sqs_session ON session_quality_scores(session_id);
|
|
308
|
+
CREATE INDEX IF NOT EXISTS idx_sqs_project ON session_quality_scores(project);
|
|
309
|
+
`);
|
|
310
|
+
|
|
311
|
+
// P1-002: Cost tracking per session
|
|
312
|
+
db.exec(`
|
|
313
|
+
CREATE TABLE IF NOT EXISTS session_costs (
|
|
314
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
315
|
+
session_id TEXT NOT NULL UNIQUE,
|
|
316
|
+
project TEXT NOT NULL DEFAULT 'my-project',
|
|
317
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
318
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
319
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
320
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
321
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
322
|
+
estimated_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
323
|
+
model TEXT,
|
|
324
|
+
duration_minutes REAL NOT NULL DEFAULT 0.0,
|
|
325
|
+
tool_calls INTEGER NOT NULL DEFAULT 0,
|
|
326
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
327
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
328
|
+
);
|
|
329
|
+
CREATE INDEX IF NOT EXISTS idx_sc_session ON session_costs(session_id);
|
|
330
|
+
`);
|
|
331
|
+
|
|
332
|
+
// P1-002: Feature cost attribution
|
|
333
|
+
db.exec(`
|
|
334
|
+
CREATE TABLE IF NOT EXISTS feature_costs (
|
|
335
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
336
|
+
feature_key TEXT NOT NULL,
|
|
337
|
+
session_id TEXT NOT NULL,
|
|
338
|
+
tokens_used INTEGER NOT NULL DEFAULT 0,
|
|
339
|
+
estimated_cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
340
|
+
commit_hash TEXT,
|
|
341
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
342
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
343
|
+
);
|
|
344
|
+
CREATE INDEX IF NOT EXISTS idx_fc_feature ON feature_costs(feature_key);
|
|
345
|
+
CREATE INDEX IF NOT EXISTS idx_fc_session ON feature_costs(session_id);
|
|
346
|
+
`);
|
|
347
|
+
|
|
348
|
+
// P1-003: Prompt effectiveness outcomes
|
|
349
|
+
db.exec(`
|
|
350
|
+
CREATE TABLE IF NOT EXISTS prompt_outcomes (
|
|
351
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
352
|
+
session_id TEXT NOT NULL,
|
|
353
|
+
prompt_hash TEXT NOT NULL,
|
|
354
|
+
prompt_text TEXT NOT NULL,
|
|
355
|
+
prompt_category TEXT NOT NULL DEFAULT 'feature',
|
|
356
|
+
word_count INTEGER NOT NULL DEFAULT 0,
|
|
357
|
+
outcome TEXT NOT NULL DEFAULT 'success' CHECK(outcome IN ('success', 'partial', 'failure', 'abandoned')),
|
|
358
|
+
corrections_needed INTEGER NOT NULL DEFAULT 0,
|
|
359
|
+
follow_up_prompts INTEGER NOT NULL DEFAULT 0,
|
|
360
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
361
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
362
|
+
);
|
|
363
|
+
CREATE INDEX IF NOT EXISTS idx_po_session ON prompt_outcomes(session_id);
|
|
364
|
+
CREATE INDEX IF NOT EXISTS idx_po_category ON prompt_outcomes(prompt_category);
|
|
365
|
+
`);
|
|
366
|
+
|
|
367
|
+
// P2-001: Compliance audit log
|
|
368
|
+
db.exec(`
|
|
369
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
370
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
371
|
+
session_id TEXT NOT NULL,
|
|
372
|
+
timestamp TEXT DEFAULT (datetime('now')),
|
|
373
|
+
event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
|
|
374
|
+
actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
|
|
375
|
+
model_id TEXT,
|
|
376
|
+
file_path TEXT,
|
|
377
|
+
change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
|
|
378
|
+
rules_in_effect TEXT,
|
|
379
|
+
approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
|
|
380
|
+
evidence TEXT,
|
|
381
|
+
metadata TEXT,
|
|
382
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
383
|
+
);
|
|
384
|
+
CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
|
|
385
|
+
CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
|
|
386
|
+
CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
|
|
387
|
+
CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
|
|
388
|
+
`);
|
|
389
|
+
|
|
390
|
+
// P2-002: Validation results
|
|
391
|
+
db.exec(`
|
|
392
|
+
CREATE TABLE IF NOT EXISTS validation_results (
|
|
393
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
394
|
+
session_id TEXT NOT NULL,
|
|
395
|
+
file_path TEXT NOT NULL,
|
|
396
|
+
validation_type TEXT NOT NULL,
|
|
397
|
+
passed INTEGER NOT NULL DEFAULT 1,
|
|
398
|
+
details TEXT,
|
|
399
|
+
rules_violated TEXT,
|
|
400
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
401
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
402
|
+
);
|
|
403
|
+
CREATE INDEX IF NOT EXISTS idx_vr_session ON validation_results(session_id);
|
|
404
|
+
CREATE INDEX IF NOT EXISTS idx_vr_file ON validation_results(file_path);
|
|
405
|
+
`);
|
|
406
|
+
|
|
407
|
+
// P2-003: Architecture decisions
|
|
408
|
+
db.exec(`
|
|
409
|
+
CREATE TABLE IF NOT EXISTS architecture_decisions (
|
|
410
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
411
|
+
session_id TEXT NOT NULL,
|
|
412
|
+
title TEXT NOT NULL,
|
|
413
|
+
context TEXT,
|
|
414
|
+
decision TEXT NOT NULL,
|
|
415
|
+
status TEXT NOT NULL DEFAULT 'accepted' CHECK(status IN ('accepted', 'superseded', 'deprecated')),
|
|
416
|
+
alternatives TEXT,
|
|
417
|
+
consequences TEXT,
|
|
418
|
+
affected_files TEXT,
|
|
419
|
+
commit_hash TEXT,
|
|
420
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
421
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
422
|
+
);
|
|
423
|
+
CREATE INDEX IF NOT EXISTS idx_ad_session ON architecture_decisions(session_id);
|
|
424
|
+
CREATE INDEX IF NOT EXISTS idx_ad_status ON architecture_decisions(status);
|
|
425
|
+
`);
|
|
426
|
+
|
|
427
|
+
// P3-001: Security scores per file
|
|
428
|
+
db.exec(`
|
|
429
|
+
CREATE TABLE IF NOT EXISTS security_scores (
|
|
430
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
431
|
+
session_id TEXT NOT NULL,
|
|
432
|
+
file_path TEXT NOT NULL,
|
|
433
|
+
risk_score INTEGER NOT NULL DEFAULT 0,
|
|
434
|
+
findings TEXT,
|
|
435
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
436
|
+
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
437
|
+
);
|
|
438
|
+
CREATE INDEX IF NOT EXISTS idx_ss_session ON security_scores(session_id);
|
|
439
|
+
CREATE INDEX IF NOT EXISTS idx_ss_file ON security_scores(file_path);
|
|
440
|
+
`);
|
|
441
|
+
|
|
442
|
+
// P3-002: Dependency assessments
|
|
443
|
+
db.exec(`
|
|
444
|
+
CREATE TABLE IF NOT EXISTS dependency_assessments (
|
|
445
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
446
|
+
package_name TEXT NOT NULL,
|
|
447
|
+
version TEXT,
|
|
448
|
+
risk_score INTEGER NOT NULL DEFAULT 0,
|
|
449
|
+
vulnerabilities INTEGER NOT NULL DEFAULT 0,
|
|
450
|
+
last_publish_days INTEGER,
|
|
451
|
+
weekly_downloads INTEGER,
|
|
452
|
+
license TEXT,
|
|
453
|
+
bundle_size_kb INTEGER,
|
|
454
|
+
previous_removals INTEGER NOT NULL DEFAULT 0,
|
|
455
|
+
assessed_at TEXT DEFAULT (datetime('now'))
|
|
456
|
+
);
|
|
457
|
+
CREATE INDEX IF NOT EXISTS idx_da_package ON dependency_assessments(package_name);
|
|
458
|
+
`);
|
|
459
|
+
|
|
460
|
+
// P4-001: Developer expertise
|
|
461
|
+
db.exec(`
|
|
462
|
+
CREATE TABLE IF NOT EXISTS developer_expertise (
|
|
463
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
464
|
+
developer_id TEXT NOT NULL,
|
|
465
|
+
module TEXT NOT NULL,
|
|
466
|
+
session_count INTEGER NOT NULL DEFAULT 0,
|
|
467
|
+
observation_count INTEGER NOT NULL DEFAULT 0,
|
|
468
|
+
expertise_score INTEGER NOT NULL DEFAULT 0,
|
|
469
|
+
last_active TEXT DEFAULT (datetime('now')),
|
|
470
|
+
UNIQUE(developer_id, module)
|
|
471
|
+
);
|
|
472
|
+
CREATE INDEX IF NOT EXISTS idx_de_developer ON developer_expertise(developer_id);
|
|
473
|
+
CREATE INDEX IF NOT EXISTS idx_de_module ON developer_expertise(module);
|
|
474
|
+
`);
|
|
475
|
+
|
|
476
|
+
// P4-001: Shared observations for team knowledge
|
|
477
|
+
db.exec(`
|
|
478
|
+
CREATE TABLE IF NOT EXISTS shared_observations (
|
|
479
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
480
|
+
original_id INTEGER,
|
|
481
|
+
developer_id TEXT NOT NULL,
|
|
482
|
+
project TEXT NOT NULL,
|
|
483
|
+
observation_type TEXT NOT NULL,
|
|
484
|
+
summary TEXT NOT NULL,
|
|
485
|
+
file_path TEXT,
|
|
486
|
+
module TEXT,
|
|
487
|
+
severity INTEGER NOT NULL DEFAULT 3,
|
|
488
|
+
is_shared INTEGER NOT NULL DEFAULT 0,
|
|
489
|
+
shared_at TEXT,
|
|
490
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
491
|
+
);
|
|
492
|
+
CREATE INDEX IF NOT EXISTS idx_so_developer ON shared_observations(developer_id);
|
|
493
|
+
CREATE INDEX IF NOT EXISTS idx_so_file ON shared_observations(file_path);
|
|
494
|
+
CREATE INDEX IF NOT EXISTS idx_so_module ON shared_observations(module);
|
|
495
|
+
`);
|
|
496
|
+
|
|
497
|
+
// P4-001: Knowledge conflicts
|
|
498
|
+
db.exec(`
|
|
499
|
+
CREATE TABLE IF NOT EXISTS knowledge_conflicts (
|
|
500
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
501
|
+
file_path TEXT NOT NULL,
|
|
502
|
+
developer_a TEXT NOT NULL,
|
|
503
|
+
developer_b TEXT NOT NULL,
|
|
504
|
+
conflict_type TEXT NOT NULL DEFAULT 'concurrent_edit',
|
|
505
|
+
resolved INTEGER NOT NULL DEFAULT 0,
|
|
506
|
+
detected_at TEXT DEFAULT (datetime('now'))
|
|
507
|
+
);
|
|
508
|
+
CREATE INDEX IF NOT EXISTS idx_kc_file ON knowledge_conflicts(file_path);
|
|
509
|
+
`);
|
|
510
|
+
|
|
511
|
+
// P4-002: Feature health tracking
|
|
512
|
+
db.exec(`
|
|
513
|
+
CREATE TABLE IF NOT EXISTS feature_health (
|
|
514
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
515
|
+
feature_key TEXT NOT NULL UNIQUE,
|
|
516
|
+
health_score INTEGER NOT NULL DEFAULT 100,
|
|
517
|
+
tests_passing INTEGER NOT NULL DEFAULT 0,
|
|
518
|
+
tests_failing INTEGER NOT NULL DEFAULT 0,
|
|
519
|
+
test_coverage_pct REAL,
|
|
520
|
+
modifications_since_test INTEGER NOT NULL DEFAULT 0,
|
|
521
|
+
last_modified TEXT,
|
|
522
|
+
last_tested TEXT,
|
|
523
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
524
|
+
);
|
|
525
|
+
CREATE INDEX IF NOT EXISTS idx_fh_feature ON feature_health(feature_key);
|
|
526
|
+
CREATE INDEX IF NOT EXISTS idx_fh_health ON feature_health(health_score);
|
|
527
|
+
`);
|
|
528
|
+
|
|
529
|
+
// ============================================================
|
|
530
|
+
// Hook Tables (cost-tracker.ts, quality-event.ts)
|
|
531
|
+
// ============================================================
|
|
532
|
+
|
|
533
|
+
// Tool-level cost events (one row per tool call)
|
|
534
|
+
db.exec(`
|
|
535
|
+
CREATE TABLE IF NOT EXISTS tool_cost_events (
|
|
536
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
537
|
+
session_id TEXT NOT NULL,
|
|
538
|
+
tool_name TEXT NOT NULL,
|
|
539
|
+
estimated_input_tokens INTEGER DEFAULT 0,
|
|
540
|
+
estimated_output_tokens INTEGER DEFAULT 0,
|
|
541
|
+
model TEXT DEFAULT '',
|
|
542
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
543
|
+
);
|
|
544
|
+
CREATE INDEX IF NOT EXISTS idx_tce_session ON tool_cost_events(session_id);
|
|
545
|
+
CREATE INDEX IF NOT EXISTS idx_tce_tool ON tool_cost_events(tool_name);
|
|
546
|
+
CREATE INDEX IF NOT EXISTS idx_tce_created ON tool_cost_events(created_at DESC);
|
|
547
|
+
`);
|
|
548
|
+
|
|
549
|
+
// Quality signal events (test failures, type errors, build failures)
|
|
550
|
+
db.exec(`
|
|
551
|
+
CREATE TABLE IF NOT EXISTS quality_events (
|
|
552
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
553
|
+
session_id TEXT NOT NULL,
|
|
554
|
+
event_type TEXT NOT NULL,
|
|
555
|
+
tool_name TEXT NOT NULL,
|
|
556
|
+
details TEXT DEFAULT '',
|
|
557
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
558
|
+
);
|
|
559
|
+
CREATE INDEX IF NOT EXISTS idx_qe_session ON quality_events(session_id);
|
|
560
|
+
CREATE INDEX IF NOT EXISTS idx_qe_event_type ON quality_events(event_type);
|
|
561
|
+
CREATE INDEX IF NOT EXISTS idx_qe_created ON quality_events(created_at DESC);
|
|
562
|
+
`);
|
|
563
|
+
|
|
564
|
+
// ============================================================
|
|
565
|
+
// Cloud Sync: Pending sync queue (offline resilience)
|
|
566
|
+
// ============================================================
|
|
567
|
+
db.exec(`
|
|
568
|
+
CREATE TABLE IF NOT EXISTS pending_sync (
|
|
569
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
570
|
+
payload TEXT NOT NULL,
|
|
571
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
572
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
573
|
+
last_error TEXT
|
|
574
|
+
);
|
|
575
|
+
CREATE INDEX IF NOT EXISTS idx_pending_sync_created ON pending_sync(created_at ASC);
|
|
576
|
+
`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ============================================================
|
|
580
|
+
// Cloud Sync: Queue Functions
|
|
581
|
+
// ============================================================
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Enqueue a sync payload for later retry.
|
|
585
|
+
*/
|
|
586
|
+
export function enqueueSyncPayload(db: Database.Database, payload: string): void {
|
|
587
|
+
db.prepare('INSERT INTO pending_sync (payload) VALUES (?)').run(payload);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Dequeue pending sync items (oldest first).
|
|
592
|
+
* Items with retry_count >= 10 are silently discarded to prevent infinite accumulation.
|
|
593
|
+
*/
|
|
594
|
+
export function dequeuePendingSync(
|
|
595
|
+
db: Database.Database,
|
|
596
|
+
limit: number = 10
|
|
597
|
+
): Array<{ id: number; payload: string; retry_count: number }> {
|
|
598
|
+
// First, discard items that have exceeded max retries
|
|
599
|
+
const stale = db.prepare(
|
|
600
|
+
'SELECT id FROM pending_sync WHERE retry_count >= 10'
|
|
601
|
+
).all() as Array<{ id: number }>;
|
|
602
|
+
if (stale.length > 0) {
|
|
603
|
+
const ids = stale.map(s => s.id);
|
|
604
|
+
db.prepare(`DELETE FROM pending_sync WHERE id IN (${ids.map(() => '?').join(',')})`).run(...ids);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return db.prepare(
|
|
608
|
+
'SELECT id, payload, retry_count FROM pending_sync ORDER BY created_at ASC LIMIT ?'
|
|
609
|
+
).all(limit) as Array<{ id: number; payload: string; retry_count: number }>;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Remove a successfully synced item from the queue.
|
|
614
|
+
*/
|
|
615
|
+
export function removePendingSync(db: Database.Database, id: number): void {
|
|
616
|
+
db.prepare('DELETE FROM pending_sync WHERE id = ?').run(id);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Increment retry count and record the last error for a failed sync attempt.
|
|
621
|
+
*/
|
|
622
|
+
export function incrementRetryCount(db: Database.Database, id: number, error: string): void {
|
|
623
|
+
db.prepare(
|
|
624
|
+
'UPDATE pending_sync SET retry_count = retry_count + 1, last_error = ? WHERE id = ?'
|
|
625
|
+
).run(error, id);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ============================================================
|
|
629
|
+
// P1-002: Database Access Functions (19 functions)
|
|
630
|
+
// ============================================================
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Auto-assign importance score based on observation type and optional VR result.
|
|
634
|
+
* Scale: 5=decision/failed_attempt, 4=cr_violation/vr_check(FAIL),
|
|
635
|
+
* 3=feature/bugfix, 2=vr_check(PASS)/refactor, 1=file_change/discovery
|
|
636
|
+
*/
|
|
637
|
+
export function assignImportance(type: string, vrResult?: string): number {
|
|
638
|
+
switch (type) {
|
|
639
|
+
case 'decision':
|
|
640
|
+
case 'failed_attempt':
|
|
641
|
+
return 5;
|
|
642
|
+
case 'cr_violation':
|
|
643
|
+
case 'incident_near_miss':
|
|
644
|
+
return 4;
|
|
645
|
+
case 'vr_check':
|
|
646
|
+
return vrResult === 'PASS' ? 2 : 4;
|
|
647
|
+
case 'pattern_compliance':
|
|
648
|
+
return vrResult === 'PASS' ? 2 : 4;
|
|
649
|
+
case 'feature':
|
|
650
|
+
case 'bugfix':
|
|
651
|
+
return 3;
|
|
652
|
+
case 'refactor':
|
|
653
|
+
return 2;
|
|
654
|
+
case 'file_change':
|
|
655
|
+
case 'discovery':
|
|
656
|
+
return 1;
|
|
657
|
+
default:
|
|
658
|
+
return 3;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Derive task_id from plan file path.
|
|
664
|
+
* Sessions working on the same plan file share a task_id.
|
|
665
|
+
*/
|
|
666
|
+
export function autoDetectTaskId(planFile: string | null | undefined): string | null {
|
|
667
|
+
if (!planFile) return null;
|
|
668
|
+
// Use the plan filename without extension as task_id
|
|
669
|
+
// e.g., "/path/to/2026-01-30-massu-memory.md" -> "2026-01-30-massu-memory"
|
|
670
|
+
const base = basename(planFile);
|
|
671
|
+
return base.replace(/\.md$/, '');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export interface CreateSessionOpts {
|
|
675
|
+
branch?: string;
|
|
676
|
+
planFile?: string;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Create a session (INSERT OR IGNORE for idempotency).
|
|
681
|
+
*/
|
|
682
|
+
export function createSession(db: Database.Database, sessionId: string, opts?: CreateSessionOpts): void {
|
|
683
|
+
const now = new Date();
|
|
684
|
+
const taskId = autoDetectTaskId(opts?.planFile);
|
|
685
|
+
db.prepare(`
|
|
686
|
+
INSERT OR IGNORE INTO sessions (session_id, git_branch, plan_file, task_id, started_at, started_at_epoch)
|
|
687
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
688
|
+
`).run(sessionId, opts?.branch ?? null, opts?.planFile ?? null, taskId, now.toISOString(), Math.floor(now.getTime() / 1000));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* End a session by updating status and ended_at.
|
|
693
|
+
*/
|
|
694
|
+
export function endSession(db: Database.Database, sessionId: string, status: 'completed' | 'abandoned' = 'completed'): void {
|
|
695
|
+
const now = new Date();
|
|
696
|
+
db.prepare(`
|
|
697
|
+
UPDATE sessions SET status = ?, ended_at = ?, ended_at_epoch = ? WHERE session_id = ?
|
|
698
|
+
`).run(status, now.toISOString(), Math.floor(now.getTime() / 1000), sessionId);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface AddObservationOpts {
|
|
702
|
+
filesInvolved?: string[];
|
|
703
|
+
planItem?: string;
|
|
704
|
+
crRule?: string;
|
|
705
|
+
vrType?: string;
|
|
706
|
+
evidence?: string;
|
|
707
|
+
importance?: number;
|
|
708
|
+
originalTokens?: number;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Insert an observation into the memory DB.
|
|
713
|
+
*/
|
|
714
|
+
export function addObservation(
|
|
715
|
+
db: Database.Database,
|
|
716
|
+
sessionId: string,
|
|
717
|
+
type: string,
|
|
718
|
+
title: string,
|
|
719
|
+
detail: string | null,
|
|
720
|
+
opts?: AddObservationOpts
|
|
721
|
+
): number {
|
|
722
|
+
const now = new Date();
|
|
723
|
+
const importance = opts?.importance ?? assignImportance(type, opts?.evidence?.includes('PASS') ? 'PASS' : undefined);
|
|
724
|
+
const result = db.prepare(`
|
|
725
|
+
INSERT INTO observations (session_id, type, title, detail, files_involved, plan_item, cr_rule, vr_type, evidence, importance, original_tokens, created_at, created_at_epoch)
|
|
726
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
727
|
+
`).run(
|
|
728
|
+
sessionId, type, title, detail,
|
|
729
|
+
JSON.stringify(opts?.filesInvolved ?? []),
|
|
730
|
+
opts?.planItem ?? null,
|
|
731
|
+
opts?.crRule ?? null,
|
|
732
|
+
opts?.vrType ?? null,
|
|
733
|
+
opts?.evidence ?? null,
|
|
734
|
+
importance,
|
|
735
|
+
opts?.originalTokens ?? 0,
|
|
736
|
+
now.toISOString(),
|
|
737
|
+
Math.floor(now.getTime() / 1000)
|
|
738
|
+
);
|
|
739
|
+
return Number(result.lastInsertRowid);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export interface SessionSummary {
|
|
743
|
+
request?: string;
|
|
744
|
+
investigated?: string;
|
|
745
|
+
decisions?: string;
|
|
746
|
+
completed?: string;
|
|
747
|
+
failedAttempts?: string;
|
|
748
|
+
nextSteps?: string;
|
|
749
|
+
filesCreated?: string[];
|
|
750
|
+
filesModified?: string[];
|
|
751
|
+
verificationResults?: Record<string, string>;
|
|
752
|
+
planProgress?: Record<string, string>;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Insert a session summary.
|
|
757
|
+
*/
|
|
758
|
+
export function addSummary(db: Database.Database, sessionId: string, summary: SessionSummary): void {
|
|
759
|
+
const now = new Date();
|
|
760
|
+
db.prepare(`
|
|
761
|
+
INSERT INTO session_summaries (session_id, request, investigated, decisions, completed, failed_attempts, next_steps, files_created, files_modified, verification_results, plan_progress, created_at, created_at_epoch)
|
|
762
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
763
|
+
`).run(
|
|
764
|
+
sessionId,
|
|
765
|
+
summary.request ?? null,
|
|
766
|
+
summary.investigated ?? null,
|
|
767
|
+
summary.decisions ?? null,
|
|
768
|
+
summary.completed ?? null,
|
|
769
|
+
summary.failedAttempts ?? null,
|
|
770
|
+
summary.nextSteps ?? null,
|
|
771
|
+
JSON.stringify(summary.filesCreated ?? []),
|
|
772
|
+
JSON.stringify(summary.filesModified ?? []),
|
|
773
|
+
JSON.stringify(summary.verificationResults ?? {}),
|
|
774
|
+
JSON.stringify(summary.planProgress ?? {}),
|
|
775
|
+
now.toISOString(),
|
|
776
|
+
Math.floor(now.getTime() / 1000)
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Insert a user prompt.
|
|
782
|
+
*/
|
|
783
|
+
export function addUserPrompt(db: Database.Database, sessionId: string, text: string, promptNumber: number): void {
|
|
784
|
+
const now = new Date();
|
|
785
|
+
db.prepare(`
|
|
786
|
+
INSERT INTO user_prompts (session_id, prompt_text, prompt_number, created_at, created_at_epoch)
|
|
787
|
+
VALUES (?, ?, ?, ?, ?)
|
|
788
|
+
`).run(sessionId, text, promptNumber, now.toISOString(), Math.floor(now.getTime() / 1000));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export interface SearchOpts {
|
|
792
|
+
type?: string;
|
|
793
|
+
crRule?: string;
|
|
794
|
+
dateFrom?: string;
|
|
795
|
+
limit?: number;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* FTS5 search on observations + user_prompts.
|
|
800
|
+
*/
|
|
801
|
+
export function searchObservations(db: Database.Database, query: string, opts?: SearchOpts): Array<{
|
|
802
|
+
id: number;
|
|
803
|
+
type: string;
|
|
804
|
+
title: string;
|
|
805
|
+
created_at: string;
|
|
806
|
+
session_id: string;
|
|
807
|
+
importance: number;
|
|
808
|
+
rank: number;
|
|
809
|
+
}> {
|
|
810
|
+
const limit = opts?.limit ?? 20;
|
|
811
|
+
let sql = `
|
|
812
|
+
SELECT o.id, o.type, o.title, o.created_at, o.session_id, o.importance,
|
|
813
|
+
rank
|
|
814
|
+
FROM observations_fts
|
|
815
|
+
JOIN observations o ON observations_fts.rowid = o.id
|
|
816
|
+
WHERE observations_fts MATCH ?
|
|
817
|
+
`;
|
|
818
|
+
const params: (string | number)[] = [sanitizeFts5Query(query)];
|
|
819
|
+
|
|
820
|
+
if (opts?.type) {
|
|
821
|
+
sql += ' AND o.type = ?';
|
|
822
|
+
params.push(opts.type);
|
|
823
|
+
}
|
|
824
|
+
if (opts?.crRule) {
|
|
825
|
+
sql += ' AND o.cr_rule = ?';
|
|
826
|
+
params.push(opts.crRule);
|
|
827
|
+
}
|
|
828
|
+
if (opts?.dateFrom) {
|
|
829
|
+
sql += ' AND o.created_at >= ?';
|
|
830
|
+
params.push(opts.dateFrom);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
sql += ' ORDER BY rank LIMIT ?';
|
|
834
|
+
params.push(limit);
|
|
835
|
+
|
|
836
|
+
return db.prepare(sql).all(...params) as Array<{
|
|
837
|
+
id: number;
|
|
838
|
+
type: string;
|
|
839
|
+
title: string;
|
|
840
|
+
created_at: string;
|
|
841
|
+
session_id: string;
|
|
842
|
+
importance: number;
|
|
843
|
+
rank: number;
|
|
844
|
+
}>;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Get recent observations, optionally filtered by session.
|
|
849
|
+
*/
|
|
850
|
+
export function getRecentObservations(db: Database.Database, limit: number = 20, sessionId?: string): Array<{
|
|
851
|
+
id: number;
|
|
852
|
+
type: string;
|
|
853
|
+
title: string;
|
|
854
|
+
detail: string | null;
|
|
855
|
+
importance: number;
|
|
856
|
+
created_at: string;
|
|
857
|
+
session_id: string;
|
|
858
|
+
}> {
|
|
859
|
+
if (sessionId) {
|
|
860
|
+
return db.prepare(`
|
|
861
|
+
SELECT id, type, title, detail, importance, created_at, session_id
|
|
862
|
+
FROM observations WHERE session_id = ?
|
|
863
|
+
ORDER BY created_at_epoch DESC LIMIT ?
|
|
864
|
+
`).all(sessionId, limit) as Array<{
|
|
865
|
+
id: number; type: string; title: string; detail: string | null;
|
|
866
|
+
importance: number; created_at: string; session_id: string;
|
|
867
|
+
}>;
|
|
868
|
+
}
|
|
869
|
+
return db.prepare(`
|
|
870
|
+
SELECT id, type, title, detail, importance, created_at, session_id
|
|
871
|
+
FROM observations
|
|
872
|
+
ORDER BY created_at_epoch DESC LIMIT ?
|
|
873
|
+
`).all(limit) as Array<{
|
|
874
|
+
id: number; type: string; title: string; detail: string | null;
|
|
875
|
+
importance: number; created_at: string; session_id: string;
|
|
876
|
+
}>;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Get recent session summaries.
|
|
881
|
+
*/
|
|
882
|
+
export function getSessionSummaries(db: Database.Database, limit: number = 10): Array<{
|
|
883
|
+
session_id: string;
|
|
884
|
+
request: string | null;
|
|
885
|
+
completed: string | null;
|
|
886
|
+
failed_attempts: string | null;
|
|
887
|
+
plan_progress: string;
|
|
888
|
+
created_at: string;
|
|
889
|
+
}> {
|
|
890
|
+
return db.prepare(`
|
|
891
|
+
SELECT session_id, request, completed, failed_attempts, plan_progress, created_at
|
|
892
|
+
FROM session_summaries
|
|
893
|
+
ORDER BY created_at_epoch DESC LIMIT ?
|
|
894
|
+
`).all(limit) as Array<{
|
|
895
|
+
session_id: string; request: string | null; completed: string | null;
|
|
896
|
+
failed_attempts: string | null; plan_progress: string; created_at: string;
|
|
897
|
+
}>;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Get complete timeline for a session.
|
|
902
|
+
*/
|
|
903
|
+
export function getSessionTimeline(db: Database.Database, sessionId: string): {
|
|
904
|
+
session: Record<string, unknown> | null;
|
|
905
|
+
observations: Array<Record<string, unknown>>;
|
|
906
|
+
summary: Record<string, unknown> | null;
|
|
907
|
+
prompts: Array<Record<string, unknown>>;
|
|
908
|
+
} {
|
|
909
|
+
const session = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId) as Record<string, unknown> | undefined;
|
|
910
|
+
const observations = db.prepare('SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC').all(sessionId) as Array<Record<string, unknown>>;
|
|
911
|
+
const summary = db.prepare('SELECT * FROM session_summaries WHERE session_id = ? ORDER BY created_at_epoch DESC LIMIT 1').get(sessionId) as Record<string, unknown> | undefined;
|
|
912
|
+
const prompts = db.prepare('SELECT * FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC').all(sessionId) as Array<Record<string, unknown>>;
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
session: session ?? null,
|
|
916
|
+
observations,
|
|
917
|
+
summary: summary ?? null,
|
|
918
|
+
prompts,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Get failed attempt observations.
|
|
924
|
+
*/
|
|
925
|
+
export function getFailedAttempts(db: Database.Database, query?: string, limit: number = 20): Array<{
|
|
926
|
+
id: number;
|
|
927
|
+
title: string;
|
|
928
|
+
detail: string | null;
|
|
929
|
+
session_id: string;
|
|
930
|
+
recurrence_count: number;
|
|
931
|
+
created_at: string;
|
|
932
|
+
}> {
|
|
933
|
+
if (query) {
|
|
934
|
+
return db.prepare(`
|
|
935
|
+
SELECT o.id, o.title, o.detail, o.session_id, o.recurrence_count, o.created_at
|
|
936
|
+
FROM observations_fts
|
|
937
|
+
JOIN observations o ON observations_fts.rowid = o.id
|
|
938
|
+
WHERE observations_fts MATCH ? AND o.type = 'failed_attempt'
|
|
939
|
+
ORDER BY o.recurrence_count DESC, rank LIMIT ?
|
|
940
|
+
`).all(sanitizeFts5Query(query), limit) as Array<{
|
|
941
|
+
id: number; title: string; detail: string | null; session_id: string;
|
|
942
|
+
recurrence_count: number; created_at: string;
|
|
943
|
+
}>;
|
|
944
|
+
}
|
|
945
|
+
return db.prepare(`
|
|
946
|
+
SELECT id, title, detail, session_id, recurrence_count, created_at
|
|
947
|
+
FROM observations WHERE type = 'failed_attempt'
|
|
948
|
+
ORDER BY recurrence_count DESC, created_at_epoch DESC LIMIT ?
|
|
949
|
+
`).all(limit) as Array<{
|
|
950
|
+
id: number; title: string; detail: string | null; session_id: string;
|
|
951
|
+
recurrence_count: number; created_at: string;
|
|
952
|
+
}>;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Search decision observations.
|
|
957
|
+
*/
|
|
958
|
+
export function getDecisionsAbout(db: Database.Database, query: string, limit: number = 20): Array<{
|
|
959
|
+
id: number;
|
|
960
|
+
title: string;
|
|
961
|
+
detail: string | null;
|
|
962
|
+
session_id: string;
|
|
963
|
+
created_at: string;
|
|
964
|
+
}> {
|
|
965
|
+
return db.prepare(`
|
|
966
|
+
SELECT o.id, o.title, o.detail, o.session_id, o.created_at
|
|
967
|
+
FROM observations_fts
|
|
968
|
+
JOIN observations o ON observations_fts.rowid = o.id
|
|
969
|
+
WHERE observations_fts MATCH ? AND o.type = 'decision'
|
|
970
|
+
ORDER BY rank LIMIT ?
|
|
971
|
+
`).all(sanitizeFts5Query(query), limit) as Array<{
|
|
972
|
+
id: number; title: string; detail: string | null; session_id: string;
|
|
973
|
+
created_at: string;
|
|
974
|
+
}>;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Delete observations older than retention period.
|
|
979
|
+
*/
|
|
980
|
+
export function pruneOldObservations(db: Database.Database, retentionDays: number = 90): number {
|
|
981
|
+
const cutoffEpoch = Math.floor(Date.now() / 1000) - (retentionDays * 86400);
|
|
982
|
+
const result = db.prepare('DELETE FROM observations WHERE created_at_epoch < ?').run(cutoffEpoch);
|
|
983
|
+
return result.changes;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Deduplicate failed attempts across sessions.
|
|
988
|
+
* If the same failure title exists, increment recurrence_count instead of creating a duplicate.
|
|
989
|
+
*/
|
|
990
|
+
export function deduplicateFailedAttempt(
|
|
991
|
+
db: Database.Database,
|
|
992
|
+
sessionId: string,
|
|
993
|
+
title: string,
|
|
994
|
+
detail: string | null,
|
|
995
|
+
opts?: AddObservationOpts
|
|
996
|
+
): number {
|
|
997
|
+
// Check if a similar failed_attempt already exists (across all sessions)
|
|
998
|
+
const existing = db.prepare(`
|
|
999
|
+
SELECT id, recurrence_count FROM observations
|
|
1000
|
+
WHERE type = 'failed_attempt' AND title = ?
|
|
1001
|
+
ORDER BY created_at_epoch DESC LIMIT 1
|
|
1002
|
+
`).get(title) as { id: number; recurrence_count: number } | undefined;
|
|
1003
|
+
|
|
1004
|
+
if (existing) {
|
|
1005
|
+
// Increment recurrence count and update detail if newer
|
|
1006
|
+
db.prepare('UPDATE observations SET recurrence_count = recurrence_count + 1, detail = COALESCE(?, detail) WHERE id = ?')
|
|
1007
|
+
.run(detail, existing.id);
|
|
1008
|
+
return existing.id;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// New failed attempt
|
|
1012
|
+
return addObservation(db, sessionId, 'failed_attempt', title, detail, {
|
|
1013
|
+
...opts,
|
|
1014
|
+
importance: 5,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Get all sessions linked to a task/plan.
|
|
1020
|
+
*/
|
|
1021
|
+
export function getSessionsByTask(db: Database.Database, taskId: string): Array<{
|
|
1022
|
+
session_id: string;
|
|
1023
|
+
status: string;
|
|
1024
|
+
started_at: string;
|
|
1025
|
+
ended_at: string | null;
|
|
1026
|
+
plan_phase: string | null;
|
|
1027
|
+
}> {
|
|
1028
|
+
return db.prepare(`
|
|
1029
|
+
SELECT session_id, status, started_at, ended_at, plan_phase
|
|
1030
|
+
FROM sessions WHERE task_id = ?
|
|
1031
|
+
ORDER BY started_at_epoch DESC
|
|
1032
|
+
`).all(taskId) as Array<{
|
|
1033
|
+
session_id: string; status: string; started_at: string;
|
|
1034
|
+
ended_at: string | null; plan_phase: string | null;
|
|
1035
|
+
}>;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Aggregate plan_progress across all sessions for a task.
|
|
1040
|
+
*/
|
|
1041
|
+
export function getCrossTaskProgress(db: Database.Database, taskId: string): Record<string, string> {
|
|
1042
|
+
const sessions = db.prepare(`
|
|
1043
|
+
SELECT session_id FROM sessions WHERE task_id = ?
|
|
1044
|
+
`).all(taskId) as Array<{ session_id: string }>;
|
|
1045
|
+
|
|
1046
|
+
const merged: Record<string, string> = {};
|
|
1047
|
+
for (const session of sessions) {
|
|
1048
|
+
const summaries = db.prepare(`
|
|
1049
|
+
SELECT plan_progress FROM session_summaries WHERE session_id = ?
|
|
1050
|
+
`).all(session.session_id) as Array<{ plan_progress: string }>;
|
|
1051
|
+
|
|
1052
|
+
for (const summary of summaries) {
|
|
1053
|
+
try {
|
|
1054
|
+
const progress = JSON.parse(summary.plan_progress) as Record<string, string>;
|
|
1055
|
+
for (const [key, value] of Object.entries(progress)) {
|
|
1056
|
+
// Later status wins (complete > in_progress > pending)
|
|
1057
|
+
if (!merged[key] || value === 'complete' || (value === 'in_progress' && merged[key] === 'pending')) {
|
|
1058
|
+
merged[key] = value;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
} catch (_e) {
|
|
1062
|
+
// Skip invalid JSON
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return merged;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Set task_id on a session for multi-session task linking.
|
|
1072
|
+
*/
|
|
1073
|
+
export function linkSessionToTask(db: Database.Database, sessionId: string, taskId: string): void {
|
|
1074
|
+
db.prepare('UPDATE sessions SET task_id = ? WHERE session_id = ?').run(taskId, sessionId);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ============================================================
|
|
1078
|
+
// Observability Functions (P2-002, P2-003, P4-001)
|
|
1079
|
+
// ============================================================
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Insert a conversation turn into the observability table.
|
|
1083
|
+
* Returns the new row ID.
|
|
1084
|
+
*/
|
|
1085
|
+
export function addConversationTurn(
|
|
1086
|
+
db: Database.Database,
|
|
1087
|
+
sessionId: string,
|
|
1088
|
+
turnNumber: number,
|
|
1089
|
+
userPrompt: string,
|
|
1090
|
+
assistantResponse: string | null,
|
|
1091
|
+
toolCallsJson: string | null,
|
|
1092
|
+
toolCallCount: number,
|
|
1093
|
+
promptTokens: number,
|
|
1094
|
+
responseTokens: number
|
|
1095
|
+
): number {
|
|
1096
|
+
const result = db.prepare(`
|
|
1097
|
+
INSERT INTO conversation_turns (session_id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens)
|
|
1098
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1099
|
+
`).run(
|
|
1100
|
+
sessionId, turnNumber, userPrompt,
|
|
1101
|
+
assistantResponse ? assistantResponse.slice(0, 10000) : null,
|
|
1102
|
+
toolCallsJson, toolCallCount, promptTokens, responseTokens
|
|
1103
|
+
);
|
|
1104
|
+
return Number(result.lastInsertRowid);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Insert a tool call detail record.
|
|
1109
|
+
*/
|
|
1110
|
+
export function addToolCallDetail(
|
|
1111
|
+
db: Database.Database,
|
|
1112
|
+
sessionId: string,
|
|
1113
|
+
turnNumber: number,
|
|
1114
|
+
toolName: string,
|
|
1115
|
+
inputSummary: string | null,
|
|
1116
|
+
inputSize: number,
|
|
1117
|
+
outputSize: number,
|
|
1118
|
+
success: boolean,
|
|
1119
|
+
filesInvolved?: string[]
|
|
1120
|
+
): void {
|
|
1121
|
+
db.prepare(`
|
|
1122
|
+
INSERT INTO tool_call_details (session_id, turn_number, tool_name, tool_input_summary, tool_input_size, tool_output_size, tool_success, files_involved)
|
|
1123
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1124
|
+
`).run(
|
|
1125
|
+
sessionId, turnNumber, toolName,
|
|
1126
|
+
inputSummary ? inputSummary.slice(0, 500) : null,
|
|
1127
|
+
inputSize, outputSize, success ? 1 : 0,
|
|
1128
|
+
filesInvolved ? JSON.stringify(filesInvolved) : null
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Get the last processed line number for incremental transcript parsing.
|
|
1134
|
+
*/
|
|
1135
|
+
export function getLastProcessedLine(db: Database.Database, sessionId: string): number {
|
|
1136
|
+
const row = db.prepare('SELECT value FROM memory_meta WHERE key = ?').get(`last_processed_line:${sessionId}`) as { value: string } | undefined;
|
|
1137
|
+
return row ? parseInt(row.value, 10) : 0;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Set the last processed line number for incremental transcript parsing.
|
|
1142
|
+
*/
|
|
1143
|
+
export function setLastProcessedLine(db: Database.Database, sessionId: string, lineNumber: number): void {
|
|
1144
|
+
db.prepare('INSERT OR REPLACE INTO memory_meta (key, value) VALUES (?, ?)').run(`last_processed_line:${sessionId}`, String(lineNumber));
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Delete conversation turns and tool call details older than retention period.
|
|
1149
|
+
*/
|
|
1150
|
+
export function pruneOldConversationTurns(db: Database.Database, retentionDays: number = 90): { turnsDeleted: number; detailsDeleted: number } {
|
|
1151
|
+
const cutoffEpoch = Math.floor(Date.now() / 1000) - (retentionDays * 86400);
|
|
1152
|
+
const turnsResult = db.prepare('DELETE FROM conversation_turns WHERE created_at_epoch < ?').run(cutoffEpoch);
|
|
1153
|
+
const detailsResult = db.prepare('DELETE FROM tool_call_details WHERE created_at_epoch < ?').run(cutoffEpoch);
|
|
1154
|
+
return { turnsDeleted: turnsResult.changes, detailsDeleted: detailsResult.changes };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Get conversation turns for a session (for replay).
|
|
1159
|
+
*/
|
|
1160
|
+
export function getConversationTurns(db: Database.Database, sessionId: string, opts?: {
|
|
1161
|
+
turnFrom?: number;
|
|
1162
|
+
turnTo?: number;
|
|
1163
|
+
includeToolCalls?: boolean;
|
|
1164
|
+
}): Array<{
|
|
1165
|
+
id: number;
|
|
1166
|
+
turn_number: number;
|
|
1167
|
+
user_prompt: string;
|
|
1168
|
+
assistant_response: string | null;
|
|
1169
|
+
tool_calls_json: string | null;
|
|
1170
|
+
tool_call_count: number;
|
|
1171
|
+
prompt_tokens: number | null;
|
|
1172
|
+
response_tokens: number | null;
|
|
1173
|
+
created_at: string;
|
|
1174
|
+
}> {
|
|
1175
|
+
let sql = 'SELECT id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens, created_at FROM conversation_turns WHERE session_id = ?';
|
|
1176
|
+
const params: (string | number)[] = [sessionId];
|
|
1177
|
+
|
|
1178
|
+
if (opts?.turnFrom !== undefined) {
|
|
1179
|
+
sql += ' AND turn_number >= ?';
|
|
1180
|
+
params.push(opts.turnFrom);
|
|
1181
|
+
}
|
|
1182
|
+
if (opts?.turnTo !== undefined) {
|
|
1183
|
+
sql += ' AND turn_number <= ?';
|
|
1184
|
+
params.push(opts.turnTo);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
sql += ' ORDER BY turn_number ASC';
|
|
1188
|
+
|
|
1189
|
+
return db.prepare(sql).all(...params) as Array<{
|
|
1190
|
+
id: number; turn_number: number; user_prompt: string;
|
|
1191
|
+
assistant_response: string | null; tool_calls_json: string | null;
|
|
1192
|
+
tool_call_count: number; prompt_tokens: number | null;
|
|
1193
|
+
response_tokens: number | null; created_at: string;
|
|
1194
|
+
}>;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Search conversation turns using FTS5.
|
|
1199
|
+
*/
|
|
1200
|
+
export function searchConversationTurns(db: Database.Database, query: string, opts?: {
|
|
1201
|
+
sessionId?: string;
|
|
1202
|
+
dateFrom?: string;
|
|
1203
|
+
dateTo?: string;
|
|
1204
|
+
minToolCalls?: number;
|
|
1205
|
+
limit?: number;
|
|
1206
|
+
}): Array<{
|
|
1207
|
+
id: number;
|
|
1208
|
+
session_id: string;
|
|
1209
|
+
turn_number: number;
|
|
1210
|
+
user_prompt: string;
|
|
1211
|
+
tool_call_count: number;
|
|
1212
|
+
response_tokens: number | null;
|
|
1213
|
+
created_at: string;
|
|
1214
|
+
rank: number;
|
|
1215
|
+
}> {
|
|
1216
|
+
const limit = opts?.limit ?? 20;
|
|
1217
|
+
let sql = `
|
|
1218
|
+
SELECT ct.id, ct.session_id, ct.turn_number, ct.user_prompt, ct.tool_call_count, ct.response_tokens, ct.created_at, rank
|
|
1219
|
+
FROM conversation_turns_fts
|
|
1220
|
+
JOIN conversation_turns ct ON conversation_turns_fts.rowid = ct.id
|
|
1221
|
+
WHERE conversation_turns_fts MATCH ?
|
|
1222
|
+
`;
|
|
1223
|
+
const params: (string | number)[] = [sanitizeFts5Query(query)];
|
|
1224
|
+
|
|
1225
|
+
if (opts?.sessionId) {
|
|
1226
|
+
sql += ' AND ct.session_id = ?';
|
|
1227
|
+
params.push(opts.sessionId);
|
|
1228
|
+
}
|
|
1229
|
+
if (opts?.dateFrom) {
|
|
1230
|
+
sql += ' AND ct.created_at >= ?';
|
|
1231
|
+
params.push(opts.dateFrom);
|
|
1232
|
+
}
|
|
1233
|
+
if (opts?.dateTo) {
|
|
1234
|
+
sql += ' AND ct.created_at <= ?';
|
|
1235
|
+
params.push(opts.dateTo);
|
|
1236
|
+
}
|
|
1237
|
+
if (opts?.minToolCalls !== undefined) {
|
|
1238
|
+
sql += ' AND ct.tool_call_count >= ?';
|
|
1239
|
+
params.push(opts.minToolCalls);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
sql += ' ORDER BY rank LIMIT ?';
|
|
1243
|
+
params.push(limit);
|
|
1244
|
+
|
|
1245
|
+
return db.prepare(sql).all(...params) as Array<{
|
|
1246
|
+
id: number; session_id: string; turn_number: number;
|
|
1247
|
+
user_prompt: string; tool_call_count: number;
|
|
1248
|
+
response_tokens: number | null; created_at: string; rank: number;
|
|
1249
|
+
}>;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Get tool usage patterns (aggregated stats).
|
|
1254
|
+
*/
|
|
1255
|
+
export function getToolPatterns(db: Database.Database, opts?: {
|
|
1256
|
+
sessionId?: string;
|
|
1257
|
+
toolName?: string;
|
|
1258
|
+
dateFrom?: string;
|
|
1259
|
+
groupBy?: 'tool' | 'session' | 'day';
|
|
1260
|
+
}): Array<Record<string, unknown>> {
|
|
1261
|
+
const groupBy = opts?.groupBy ?? 'tool';
|
|
1262
|
+
const params: (string | number)[] = [];
|
|
1263
|
+
let whereClause = '';
|
|
1264
|
+
const conditions: string[] = [];
|
|
1265
|
+
|
|
1266
|
+
if (opts?.sessionId) {
|
|
1267
|
+
conditions.push('session_id = ?');
|
|
1268
|
+
params.push(opts.sessionId);
|
|
1269
|
+
}
|
|
1270
|
+
if (opts?.toolName) {
|
|
1271
|
+
conditions.push('tool_name = ?');
|
|
1272
|
+
params.push(opts.toolName);
|
|
1273
|
+
}
|
|
1274
|
+
if (opts?.dateFrom) {
|
|
1275
|
+
conditions.push('created_at >= ?');
|
|
1276
|
+
params.push(opts.dateFrom);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (conditions.length > 0) {
|
|
1280
|
+
whereClause = 'WHERE ' + conditions.join(' AND ');
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
let sql: string;
|
|
1284
|
+
switch (groupBy) {
|
|
1285
|
+
case 'session':
|
|
1286
|
+
sql = `SELECT session_id, COUNT(*) as call_count, COUNT(DISTINCT tool_name) as unique_tools,
|
|
1287
|
+
SUM(CASE WHEN tool_success = 1 THEN 1 ELSE 0 END) as successes,
|
|
1288
|
+
SUM(CASE WHEN tool_success = 0 THEN 1 ELSE 0 END) as failures,
|
|
1289
|
+
AVG(tool_output_size) as avg_output_size
|
|
1290
|
+
FROM tool_call_details ${whereClause}
|
|
1291
|
+
GROUP BY session_id ORDER BY call_count DESC`;
|
|
1292
|
+
break;
|
|
1293
|
+
case 'day':
|
|
1294
|
+
sql = `SELECT date(created_at) as day, COUNT(*) as call_count, COUNT(DISTINCT tool_name) as unique_tools,
|
|
1295
|
+
SUM(CASE WHEN tool_success = 1 THEN 1 ELSE 0 END) as successes
|
|
1296
|
+
FROM tool_call_details ${whereClause}
|
|
1297
|
+
GROUP BY date(created_at) ORDER BY day DESC`;
|
|
1298
|
+
break;
|
|
1299
|
+
default: // 'tool'
|
|
1300
|
+
sql = `SELECT tool_name, COUNT(*) as call_count,
|
|
1301
|
+
SUM(CASE WHEN tool_success = 1 THEN 1 ELSE 0 END) as successes,
|
|
1302
|
+
SUM(CASE WHEN tool_success = 0 THEN 1 ELSE 0 END) as failures,
|
|
1303
|
+
AVG(tool_output_size) as avg_output_size,
|
|
1304
|
+
AVG(tool_input_size) as avg_input_size
|
|
1305
|
+
FROM tool_call_details ${whereClause}
|
|
1306
|
+
GROUP BY tool_name ORDER BY call_count DESC`;
|
|
1307
|
+
break;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Get session stats for observability.
|
|
1315
|
+
*/
|
|
1316
|
+
export function getSessionStats(db: Database.Database, opts?: {
|
|
1317
|
+
sessionId?: string;
|
|
1318
|
+
limit?: number;
|
|
1319
|
+
}): Array<Record<string, unknown>> {
|
|
1320
|
+
if (opts?.sessionId) {
|
|
1321
|
+
// Single session stats
|
|
1322
|
+
const turns = db.prepare('SELECT COUNT(*) as turn_count, SUM(tool_call_count) as total_tool_calls, SUM(prompt_tokens) as total_prompt_tokens, SUM(response_tokens) as total_response_tokens FROM conversation_turns WHERE session_id = ?').get(opts.sessionId) as Record<string, unknown>;
|
|
1323
|
+
const toolBreakdown = db.prepare('SELECT tool_name, COUNT(*) as count FROM tool_call_details WHERE session_id = ? GROUP BY tool_name ORDER BY count DESC').all(opts.sessionId) as Array<Record<string, unknown>>;
|
|
1324
|
+
const session = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(opts.sessionId) as Record<string, unknown> | undefined;
|
|
1325
|
+
|
|
1326
|
+
return [{
|
|
1327
|
+
session_id: opts.sessionId,
|
|
1328
|
+
status: session?.status ?? 'unknown',
|
|
1329
|
+
started_at: session?.started_at ?? null,
|
|
1330
|
+
ended_at: session?.ended_at ?? null,
|
|
1331
|
+
...turns,
|
|
1332
|
+
tool_breakdown: toolBreakdown,
|
|
1333
|
+
}];
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const limit = opts?.limit ?? 10;
|
|
1337
|
+
return db.prepare(`
|
|
1338
|
+
SELECT s.session_id, s.status, s.started_at, s.ended_at,
|
|
1339
|
+
COUNT(ct.id) as turn_count,
|
|
1340
|
+
COALESCE(SUM(ct.tool_call_count), 0) as total_tool_calls,
|
|
1341
|
+
COALESCE(SUM(ct.prompt_tokens), 0) as total_prompt_tokens,
|
|
1342
|
+
COALESCE(SUM(ct.response_tokens), 0) as total_response_tokens
|
|
1343
|
+
FROM sessions s
|
|
1344
|
+
LEFT JOIN conversation_turns ct ON s.session_id = ct.session_id
|
|
1345
|
+
GROUP BY s.session_id
|
|
1346
|
+
ORDER BY s.started_at_epoch DESC
|
|
1347
|
+
LIMIT ?
|
|
1348
|
+
`).all(limit) as Array<Record<string, unknown>>;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Get database size information for observability monitoring.
|
|
1353
|
+
*/
|
|
1354
|
+
export function getObservabilityDbSize(db: Database.Database): {
|
|
1355
|
+
conversation_turns_count: number;
|
|
1356
|
+
tool_call_details_count: number;
|
|
1357
|
+
observations_count: number;
|
|
1358
|
+
db_page_count: number;
|
|
1359
|
+
db_page_size: number;
|
|
1360
|
+
estimated_size_mb: number;
|
|
1361
|
+
} {
|
|
1362
|
+
const turnsCount = (db.prepare('SELECT COUNT(*) as c FROM conversation_turns').get() as { c: number }).c;
|
|
1363
|
+
const detailsCount = (db.prepare('SELECT COUNT(*) as c FROM tool_call_details').get() as { c: number }).c;
|
|
1364
|
+
const obsCount = (db.prepare('SELECT COUNT(*) as c FROM observations').get() as { c: number }).c;
|
|
1365
|
+
const pageCount = (db.pragma('page_count') as Array<{ page_count: number }>)[0]?.page_count ?? 0;
|
|
1366
|
+
const pageSize = (db.pragma('page_size') as Array<{ page_size: number }>)[0]?.page_size ?? 4096;
|
|
1367
|
+
|
|
1368
|
+
return {
|
|
1369
|
+
conversation_turns_count: turnsCount,
|
|
1370
|
+
tool_call_details_count: detailsCount,
|
|
1371
|
+
observations_count: obsCount,
|
|
1372
|
+
db_page_count: pageCount,
|
|
1373
|
+
db_page_size: pageSize,
|
|
1374
|
+
estimated_size_mb: Math.round((pageCount * pageSize) / (1024 * 1024) * 100) / 100,
|
|
1375
|
+
};
|
|
1376
|
+
}
|