@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.
Files changed (87) hide show
  1. package/README.md +2 -2
  2. package/dist/hooks/cost-tracker.js +23 -35
  3. package/dist/hooks/post-edit-context.js +2 -2
  4. package/dist/hooks/post-tool-use.js +43 -58
  5. package/dist/hooks/pre-compact.js +23 -38
  6. package/dist/hooks/pre-delete-check.js +18 -31
  7. package/dist/hooks/quality-event.js +23 -35
  8. package/dist/hooks/session-end.js +62 -78
  9. package/dist/hooks/session-start.js +33 -42
  10. package/dist/hooks/user-prompt.js +23 -38
  11. package/package.json +8 -14
  12. package/src/adr-generator.ts +9 -2
  13. package/src/analytics.ts +9 -3
  14. package/src/audit-trail.ts +10 -3
  15. package/src/cloud-sync.ts +14 -18
  16. package/src/commands/init.ts +1 -5
  17. package/src/cost-tracker.ts +11 -6
  18. package/src/dependency-scorer.ts +9 -2
  19. package/src/docs-tools.ts +13 -10
  20. package/src/hooks/post-edit-context.ts +3 -3
  21. package/src/hooks/session-end.ts +3 -3
  22. package/src/hooks/session-start.ts +2 -2
  23. package/src/memory-db.ts +1351 -23
  24. package/src/memory-tools.ts +14 -15
  25. package/src/observability-tools.ts +13 -2
  26. package/src/prompt-analyzer.ts +9 -2
  27. package/src/regression-detector.ts +9 -3
  28. package/src/security-scorer.ts +9 -2
  29. package/src/sentinel-db.ts +43 -88
  30. package/src/sentinel-tools.ts +8 -11
  31. package/src/server.ts +1 -2
  32. package/src/team-knowledge.ts +9 -2
  33. package/src/tools.ts +771 -35
  34. package/src/validate-features-runner.ts +0 -1
  35. package/src/validation-engine.ts +9 -2
  36. package/dist/cli.js +0 -7890
  37. package/dist/server.js +0 -7008
  38. package/src/__tests__/adr-generator.test.ts +0 -260
  39. package/src/__tests__/analytics.test.ts +0 -282
  40. package/src/__tests__/audit-trail.test.ts +0 -382
  41. package/src/__tests__/backfill-sessions.test.ts +0 -690
  42. package/src/__tests__/cli.test.ts +0 -290
  43. package/src/__tests__/cloud-sync.test.ts +0 -261
  44. package/src/__tests__/config-sections.test.ts +0 -359
  45. package/src/__tests__/config.test.ts +0 -732
  46. package/src/__tests__/cost-tracker.test.ts +0 -348
  47. package/src/__tests__/db.test.ts +0 -177
  48. package/src/__tests__/dependency-scorer.test.ts +0 -325
  49. package/src/__tests__/docs-integration.test.ts +0 -178
  50. package/src/__tests__/docs-tools.test.ts +0 -199
  51. package/src/__tests__/domains.test.ts +0 -236
  52. package/src/__tests__/hooks.test.ts +0 -221
  53. package/src/__tests__/import-resolver.test.ts +0 -95
  54. package/src/__tests__/integration/path-traversal.test.ts +0 -134
  55. package/src/__tests__/integration/pricing-consistency.test.ts +0 -88
  56. package/src/__tests__/integration/tool-registration.test.ts +0 -146
  57. package/src/__tests__/memory-db.test.ts +0 -404
  58. package/src/__tests__/memory-enhancements.test.ts +0 -316
  59. package/src/__tests__/memory-tools.test.ts +0 -199
  60. package/src/__tests__/middleware-tree.test.ts +0 -177
  61. package/src/__tests__/observability-tools.test.ts +0 -595
  62. package/src/__tests__/observability.test.ts +0 -437
  63. package/src/__tests__/observation-extractor.test.ts +0 -167
  64. package/src/__tests__/page-deps.test.ts +0 -60
  65. package/src/__tests__/prompt-analyzer.test.ts +0 -298
  66. package/src/__tests__/regression-detector.test.ts +0 -295
  67. package/src/__tests__/rules.test.ts +0 -87
  68. package/src/__tests__/schema-mapper.test.ts +0 -29
  69. package/src/__tests__/security-scorer.test.ts +0 -238
  70. package/src/__tests__/security-utils.test.ts +0 -175
  71. package/src/__tests__/sentinel-db.test.ts +0 -491
  72. package/src/__tests__/sentinel-scanner.test.ts +0 -750
  73. package/src/__tests__/sentinel-tools.test.ts +0 -324
  74. package/src/__tests__/sentinel-types.test.ts +0 -750
  75. package/src/__tests__/server.test.ts +0 -452
  76. package/src/__tests__/session-archiver.test.ts +0 -524
  77. package/src/__tests__/session-state-generator.test.ts +0 -900
  78. package/src/__tests__/team-knowledge.test.ts +0 -327
  79. package/src/__tests__/tools.test.ts +0 -340
  80. package/src/__tests__/transcript-parser.test.ts +0 -195
  81. package/src/__tests__/trpc-index.test.ts +0 -25
  82. package/src/__tests__/validate-features-runner.test.ts +0 -517
  83. package/src/__tests__/validation-engine.test.ts +0 -300
  84. package/src/core-tools.ts +0 -685
  85. package/src/memory-queries.ts +0 -804
  86. package/src/memory-schema.ts +0 -546
  87. package/src/tool-helpers.ts +0 -41
@@ -1,804 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * Memory database CRUD query functions.
6
- * Split from memory-db.ts (P3-001 remediation) to keep memory-db.ts
7
- * focused on connection factory + schema initialization.
8
- */
9
-
10
- import type Database from 'better-sqlite3';
11
- import { basename } from 'path';
12
-
13
- // ============================================================
14
- // Cloud Sync: Queue Functions
15
- // ============================================================
16
-
17
- /**
18
- * Enqueue a sync payload for later retry.
19
- */
20
- export function enqueueSyncPayload(db: Database.Database, payload: string): void {
21
- db.prepare('INSERT INTO pending_sync (payload) VALUES (?)').run(payload);
22
- }
23
-
24
- /**
25
- * Dequeue pending sync items (oldest first).
26
- * Items with retry_count >= 10 are silently discarded to prevent infinite accumulation.
27
- */
28
- export function dequeuePendingSync(
29
- db: Database.Database,
30
- limit: number = 10
31
- ): Array<{ id: number; payload: string; retry_count: number }> {
32
- // First, discard items that have exceeded max retries
33
- db.prepare('DELETE FROM pending_sync WHERE retry_count >= 10').run();
34
-
35
- return db.prepare(
36
- 'SELECT id, payload, retry_count FROM pending_sync ORDER BY created_at ASC LIMIT ?'
37
- ).all(limit) as Array<{ id: number; payload: string; retry_count: number }>;
38
- }
39
-
40
- /**
41
- * Remove a successfully synced item from the queue.
42
- */
43
- export function removePendingSync(db: Database.Database, id: number): void {
44
- db.prepare('DELETE FROM pending_sync WHERE id = ?').run(id);
45
- }
46
-
47
- /**
48
- * Increment retry count and record the last error for a failed sync attempt.
49
- */
50
- export function incrementRetryCount(db: Database.Database, id: number, error: string): void {
51
- db.prepare(
52
- 'UPDATE pending_sync SET retry_count = retry_count + 1, last_error = ? WHERE id = ?'
53
- ).run(error, id);
54
- }
55
-
56
- // ============================================================
57
- // P1-002: Database Access Functions
58
- // ============================================================
59
-
60
- /**
61
- * Auto-assign importance score based on observation type and optional VR result.
62
- * Scale: 5=decision/failed_attempt, 4=cr_violation/vr_check(FAIL),
63
- * 3=feature/bugfix, 2=vr_check(PASS)/refactor, 1=file_change/discovery
64
- */
65
- export function assignImportance(type: string, vrResult?: string): number {
66
- switch (type) {
67
- case 'decision':
68
- case 'failed_attempt':
69
- return 5;
70
- case 'cr_violation':
71
- case 'incident_near_miss':
72
- return 4;
73
- case 'vr_check':
74
- return vrResult === 'PASS' ? 2 : 4;
75
- case 'pattern_compliance':
76
- return vrResult === 'PASS' ? 2 : 4;
77
- case 'feature':
78
- case 'bugfix':
79
- return 3;
80
- case 'refactor':
81
- return 2;
82
- case 'file_change':
83
- case 'discovery':
84
- return 1;
85
- default:
86
- return 3;
87
- }
88
- }
89
-
90
- /**
91
- * Derive task_id from plan file path.
92
- * Sessions working on the same plan file share a task_id.
93
- */
94
- export function autoDetectTaskId(planFile: string | null | undefined): string | null {
95
- if (!planFile) return null;
96
- // Use the plan filename without extension as task_id
97
- // e.g., "/path/to/2026-01-30-massu-memory.md" -> "2026-01-30-massu-memory"
98
- const base = basename(planFile);
99
- return base.replace(/\.md$/, '');
100
- }
101
-
102
- export interface CreateSessionOpts {
103
- branch?: string;
104
- planFile?: string;
105
- }
106
-
107
- /**
108
- * Create a session (INSERT OR IGNORE for idempotency).
109
- */
110
- export function createSession(db: Database.Database, sessionId: string, opts?: CreateSessionOpts): void {
111
- const now = new Date();
112
- const taskId = autoDetectTaskId(opts?.planFile);
113
- db.prepare(`
114
- INSERT OR IGNORE INTO sessions (session_id, git_branch, plan_file, task_id, started_at, started_at_epoch)
115
- VALUES (?, ?, ?, ?, ?, ?)
116
- `).run(sessionId, opts?.branch ?? null, opts?.planFile ?? null, taskId, now.toISOString(), Math.floor(now.getTime() / 1000));
117
- }
118
-
119
- /**
120
- * End a session by updating status and ended_at.
121
- */
122
- export function endSession(db: Database.Database, sessionId: string, status: 'completed' | 'abandoned' = 'completed'): void {
123
- const now = new Date();
124
- db.prepare(`
125
- UPDATE sessions SET status = ?, ended_at = ?, ended_at_epoch = ? WHERE session_id = ?
126
- `).run(status, now.toISOString(), Math.floor(now.getTime() / 1000), sessionId);
127
- }
128
-
129
- export interface AddObservationOpts {
130
- filesInvolved?: string[];
131
- planItem?: string;
132
- crRule?: string;
133
- vrType?: string;
134
- evidence?: string;
135
- importance?: number;
136
- originalTokens?: number;
137
- }
138
-
139
- /**
140
- * Insert an observation into the memory DB.
141
- */
142
- export function addObservation(
143
- db: Database.Database,
144
- sessionId: string,
145
- type: string,
146
- title: string,
147
- detail: string | null,
148
- opts?: AddObservationOpts
149
- ): number {
150
- const now = new Date();
151
- const importance = opts?.importance ?? assignImportance(type, opts?.evidence?.includes('PASS') ? 'PASS' : undefined);
152
- const result = db.prepare(`
153
- 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)
154
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
155
- `).run(
156
- sessionId, type, title, detail,
157
- JSON.stringify(opts?.filesInvolved ?? []),
158
- opts?.planItem ?? null,
159
- opts?.crRule ?? null,
160
- opts?.vrType ?? null,
161
- opts?.evidence ?? null,
162
- importance,
163
- opts?.originalTokens ?? 0,
164
- now.toISOString(),
165
- Math.floor(now.getTime() / 1000)
166
- );
167
- return Number(result.lastInsertRowid);
168
- }
169
-
170
- export interface SessionSummary {
171
- request?: string;
172
- investigated?: string;
173
- decisions?: string;
174
- completed?: string;
175
- failedAttempts?: string;
176
- nextSteps?: string;
177
- filesCreated?: string[];
178
- filesModified?: string[];
179
- verificationResults?: Record<string, string>;
180
- planProgress?: Record<string, string>;
181
- }
182
-
183
- /**
184
- * Insert a session summary.
185
- */
186
- export function addSummary(db: Database.Database, sessionId: string, summary: SessionSummary): void {
187
- const now = new Date();
188
- db.prepare(`
189
- 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)
190
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
191
- `).run(
192
- sessionId,
193
- summary.request ?? null,
194
- summary.investigated ?? null,
195
- summary.decisions ?? null,
196
- summary.completed ?? null,
197
- summary.failedAttempts ?? null,
198
- summary.nextSteps ?? null,
199
- JSON.stringify(summary.filesCreated ?? []),
200
- JSON.stringify(summary.filesModified ?? []),
201
- JSON.stringify(summary.verificationResults ?? {}),
202
- JSON.stringify(summary.planProgress ?? {}),
203
- now.toISOString(),
204
- Math.floor(now.getTime() / 1000)
205
- );
206
- }
207
-
208
- /**
209
- * Insert a user prompt.
210
- */
211
- export function addUserPrompt(db: Database.Database, sessionId: string, text: string, promptNumber: number): void {
212
- const now = new Date();
213
- db.prepare(`
214
- INSERT INTO user_prompts (session_id, prompt_text, prompt_number, created_at, created_at_epoch)
215
- VALUES (?, ?, ?, ?, ?)
216
- `).run(sessionId, text, promptNumber, now.toISOString(), Math.floor(now.getTime() / 1000));
217
- }
218
-
219
- export interface SearchOpts {
220
- type?: string;
221
- crRule?: string;
222
- dateFrom?: string;
223
- limit?: number;
224
- }
225
-
226
- /**
227
- * FTS5 search on observations + user_prompts.
228
- */
229
- export function searchObservations(db: Database.Database, query: string, opts?: SearchOpts): Array<{
230
- id: number;
231
- type: string;
232
- title: string;
233
- created_at: string;
234
- session_id: string;
235
- importance: number;
236
- rank: number;
237
- }> {
238
- const limit = opts?.limit ?? 20;
239
- let sql = `
240
- SELECT o.id, o.type, o.title, o.created_at, o.session_id, o.importance,
241
- rank
242
- FROM observations_fts
243
- JOIN observations o ON observations_fts.rowid = o.id
244
- WHERE observations_fts MATCH ?
245
- `;
246
- const params: (string | number)[] = [query];
247
-
248
- if (opts?.type) {
249
- sql += ' AND o.type = ?';
250
- params.push(opts.type);
251
- }
252
- if (opts?.crRule) {
253
- sql += ' AND o.cr_rule = ?';
254
- params.push(opts.crRule);
255
- }
256
- if (opts?.dateFrom) {
257
- sql += ' AND o.created_at >= ?';
258
- params.push(opts.dateFrom);
259
- }
260
-
261
- sql += ' ORDER BY rank LIMIT ?';
262
- params.push(limit);
263
-
264
- return db.prepare(sql).all(...params) as Array<{
265
- id: number;
266
- type: string;
267
- title: string;
268
- created_at: string;
269
- session_id: string;
270
- importance: number;
271
- rank: number;
272
- }>;
273
- }
274
-
275
- /**
276
- * Get recent observations, optionally filtered by session.
277
- */
278
- export function getRecentObservations(db: Database.Database, limit: number = 20, sessionId?: string): Array<{
279
- id: number;
280
- type: string;
281
- title: string;
282
- detail: string | null;
283
- importance: number;
284
- created_at: string;
285
- session_id: string;
286
- }> {
287
- if (sessionId) {
288
- return db.prepare(`
289
- SELECT id, type, title, detail, importance, created_at, session_id
290
- FROM observations WHERE session_id = ?
291
- ORDER BY created_at_epoch DESC LIMIT ?
292
- `).all(sessionId, limit) as Array<{
293
- id: number; type: string; title: string; detail: string | null;
294
- importance: number; created_at: string; session_id: string;
295
- }>;
296
- }
297
- return db.prepare(`
298
- SELECT id, type, title, detail, importance, created_at, session_id
299
- FROM observations
300
- ORDER BY created_at_epoch DESC LIMIT ?
301
- `).all(limit) as Array<{
302
- id: number; type: string; title: string; detail: string | null;
303
- importance: number; created_at: string; session_id: string;
304
- }>;
305
- }
306
-
307
- /**
308
- * Get recent session summaries.
309
- */
310
- export function getSessionSummaries(db: Database.Database, limit: number = 10): Array<{
311
- session_id: string;
312
- request: string | null;
313
- completed: string | null;
314
- failed_attempts: string | null;
315
- plan_progress: string;
316
- created_at: string;
317
- }> {
318
- return db.prepare(`
319
- SELECT session_id, request, completed, failed_attempts, plan_progress, created_at
320
- FROM session_summaries
321
- ORDER BY created_at_epoch DESC LIMIT ?
322
- `).all(limit) as Array<{
323
- session_id: string; request: string | null; completed: string | null;
324
- failed_attempts: string | null; plan_progress: string; created_at: string;
325
- }>;
326
- }
327
-
328
- /**
329
- * Get complete timeline for a session.
330
- */
331
- export function getSessionTimeline(db: Database.Database, sessionId: string): {
332
- session: Record<string, unknown> | null;
333
- observations: Array<Record<string, unknown>>;
334
- summary: Record<string, unknown> | null;
335
- prompts: Array<Record<string, unknown>>;
336
- } {
337
- const session = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(sessionId) as Record<string, unknown> | undefined;
338
- const observations = db.prepare('SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC').all(sessionId) as Array<Record<string, unknown>>;
339
- 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;
340
- const prompts = db.prepare('SELECT * FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC').all(sessionId) as Array<Record<string, unknown>>;
341
-
342
- return {
343
- session: session ?? null,
344
- observations,
345
- summary: summary ?? null,
346
- prompts,
347
- };
348
- }
349
-
350
- /**
351
- * Get failed attempt observations.
352
- */
353
- export function getFailedAttempts(db: Database.Database, query?: string, limit: number = 20): Array<{
354
- id: number;
355
- title: string;
356
- detail: string | null;
357
- session_id: string;
358
- recurrence_count: number;
359
- created_at: string;
360
- }> {
361
- if (query) {
362
- return db.prepare(`
363
- SELECT o.id, o.title, o.detail, o.session_id, o.recurrence_count, o.created_at
364
- FROM observations_fts
365
- JOIN observations o ON observations_fts.rowid = o.id
366
- WHERE observations_fts MATCH ? AND o.type = 'failed_attempt'
367
- ORDER BY o.recurrence_count DESC, rank LIMIT ?
368
- `).all(query, limit) as Array<{
369
- id: number; title: string; detail: string | null; session_id: string;
370
- recurrence_count: number; created_at: string;
371
- }>;
372
- }
373
- return db.prepare(`
374
- SELECT id, title, detail, session_id, recurrence_count, created_at
375
- FROM observations WHERE type = 'failed_attempt'
376
- ORDER BY recurrence_count DESC, created_at_epoch DESC LIMIT ?
377
- `).all(limit) as Array<{
378
- id: number; title: string; detail: string | null; session_id: string;
379
- recurrence_count: number; created_at: string;
380
- }>;
381
- }
382
-
383
- /**
384
- * Search decision observations.
385
- */
386
- export function getDecisionsAbout(db: Database.Database, query: string, limit: number = 20): Array<{
387
- id: number;
388
- title: string;
389
- detail: string | null;
390
- session_id: string;
391
- created_at: string;
392
- }> {
393
- return db.prepare(`
394
- SELECT o.id, o.title, o.detail, o.session_id, o.created_at
395
- FROM observations_fts
396
- JOIN observations o ON observations_fts.rowid = o.id
397
- WHERE observations_fts MATCH ? AND o.type = 'decision'
398
- ORDER BY rank LIMIT ?
399
- `).all(query, limit) as Array<{
400
- id: number; title: string; detail: string | null; session_id: string;
401
- created_at: string;
402
- }>;
403
- }
404
-
405
- /**
406
- * Delete observations older than retention period.
407
- */
408
- export function pruneOldObservations(db: Database.Database, retentionDays: number = 90): number {
409
- const cutoffEpoch = Math.floor(Date.now() / 1000) - (retentionDays * 86400);
410
- const result = db.prepare('DELETE FROM observations WHERE created_at_epoch < ?').run(cutoffEpoch);
411
- return result.changes;
412
- }
413
-
414
- /**
415
- * Deduplicate failed attempts across sessions.
416
- * If the same failure title exists, increment recurrence_count instead of creating a duplicate.
417
- */
418
- export function deduplicateFailedAttempt(
419
- db: Database.Database,
420
- sessionId: string,
421
- title: string,
422
- detail: string | null,
423
- opts?: AddObservationOpts
424
- ): number {
425
- // Check if a similar failed_attempt already exists (across all sessions)
426
- const existing = db.prepare(`
427
- SELECT id, recurrence_count FROM observations
428
- WHERE type = 'failed_attempt' AND title = ?
429
- ORDER BY created_at_epoch DESC LIMIT 1
430
- `).get(title) as { id: number; recurrence_count: number } | undefined;
431
-
432
- if (existing) {
433
- // Increment recurrence count and update detail if newer
434
- db.prepare('UPDATE observations SET recurrence_count = recurrence_count + 1, detail = COALESCE(?, detail) WHERE id = ?')
435
- .run(detail, existing.id);
436
- return existing.id;
437
- }
438
-
439
- // New failed attempt
440
- return addObservation(db, sessionId, 'failed_attempt', title, detail, {
441
- ...opts,
442
- importance: 5,
443
- });
444
- }
445
-
446
- /**
447
- * Get all sessions linked to a task/plan.
448
- */
449
- export function getSessionsByTask(db: Database.Database, taskId: string): Array<{
450
- session_id: string;
451
- status: string;
452
- started_at: string;
453
- ended_at: string | null;
454
- plan_phase: string | null;
455
- }> {
456
- return db.prepare(`
457
- SELECT session_id, status, started_at, ended_at, plan_phase
458
- FROM sessions WHERE task_id = ?
459
- ORDER BY started_at_epoch DESC
460
- `).all(taskId) as Array<{
461
- session_id: string; status: string; started_at: string;
462
- ended_at: string | null; plan_phase: string | null;
463
- }>;
464
- }
465
-
466
- /**
467
- * Aggregate plan_progress across all sessions for a task.
468
- */
469
- export function getCrossTaskProgress(db: Database.Database, taskId: string): Record<string, string> {
470
- const sessions = db.prepare(`
471
- SELECT session_id FROM sessions WHERE task_id = ?
472
- `).all(taskId) as Array<{ session_id: string }>;
473
-
474
- const merged: Record<string, string> = {};
475
- for (const session of sessions) {
476
- const summaries = db.prepare(`
477
- SELECT plan_progress FROM session_summaries WHERE session_id = ?
478
- `).all(session.session_id) as Array<{ plan_progress: string }>;
479
-
480
- for (const summary of summaries) {
481
- try {
482
- const progress = JSON.parse(summary.plan_progress) as Record<string, string>;
483
- for (const [key, value] of Object.entries(progress)) {
484
- // Later status wins (complete > in_progress > pending)
485
- if (!merged[key] || value === 'complete' || (value === 'in_progress' && merged[key] === 'pending')) {
486
- merged[key] = value;
487
- }
488
- }
489
- } catch (_e) {
490
- // Skip invalid JSON
491
- }
492
- }
493
- }
494
-
495
- return merged;
496
- }
497
-
498
- /**
499
- * Set task_id on a session for multi-session task linking.
500
- */
501
- export function linkSessionToTask(db: Database.Database, sessionId: string, taskId: string): void {
502
- db.prepare('UPDATE sessions SET task_id = ? WHERE session_id = ?').run(taskId, sessionId);
503
- }
504
-
505
- // ============================================================
506
- // Observability Functions (P2-002, P2-003, P4-001)
507
- // ============================================================
508
-
509
- /**
510
- * Insert a conversation turn into the observability table.
511
- * Returns the new row ID.
512
- */
513
- export function addConversationTurn(
514
- db: Database.Database,
515
- sessionId: string,
516
- turnNumber: number,
517
- userPrompt: string,
518
- assistantResponse: string | null,
519
- toolCallsJson: string | null,
520
- toolCallCount: number,
521
- promptTokens: number,
522
- responseTokens: number
523
- ): number {
524
- const result = db.prepare(`
525
- INSERT INTO conversation_turns (session_id, turn_number, user_prompt, assistant_response, tool_calls_json, tool_call_count, prompt_tokens, response_tokens)
526
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
527
- `).run(
528
- sessionId, turnNumber, userPrompt,
529
- assistantResponse ? assistantResponse.slice(0, 10000) : null,
530
- toolCallsJson, toolCallCount, promptTokens, responseTokens
531
- );
532
- return Number(result.lastInsertRowid);
533
- }
534
-
535
- /**
536
- * Insert a tool call detail record.
537
- */
538
- export function addToolCallDetail(
539
- db: Database.Database,
540
- sessionId: string,
541
- turnNumber: number,
542
- toolName: string,
543
- inputSummary: string | null,
544
- inputSize: number,
545
- outputSize: number,
546
- success: boolean,
547
- filesInvolved?: string[]
548
- ): void {
549
- db.prepare(`
550
- INSERT INTO tool_call_details (session_id, turn_number, tool_name, tool_input_summary, tool_input_size, tool_output_size, tool_success, files_involved)
551
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
552
- `).run(
553
- sessionId, turnNumber, toolName,
554
- inputSummary ? inputSummary.slice(0, 500) : null,
555
- inputSize, outputSize, success ? 1 : 0,
556
- filesInvolved ? JSON.stringify(filesInvolved) : null
557
- );
558
- }
559
-
560
- /**
561
- * Get the last processed line number for incremental transcript parsing.
562
- */
563
- export function getLastProcessedLine(db: Database.Database, sessionId: string): number {
564
- const row = db.prepare('SELECT value FROM memory_meta WHERE key = ?').get(`last_processed_line:${sessionId}`) as { value: string } | undefined;
565
- return row ? parseInt(row.value, 10) : 0;
566
- }
567
-
568
- /**
569
- * Set the last processed line number for incremental transcript parsing.
570
- */
571
- export function setLastProcessedLine(db: Database.Database, sessionId: string, lineNumber: number): void {
572
- db.prepare('INSERT OR REPLACE INTO memory_meta (key, value) VALUES (?, ?)').run(`last_processed_line:${sessionId}`, String(lineNumber));
573
- }
574
-
575
- /**
576
- * Delete conversation turns and tool call details older than retention period.
577
- */
578
- export function pruneOldConversationTurns(db: Database.Database, retentionDays: number = 90): { turnsDeleted: number; detailsDeleted: number } {
579
- const cutoffEpoch = Math.floor(Date.now() / 1000) - (retentionDays * 86400);
580
- const turnsResult = db.prepare('DELETE FROM conversation_turns WHERE created_at_epoch < ?').run(cutoffEpoch);
581
- const detailsResult = db.prepare('DELETE FROM tool_call_details WHERE created_at_epoch < ?').run(cutoffEpoch);
582
- return { turnsDeleted: turnsResult.changes, detailsDeleted: detailsResult.changes };
583
- }
584
-
585
- /**
586
- * Get conversation turns for a session (for replay).
587
- */
588
- export function getConversationTurns(db: Database.Database, sessionId: string, opts?: {
589
- turnFrom?: number;
590
- turnTo?: number;
591
- includeToolCalls?: boolean;
592
- }): Array<{
593
- id: number;
594
- turn_number: number;
595
- user_prompt: string;
596
- assistant_response: string | null;
597
- tool_calls_json: string | null;
598
- tool_call_count: number;
599
- prompt_tokens: number | null;
600
- response_tokens: number | null;
601
- created_at: string;
602
- }> {
603
- 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 = ?';
604
- const params: (string | number)[] = [sessionId];
605
-
606
- if (opts?.turnFrom !== undefined) {
607
- sql += ' AND turn_number >= ?';
608
- params.push(opts.turnFrom);
609
- }
610
- if (opts?.turnTo !== undefined) {
611
- sql += ' AND turn_number <= ?';
612
- params.push(opts.turnTo);
613
- }
614
-
615
- sql += ' ORDER BY turn_number ASC';
616
-
617
- return db.prepare(sql).all(...params) as Array<{
618
- id: number; turn_number: number; user_prompt: string;
619
- assistant_response: string | null; tool_calls_json: string | null;
620
- tool_call_count: number; prompt_tokens: number | null;
621
- response_tokens: number | null; created_at: string;
622
- }>;
623
- }
624
-
625
- /**
626
- * Search conversation turns using FTS5.
627
- */
628
- export function searchConversationTurns(db: Database.Database, query: string, opts?: {
629
- sessionId?: string;
630
- dateFrom?: string;
631
- dateTo?: string;
632
- minToolCalls?: number;
633
- limit?: number;
634
- }): Array<{
635
- id: number;
636
- session_id: string;
637
- turn_number: number;
638
- user_prompt: string;
639
- tool_call_count: number;
640
- response_tokens: number | null;
641
- created_at: string;
642
- rank: number;
643
- }> {
644
- const limit = opts?.limit ?? 20;
645
- let sql = `
646
- SELECT ct.id, ct.session_id, ct.turn_number, ct.user_prompt, ct.tool_call_count, ct.response_tokens, ct.created_at, rank
647
- FROM conversation_turns_fts
648
- JOIN conversation_turns ct ON conversation_turns_fts.rowid = ct.id
649
- WHERE conversation_turns_fts MATCH ?
650
- `;
651
- const params: (string | number)[] = [query];
652
-
653
- if (opts?.sessionId) {
654
- sql += ' AND ct.session_id = ?';
655
- params.push(opts.sessionId);
656
- }
657
- if (opts?.dateFrom) {
658
- sql += ' AND ct.created_at >= ?';
659
- params.push(opts.dateFrom);
660
- }
661
- if (opts?.dateTo) {
662
- sql += ' AND ct.created_at <= ?';
663
- params.push(opts.dateTo);
664
- }
665
- if (opts?.minToolCalls !== undefined) {
666
- sql += ' AND ct.tool_call_count >= ?';
667
- params.push(opts.minToolCalls);
668
- }
669
-
670
- sql += ' ORDER BY rank LIMIT ?';
671
- params.push(limit);
672
-
673
- return db.prepare(sql).all(...params) as Array<{
674
- id: number; session_id: string; turn_number: number;
675
- user_prompt: string; tool_call_count: number;
676
- response_tokens: number | null; created_at: string; rank: number;
677
- }>;
678
- }
679
-
680
- /**
681
- * Get tool usage patterns (aggregated stats).
682
- */
683
- export function getToolPatterns(db: Database.Database, opts?: {
684
- sessionId?: string;
685
- toolName?: string;
686
- dateFrom?: string;
687
- groupBy?: 'tool' | 'session' | 'day';
688
- }): Array<Record<string, unknown>> {
689
- const groupBy = opts?.groupBy ?? 'tool';
690
- const params: (string | number)[] = [];
691
- let whereClause = '';
692
- const conditions: string[] = [];
693
-
694
- if (opts?.sessionId) {
695
- conditions.push('session_id = ?');
696
- params.push(opts.sessionId);
697
- }
698
- if (opts?.toolName) {
699
- conditions.push('tool_name = ?');
700
- params.push(opts.toolName);
701
- }
702
- if (opts?.dateFrom) {
703
- conditions.push('created_at >= ?');
704
- params.push(opts.dateFrom);
705
- }
706
-
707
- if (conditions.length > 0) {
708
- whereClause = 'WHERE ' + conditions.join(' AND ');
709
- }
710
-
711
- let sql: string;
712
- switch (groupBy) {
713
- case 'session':
714
- sql = `SELECT session_id, COUNT(*) as call_count, COUNT(DISTINCT tool_name) as unique_tools,
715
- SUM(CASE WHEN tool_success = 1 THEN 1 ELSE 0 END) as successes,
716
- SUM(CASE WHEN tool_success = 0 THEN 1 ELSE 0 END) as failures,
717
- AVG(tool_output_size) as avg_output_size
718
- FROM tool_call_details ${whereClause}
719
- GROUP BY session_id ORDER BY call_count DESC`;
720
- break;
721
- case 'day':
722
- sql = `SELECT date(created_at) as day, COUNT(*) as call_count, COUNT(DISTINCT tool_name) as unique_tools,
723
- SUM(CASE WHEN tool_success = 1 THEN 1 ELSE 0 END) as successes
724
- FROM tool_call_details ${whereClause}
725
- GROUP BY date(created_at) ORDER BY day DESC`;
726
- break;
727
- default: // 'tool'
728
- sql = `SELECT tool_name, COUNT(*) as call_count,
729
- SUM(CASE WHEN tool_success = 1 THEN 1 ELSE 0 END) as successes,
730
- SUM(CASE WHEN tool_success = 0 THEN 1 ELSE 0 END) as failures,
731
- AVG(tool_output_size) as avg_output_size,
732
- AVG(tool_input_size) as avg_input_size
733
- FROM tool_call_details ${whereClause}
734
- GROUP BY tool_name ORDER BY call_count DESC`;
735
- break;
736
- }
737
-
738
- return db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
739
- }
740
-
741
- /**
742
- * Get session stats for observability.
743
- */
744
- export function getSessionStats(db: Database.Database, opts?: {
745
- sessionId?: string;
746
- limit?: number;
747
- }): Array<Record<string, unknown>> {
748
- if (opts?.sessionId) {
749
- // Single session stats
750
- 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>;
751
- 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>>;
752
- const session = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(opts.sessionId) as Record<string, unknown> | undefined;
753
-
754
- return [{
755
- session_id: opts.sessionId,
756
- status: session?.status ?? 'unknown',
757
- started_at: session?.started_at ?? null,
758
- ended_at: session?.ended_at ?? null,
759
- ...turns,
760
- tool_breakdown: toolBreakdown,
761
- }];
762
- }
763
-
764
- const limit = opts?.limit ?? 10;
765
- return db.prepare(`
766
- SELECT s.session_id, s.status, s.started_at, s.ended_at,
767
- COUNT(ct.id) as turn_count,
768
- COALESCE(SUM(ct.tool_call_count), 0) as total_tool_calls,
769
- COALESCE(SUM(ct.prompt_tokens), 0) as total_prompt_tokens,
770
- COALESCE(SUM(ct.response_tokens), 0) as total_response_tokens
771
- FROM sessions s
772
- LEFT JOIN conversation_turns ct ON s.session_id = ct.session_id
773
- GROUP BY s.session_id
774
- ORDER BY s.started_at_epoch DESC
775
- LIMIT ?
776
- `).all(limit) as Array<Record<string, unknown>>;
777
- }
778
-
779
- /**
780
- * Get database size information for observability monitoring.
781
- */
782
- export function getObservabilityDbSize(db: Database.Database): {
783
- conversation_turns_count: number;
784
- tool_call_details_count: number;
785
- observations_count: number;
786
- db_page_count: number;
787
- db_page_size: number;
788
- estimated_size_mb: number;
789
- } {
790
- const turnsCount = (db.prepare('SELECT COUNT(*) as c FROM conversation_turns').get() as { c: number }).c;
791
- const detailsCount = (db.prepare('SELECT COUNT(*) as c FROM tool_call_details').get() as { c: number }).c;
792
- const obsCount = (db.prepare('SELECT COUNT(*) as c FROM observations').get() as { c: number }).c;
793
- const pageCount = (db.pragma('page_count') as Array<{ page_count: number }>)[0]?.page_count ?? 0;
794
- const pageSize = (db.pragma('page_size') as Array<{ page_size: number }>)[0]?.page_size ?? 4096;
795
-
796
- return {
797
- conversation_turns_count: turnsCount,
798
- tool_call_details_count: detailsCount,
799
- observations_count: obsCount,
800
- db_page_count: pageCount,
801
- db_page_size: pageSize,
802
- estimated_size_mb: Math.round((pageCount * pageSize) / (1024 * 1024) * 100) / 100,
803
- };
804
- }