From 2c555fde907c9f3b3f8d140064852468c3ab36a1 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 11:15:28 +0530 Subject: [PATCH 1/6] =?UTF-8?q?feat(db):=20V8=20migration=20=E2=80=94=20ad?= =?UTF-8?q?d=20session=5Fmessage=5Fcount=20to=20analysis=5Fusage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds session_message_count column to the analysis_usage table. Used by the insights command for resume detection: skip re-analysis if the session's message count hasn't changed since last analysis. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/db/migrate.ts | 16 +++++++++++++++- cli/src/db/schema.ts | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cli/src/db/migrate.ts b/cli/src/db/migrate.ts index 37af660..bb25dd1 100644 --- a/cli/src/db/migrate.ts +++ b/cli/src/db/migrate.ts @@ -4,6 +4,7 @@ import { SCHEMA_SQL, CURRENT_SCHEMA_VERSION } from './schema.js'; export interface MigrationResult { v6Applied: boolean; v7Applied: boolean; + v8Applied: boolean; } /** @@ -17,6 +18,7 @@ export interface MigrationResult { * Version 5: Add deleted_at column to sessions for soft-delete (user-initiated hide) * Version 6: Add compact_count, auto_compact_count, slash_commands columns to sessions * Version 7: Add analysis_usage table for tracking LLM analysis costs per session + * Version 8: Add session_message_count to analysis_usage for resume detection */ export function runMigrations(db: Database.Database): MigrationResult { // Create schema_version table first if it doesn't exist. @@ -62,7 +64,13 @@ export function runMigrations(db: Database.Database): MigrationResult { v7Applied = true; } - return { v6Applied, v7Applied }; + let v8Applied = false; + if (currentVersion < 8) { + applyV8(db); + v8Applied = true; + } + + return { v6Applied, v7Applied, v8Applied }; } function getCurrentVersion(db: Database.Database): number { @@ -133,6 +141,12 @@ function applyV6(db: Database.Database): void { db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(6); } + +function applyV8(db: Database.Database): void { + db.exec(`ALTER TABLE analysis_usage ADD COLUMN session_message_count INTEGER`); + db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(8); +} + function applyV7(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS analysis_usage ( diff --git a/cli/src/db/schema.ts b/cli/src/db/schema.ts index 8e15d02..5eb09b9 100644 --- a/cli/src/db/schema.ts +++ b/cli/src/db/schema.ts @@ -128,6 +128,6 @@ CREATE TABLE IF NOT EXISTS usage_stats ( ); `; -export const CURRENT_SCHEMA_VERSION = 7; +export const CURRENT_SCHEMA_VERSION = 8; export { runMigrations } from './migrate.js'; From f4d7f6266ec65e4bbf05acb7bc5a730bc4e55ad7 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 11:15:34 +0530 Subject: [PATCH 2/6] feat(cli): add syncSingleFile() to sync.ts Exports a focused sync function that parses and writes a single session file to SQLite without scanning all providers. Used by the insights --hook path to guarantee fresh data before analysis. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/sync.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts index 608eda4..15e868d 100644 --- a/cli/src/commands/sync.ts +++ b/cli/src/commands/sync.ts @@ -366,6 +366,24 @@ export async function syncCommand(options: SyncOptions = {}): Promise { } } + +/** + * Sync a single session file to SQLite. + * Used by the insights --hook path to guarantee fresh data before analysis. + * Much faster than full sync (no directory scanning, no other providers). + */ +export async function syncSingleFile(options: { + filePath: string; + sourceTool?: string; + quiet?: boolean; +}): Promise { + const provider = getProvider(options.sourceTool ?? 'claude-code'); + const session = await provider.parse(options.filePath); + if (!session) return; + insertSessionWithProjectAndReturnIsNew(session, false); + insertMessages(session); +} + /** * Filter files to only those that need syncing */ From af48e4b2a375aafd1d2a2c3b2dd07391c95db00a Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 11:15:44 +0530 Subject: [PATCH 3/6] feat(cli): add insights command with --native, --hook, --force modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `code-insights insights ` command: - --native: delegates to ClaudeNativeRunner (claude -p, zero config) - default: delegates to ProviderRunner (configured LLM) - --hook: reads { session_id, transcript_path } from stdin JSON, calls syncSingleFile() before analysis - --force: bypasses resume detection, always re-analyzes - -q: suppresses output for hook usage Two-pass analysis: session analysis then prompt quality, both saving to SQLite via inline DB helpers (no circular server import). Resume detection in hook mode: compares session.message_count against analysis_usage.session_message_count — skips if unchanged. Also adds `insights check` subcommand for finding unanalyzed sessions. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/__tests__/insights.test.ts | 423 +++++++++++++ cli/src/commands/insights.ts | 635 ++++++++++++++++++++ 2 files changed, 1058 insertions(+) create mode 100644 cli/src/commands/__tests__/insights.test.ts create mode 100644 cli/src/commands/insights.ts diff --git a/cli/src/commands/__tests__/insights.test.ts b/cli/src/commands/__tests__/insights.test.ts new file mode 100644 index 0000000..7498fbf --- /dev/null +++ b/cli/src/commands/__tests__/insights.test.ts @@ -0,0 +1,423 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../../db/migrate.js'; + +// ── Shared mocks ────────────────────────────────────────────────────────────── + +let mockDb: Database.Database; + +vi.mock('../../db/client.js', () => ({ + getDb: () => mockDb, +})); + +vi.mock('../../utils/telemetry.js', () => ({ + trackEvent: vi.fn(), + captureError: vi.fn(), + classifyError: vi.fn(() => ({ error_type: 'unknown', error_message: 'unknown' })), +})); + +vi.mock('../../utils/config.js', () => ({ + loadSyncState: () => ({ lastSync: '', files: {} }), + saveSyncState: vi.fn(), + getConfigDir: () => '/tmp', + loadConfig: vi.fn(() => null), +})); + +const mockInsertSession = vi.fn(() => true); +const mockInsertMessages = vi.fn(); +vi.mock('../../db/write.js', () => ({ + insertSessionWithProjectAndReturnIsNew: mockInsertSession, + insertMessages: mockInsertMessages, + recalculateUsageStats: vi.fn(() => ({ sessionsWithUsage: 0 })), +})); + +const mockValidate = vi.fn(); +const mockRunAnalysis = vi.fn(); +vi.mock('../../analysis/native-runner.js', () => { + // Must use a real class (not vi.fn()) so `new ClaudeNativeRunner()` works + class MockNativeRunner { + readonly name = 'claude-code-native'; + runAnalysis = mockRunAnalysis; + static validate = mockValidate; + } + return { ClaudeNativeRunner: MockNativeRunner }; +}); + +const mockFromConfig = vi.fn(); +const mockProviderRunAnalysis = vi.fn(); +vi.mock('../../analysis/provider-runner.js', () => ({ + ProviderRunner: { + fromConfig: () => { + mockFromConfig(); + return { name: 'anthropic', runAnalysis: mockProviderRunAnalysis }; + }, + }, +})); + +const mockProvider = { + parse: vi.fn(), + getProviderName: vi.fn(() => 'claude-code'), +}; +vi.mock('../../providers/registry.js', () => ({ + getProvider: vi.fn(() => mockProvider), + getAllProviders: vi.fn(() => [mockProvider]), +})); + +// ── Seed helpers ────────────────────────────────────────────────────────────── + +function seedSession(db: Database.Database, id = 'sess1', messageCount = 10): void { + db.exec(` + INSERT OR IGNORE INTO projects (id, name, path, last_activity) + VALUES ('p1', 'test-project', '/test', datetime('now')); + INSERT OR IGNORE INTO sessions + (id, project_id, project_name, project_path, started_at, ended_at, message_count) + VALUES ('${id}', 'p1', 'test-project', '/test', datetime('now'), datetime('now'), ${messageCount}); + `); +} + +function makeAnalysisResponse(): string { + return JSON.stringify({ + summary: { title: 'Test session', content: 'Did things', bullets: [] }, + decisions: [], + learnings: [], + facets: { + outcome_satisfaction: 'high', + workflow_pattern: 'direct-execution', + had_course_correction: false, + course_correction_reason: null, + iteration_count: 0, + friction_points: [], + effective_patterns: [], + }, + }); +} + +function makePQResponse(): string { + return JSON.stringify({ + efficiency_score: 75, + assessment: 'Good prompting overall.', + message_overhead: 0, + takeaways: [], + findings: [], + dimension_scores: { + context_provision: 80, + request_specificity: 70, + scope_management: 75, + information_timing: 80, + correction_quality: 75, + }, + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('V8 migration — session_message_count column', () => { + it('adds session_message_count column to analysis_usage', () => { + const db = new Database(':memory:'); + runMigrations(db); + + db.exec(` + INSERT INTO projects (id, name, path, last_activity) + VALUES ('p1', 'test', '/test', datetime('now')); + INSERT INTO sessions (id, project_id, project_name, project_path, started_at, ended_at) + VALUES ('s1', 'p1', 'test', '/test', datetime('now'), datetime('now')); + `); + db.prepare(` + INSERT INTO analysis_usage (session_id, analysis_type, provider, model, session_message_count) + VALUES ('s1', 'session', 'claude-code-native', 'claude-native', 10) + `).run(); + + const row = db.prepare( + 'SELECT session_message_count FROM analysis_usage WHERE session_id = ?' + ).get('s1') as { session_message_count: number }; + + expect(row.session_message_count).toBe(10); + db.close(); + }); + + it('double-apply leaves exactly one schema_version row per version (now up to 8)', () => { + const db = new Database(':memory:'); + runMigrations(db); + runMigrations(db); + + const rows = db + .prepare('SELECT version FROM schema_version ORDER BY version') + .all() as Array<{ version: number }>; + + expect(rows.map(r => r.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + db.close(); + }); + + it('session_message_count defaults to NULL when not provided', () => { + const db = new Database(':memory:'); + runMigrations(db); + + db.exec(` + INSERT INTO projects (id, name, path, last_activity) + VALUES ('p2', 'test', '/test', datetime('now')); + INSERT INTO sessions (id, project_id, project_name, project_path, started_at, ended_at) + VALUES ('s2', 'p2', 'test', '/test', datetime('now'), datetime('now')); + `); + db.prepare(` + INSERT INTO analysis_usage (session_id, analysis_type, provider, model) + VALUES ('s2', 'session', 'anthropic', 'claude-sonnet-4-5') + `).run(); + + const row = db.prepare( + 'SELECT session_message_count FROM analysis_usage WHERE session_id = ?' + ).get('s2') as { session_message_count: number | null }; + + expect(row.session_message_count).toBeNull(); + db.close(); + }); +}); + +describe('runInsightsCommand — provider mode (no --native)', () => { + beforeEach(() => { + mockDb = new Database(':memory:'); + runMigrations(mockDb); + mockRunAnalysis.mockReset(); + mockProviderRunAnalysis.mockReset(); + mockFromConfig.mockReset(); + mockValidate.mockReset(); + mockInsertSession.mockReset(); + mockInsertMessages.mockReset(); + mockProvider.parse.mockReset(); + }); + + it('calls ProviderRunner.fromConfig() when --native is false', async () => { + seedSession(mockDb); + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ sessionId: 'sess1', native: false, quiet: true }); + + expect(mockFromConfig).toHaveBeenCalledTimes(1); + expect(mockValidate).not.toHaveBeenCalled(); + }); + + it('saves insights to the database', async () => { + seedSession(mockDb); + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ sessionId: 'sess1', native: false, quiet: true }); + + const insights = mockDb.prepare('SELECT * FROM insights WHERE session_id = ?').all('sess1'); + // summary + prompt_quality + expect(insights.length).toBeGreaterThanOrEqual(2); + }); + + it('records analysis_usage for session and prompt_quality', async () => { + seedSession(mockDb); + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ sessionId: 'sess1', native: false, quiet: true }); + + const usageRows = mockDb + .prepare('SELECT analysis_type FROM analysis_usage WHERE session_id = ? ORDER BY analysis_type') + .all('sess1') as Array<{ analysis_type: string }>; + + expect(usageRows.map(r => r.analysis_type)).toEqual(['prompt_quality', 'session']); + }); + + it('records session_message_count in analysis_usage (V8)', async () => { + seedSession(mockDb, 'sess1', 12); + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ sessionId: 'sess1', native: false, quiet: true }); + + const row = mockDb.prepare( + `SELECT session_message_count FROM analysis_usage WHERE session_id = ? AND analysis_type = 'session'` + ).get('sess1') as { session_message_count: number }; + + expect(row.session_message_count).toBe(12); + }); + + it('throws if session not found in DB', async () => { + const { runInsightsCommand } = await import('../insights.js'); + await expect( + runInsightsCommand({ sessionId: 'nonexistent', native: false, quiet: true }) + ).rejects.toThrow(/not found/i); + }); +}); + +describe('runInsightsCommand — native mode (--native)', () => { + beforeEach(() => { + mockDb = new Database(':memory:'); + runMigrations(mockDb); + mockRunAnalysis.mockReset(); + mockValidate.mockReset(); + mockFromConfig.mockReset(); + mockProviderRunAnalysis.mockReset(); + mockInsertSession.mockReset(); + mockInsertMessages.mockReset(); + mockProvider.parse.mockReset(); + }); + + it('calls ClaudeNativeRunner.validate() and uses native runner', async () => { + seedSession(mockDb); + mockRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 200, inputTokens: 0, outputTokens: 0, model: 'claude-native', provider: 'claude-code-native' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 150, inputTokens: 0, outputTokens: 0, model: 'claude-native', provider: 'claude-code-native' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ sessionId: 'sess1', native: true, quiet: true }); + + expect(mockValidate).toHaveBeenCalledTimes(1); + expect(mockFromConfig).not.toHaveBeenCalled(); + expect(mockRunAnalysis).toHaveBeenCalledTimes(2); + }); +}); + +describe('runInsightsCommand — --force flag', () => { + beforeEach(() => { + mockDb = new Database(':memory:'); + runMigrations(mockDb); + mockProviderRunAnalysis.mockReset(); + mockFromConfig.mockReset(); + mockInsertSession.mockReset(); + mockInsertMessages.mockReset(); + mockProvider.parse.mockReset(); + }); + + it('re-analyzes even if analysis_usage exists with matching message_count', async () => { + seedSession(mockDb, 'sess1', 10); + + mockDb.prepare(` + INSERT INTO analysis_usage (session_id, analysis_type, provider, model, session_message_count) + VALUES ('sess1', 'session', 'openai', 'gpt-4', 10) + `).run(); + + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ sessionId: 'sess1', native: false, force: true, quiet: true }); + + expect(mockProviderRunAnalysis).toHaveBeenCalledTimes(2); + }); +}); + +describe('runInsightsCommand — resume detection (hookMode)', () => { + beforeEach(() => { + mockDb = new Database(':memory:'); + runMigrations(mockDb); + mockProviderRunAnalysis.mockReset(); + mockFromConfig.mockReset(); + mockInsertSession.mockReset(); + mockInsertMessages.mockReset(); + mockProvider.parse.mockReset(); + }); + + it('skips analysis when message_count matches existing analysis_usage', async () => { + seedSession(mockDb, 'sess1', 10); + + mockDb.prepare(` + INSERT INTO analysis_usage (session_id, analysis_type, provider, model, session_message_count) + VALUES ('sess1', 'session', 'openai', 'gpt-4', 10) + `).run(); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ + sessionId: 'sess1', + native: false, + hookMode: true, + quiet: true, + }); + + expect(mockProviderRunAnalysis).not.toHaveBeenCalled(); + }); + + it('proceeds when message_count differs from analysis_usage', async () => { + seedSession(mockDb, 'sess1', 15); + + mockDb.prepare(` + INSERT INTO analysis_usage (session_id, analysis_type, provider, model, session_message_count) + VALUES ('sess1', 'session', 'openai', 'gpt-4', 10) + `).run(); + + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ + sessionId: 'sess1', + native: false, + hookMode: true, + quiet: true, + }); + + expect(mockProviderRunAnalysis).toHaveBeenCalledTimes(2); + }); + + it('proceeds when no analysis_usage row exists', async () => { + seedSession(mockDb, 'sess1', 8); + + mockProviderRunAnalysis + .mockResolvedValueOnce({ rawJson: makeAnalysisResponse(), durationMs: 100, inputTokens: 50, outputTokens: 50, model: 'gpt-4', provider: 'openai' }) + .mockResolvedValueOnce({ rawJson: makePQResponse(), durationMs: 80, inputTokens: 30, outputTokens: 30, model: 'gpt-4', provider: 'openai' }); + + const { runInsightsCommand } = await import('../insights.js'); + await runInsightsCommand({ + sessionId: 'sess1', + native: false, + hookMode: true, + quiet: true, + }); + + expect(mockProviderRunAnalysis).toHaveBeenCalledTimes(2); + }); +}); + +describe('syncSingleFile', () => { + beforeEach(() => { + mockDb = new Database(':memory:'); + runMigrations(mockDb); + mockInsertSession.mockReset(); + mockInsertMessages.mockReset(); + mockProvider.parse.mockReset(); + }); + + it('calls provider.parse() and inserts session and messages', async () => { + const fakeSession = { + id: 'parsed-sess', + project_id: 'p1', + project_name: 'test', + project_path: '/test', + messages: [{ id: 'm1', type: 'user', content: 'hello', timestamp: new Date().toISOString() }], + messageCount: 5, + }; + mockProvider.parse.mockResolvedValueOnce(fakeSession); + mockInsertSession.mockReturnValue(true); + + const { syncSingleFile } = await import('../sync.js'); + await syncSingleFile({ filePath: '/path/to/session.jsonl' }); + + expect(mockProvider.parse).toHaveBeenCalledWith('/path/to/session.jsonl'); + expect(mockInsertSession).toHaveBeenCalledWith(fakeSession, false); + expect(mockInsertMessages).toHaveBeenCalledWith(fakeSession); + }); + + it('does nothing if provider.parse() returns null', async () => { + mockProvider.parse.mockResolvedValueOnce(null); + + const { syncSingleFile } = await import('../sync.js'); + await syncSingleFile({ filePath: '/path/to/empty.jsonl' }); + + expect(mockInsertSession).not.toHaveBeenCalled(); + expect(mockInsertMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/commands/insights.ts b/cli/src/commands/insights.ts new file mode 100644 index 0000000..ebc70ed --- /dev/null +++ b/cli/src/commands/insights.ts @@ -0,0 +1,635 @@ +/** + * insights command — analyze a session using configured LLM or native claude -p. + * + * Two modes: + * --native Use claude -p (user's Claude subscription, zero config) + * (default) Use configured LLM provider (OpenAI, Anthropic, Gemini, Ollama) + * + * Hook mode (--hook): + * Reads { session_id, transcript_path, cwd } from stdin JSON, + * calls syncSingleFile() to guarantee fresh data, then analyzes. + * + * Resume detection (hook mode only): + * Skips analysis if analysis_usage.session_message_count matches current + * sessions.message_count — the session has not changed since last analysis. + * Bypassed with --force. + */ + +import { randomUUID } from 'crypto'; +import chalk from 'chalk'; +import { getDb } from '../db/client.js'; +import { ClaudeNativeRunner } from '../analysis/native-runner.js'; +import { ProviderRunner } from '../analysis/provider-runner.js'; +import { + SHARED_ANALYST_SYSTEM_PROMPT, + buildSessionAnalysisInstructions, + buildPromptQualityInstructions, + buildCacheableConversationBlock, +} from '../analysis/prompts.js'; +import { formatMessagesForAnalysis } from '../analysis/message-format.js'; +import { parseAnalysisResponse, parsePromptQualityResponse } from '../analysis/response-parsers.js'; +import { normalizePatternCategory } from '../analysis/pattern-normalize.js'; +import { normalizePromptQualityCategory } from '../analysis/prompt-quality-normalize.js'; +import type { AnalysisRunner } from '../analysis/runner-types.js'; +import type { SQLiteMessageRow, AnalysisResponse, PromptQualityResponse } from '../analysis/prompt-types.js'; + +const ANALYSIS_VERSION = '3.0.0'; + +// ── DB types (mirror server/src/llm/analysis-db.ts, no cross-package import) ── + +interface SessionRow { + id: string; + project_id: string; + project_name: string; + project_path: string; + summary: string | null; + ended_at: string; + message_count: number; + compact_count: number | null; + auto_compact_count: number | null; + slash_commands: string | null; +} + +interface InsightRow { + id: string; + session_id: string; + project_id: string; + project_name: string; + type: string; + title: string; + content: string; + summary: string; + bullets: string; + confidence: number; + source: 'llm'; + metadata: string | null; + timestamp: string; + created_at: string; + scope: string; + analysis_version: string; +} + +// ── Inline DB helpers (CLI cannot import from @code-insights/server) ────────── + +function loadSessionForAnalysis(sessionId: string): SessionRow | null { + const db = getDb(); + return db.prepare(` + SELECT id, project_id, project_name, project_path, summary, ended_at, + message_count, compact_count, auto_compact_count, slash_commands + FROM sessions + WHERE id = ? AND deleted_at IS NULL + `).get(sessionId) as SessionRow | null; +} + +function loadSessionMessages(sessionId: string): SQLiteMessageRow[] { + const db = getDb(); + return db.prepare(` + SELECT id, session_id, type, content, thinking, tool_calls, tool_results, usage, timestamp, parent_id + FROM messages + WHERE session_id = ? + ORDER BY timestamp ASC + `).all(sessionId) as SQLiteMessageRow[]; +} + +function saveInsightsToDb(insights: InsightRow[]): void { + if (insights.length === 0) return; + const db = getDb(); + const insert = db.prepare(` + INSERT OR REPLACE INTO insights ( + id, session_id, project_id, project_name, type, title, content, + summary, bullets, confidence, source, metadata, timestamp, + created_at, scope, analysis_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const insertMany = db.transaction((rows: InsightRow[]) => { + for (const row of rows) { + insert.run( + row.id, row.session_id, row.project_id, row.project_name, + row.type, row.title, row.content, row.summary, row.bullets, + row.confidence, row.source, row.metadata, row.timestamp, + row.created_at, row.scope, row.analysis_version, + ); + } + }); + insertMany(insights); +} + +function deleteSessionInsights(sessionId: string, opts: { + excludeTypes?: string[]; + excludeIds?: string[]; +}): void { + const db = getDb(); + const conditions: string[] = ['session_id = ?']; + const params: (string | number)[] = [sessionId]; + + if (opts.excludeTypes && opts.excludeTypes.length > 0) { + conditions.push(`type NOT IN (${opts.excludeTypes.map(() => '?').join(', ')})`); + params.push(...opts.excludeTypes); + } + if (opts.excludeIds && opts.excludeIds.length > 0) { + conditions.push(`id NOT IN (${opts.excludeIds.map(() => '?').join(', ')})`); + params.push(...opts.excludeIds); + } + + db.prepare(`DELETE FROM insights WHERE ${conditions.join(' AND ')}`).run(...params); +} + +function saveFacetsToDb( + sessionId: string, + facets: NonNullable, +): void { + const db = getDb(); + const normalizedPatterns = Array.isArray(facets.effective_patterns) + ? facets.effective_patterns.map(ep => ({ + ...ep, + category: ep.category ? normalizePatternCategory(ep.category) : 'uncategorized', + })) + : []; + + db.prepare(` + INSERT OR REPLACE INTO session_facets + (session_id, outcome_satisfaction, workflow_pattern, had_course_correction, + course_correction_reason, iteration_count, friction_points, effective_patterns, + analysis_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + sessionId, + facets.outcome_satisfaction, + facets.workflow_pattern ?? null, + facets.had_course_correction ? 1 : 0, + facets.course_correction_reason ?? null, + facets.iteration_count, + JSON.stringify(Array.isArray(facets.friction_points) ? facets.friction_points : []), + JSON.stringify(normalizedPatterns), + ANALYSIS_VERSION, + ); +} + +function saveAnalysisUsage(data: { + session_id: string; + analysis_type: 'session' | 'prompt_quality'; + provider: string; + model: string; + input_tokens: number; + output_tokens: number; + cache_creation_tokens?: number; + cache_read_tokens?: number; + estimated_cost_usd: number; + duration_ms?: number; + session_message_count?: number; +}): void { + const db = getDb(); + db.prepare(` + INSERT OR REPLACE INTO analysis_usage + (session_id, analysis_type, provider, model, + input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + estimated_cost_usd, duration_ms, chunk_count, session_message_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + `).run( + data.session_id, + data.analysis_type, + data.provider, + data.model, + data.input_tokens, + data.output_tokens, + data.cache_creation_tokens ?? 0, + data.cache_read_tokens ?? 0, + data.estimated_cost_usd, + data.duration_ms ?? null, + data.session_message_count ?? null, + ); +} + +// ── Row converters ──────────────────────────────────────────────────────────── + +function convertToInsightRows(response: AnalysisResponse, session: SessionRow): InsightRow[] { + const insights: InsightRow[] = []; + const now = new Date().toISOString(); + + insights.push({ + id: randomUUID(), + session_id: session.id, + project_id: session.project_id, + project_name: session.project_name, + type: 'summary', + title: response.summary.title, + content: response.summary.content, + summary: response.summary.content, + bullets: JSON.stringify(response.summary.bullets), + confidence: 0.9, + source: 'llm', + metadata: response.summary.outcome + ? JSON.stringify({ outcome: response.summary.outcome }) + : null, + timestamp: session.ended_at, + created_at: now, + scope: 'session', + analysis_version: ANALYSIS_VERSION, + }); + + for (const decision of (response.decisions ?? [])) { + const confidence = decision.confidence ?? 85; + if (confidence < 70) continue; + + const content = decision.situation && decision.choice + ? `${decision.situation} → ${decision.choice}` + : decision.choice || decision.situation || decision.title; + + insights.push({ + id: randomUUID(), + session_id: session.id, + project_id: session.project_id, + project_name: session.project_name, + type: 'decision', + title: decision.title, + content, + summary: (decision.choice || content).slice(0, 200), + bullets: JSON.stringify( + (decision.alternatives || []) + .filter((a: { option?: string }) => a?.option) + .map((a: { option?: string; rejected_because?: string }) => + `${a.option}: ${a.rejected_because || 'no reason given'}`) + ), + confidence: confidence / 100, + source: 'llm', + metadata: JSON.stringify({ + situation: decision.situation, + choice: decision.choice, + reasoning: decision.reasoning, + alternatives: decision.alternatives, + trade_offs: decision.trade_offs, + revisit_when: decision.revisit_when, + evidence: decision.evidence, + }), + timestamp: session.ended_at, + created_at: now, + scope: 'session', + analysis_version: ANALYSIS_VERSION, + }); + } + + for (const learning of (response.learnings ?? [])) { + const confidence = learning.confidence ?? 80; + if (confidence < 70) continue; + + const content = learning.takeaway || learning.title; + + insights.push({ + id: randomUUID(), + session_id: session.id, + project_id: session.project_id, + project_name: session.project_name, + type: 'learning', + title: learning.title, + content, + summary: content.slice(0, 200), + bullets: JSON.stringify([]), + confidence: confidence / 100, + source: 'llm', + metadata: JSON.stringify({ + symptom: learning.symptom, + root_cause: learning.root_cause, + takeaway: learning.takeaway, + applies_when: learning.applies_when, + evidence: learning.evidence, + }), + timestamp: session.ended_at, + created_at: now, + scope: 'session', + analysis_version: ANALYSIS_VERSION, + }); + } + + return insights; +} + +function convertPQToInsightRow(response: PromptQualityResponse, session: SessionRow): InsightRow { + const now = new Date().toISOString(); + + const normalizedFindings = (response.findings ?? []).map((f: { category?: string }) => ({ + ...f, + category: f.category ? normalizePromptQualityCategory(f.category) : 'uncategorized', + })); + const normalizedTakeaways = (response.takeaways ?? []).map((t: { category?: string }) => ({ + ...t, + category: t.category ? normalizePromptQualityCategory(t.category) : 'uncategorized', + })); + + return { + id: randomUUID(), + session_id: session.id, + project_id: session.project_id, + project_name: session.project_name, + type: 'prompt_quality', + title: `Prompt Efficiency: ${response.efficiency_score}/100`, + content: response.assessment, + summary: response.assessment, + bullets: JSON.stringify([]), + confidence: 0.85, + source: 'llm', + metadata: JSON.stringify({ + efficiency_score: response.efficiency_score, + message_overhead: response.message_overhead, + takeaways: normalizedTakeaways, + findings: normalizedFindings, + dimension_scores: response.dimension_scores, + }), + timestamp: session.ended_at, + created_at: now, + scope: 'session', + analysis_version: ANALYSIS_VERSION, + }; +} + +// ── Resume detection ────────────────────────────────────────────────────────── + +function isAlreadyAnalyzed(sessionId: string, currentMessageCount: number): boolean { + const db = getDb(); + const row = db.prepare(` + SELECT session_message_count FROM analysis_usage + WHERE session_id = ? AND analysis_type = 'session' + `).get(sessionId) as { session_message_count: number | null } | undefined; + + if (!row) return false; + return row.session_message_count === currentMessageCount; +} + +// ── Command options ─────────────────────────────────────────────────────────── + +export interface InsightsCommandOptions { + sessionId: string; + native: boolean; + hookMode?: boolean; + force?: boolean; + quiet?: boolean; + source?: string; +} + +// ── Core logic ──────────────────────────────────────────────────────────────── + +/** + * Run analysis on a session. Called by the CLI command and tests. + * + * @throws if session not found or LLM is not configured / not available + */ +export async function runInsightsCommand(options: InsightsCommandOptions): Promise { + const log = options.quiet ? () => {} : console.log.bind(console); + + // 1. Build the runner + let runner: AnalysisRunner; + if (options.native) { + ClaudeNativeRunner.validate(); + runner = new ClaudeNativeRunner(); + } else { + runner = ProviderRunner.fromConfig(); + } + + // 2. Load session from DB + const session = loadSessionForAnalysis(options.sessionId); + if (!session) { + throw new Error(`Session '${options.sessionId}' not found in local database.`); + } + + // 3. Resume detection — hook mode only (skipped when --force) + if (options.hookMode && !options.force) { + if (isAlreadyAnalyzed(options.sessionId, session.message_count)) { + return; // already analyzed at this session length + } + } + + // 4. Load messages + const messages = loadSessionMessages(options.sessionId); + + // 5. Build shared conversation block (same for both passes) + const formattedMessages = formatMessagesForAnalysis(messages); + + // Session metadata for prompt builders + const slashCommands = (() => { + try { + return JSON.parse(session.slash_commands ?? '[]') as string[]; + } catch { + return [] as string[]; + } + })(); + const sessionMeta = { + compactCount: session.compact_count ?? 0, + autoCompactCount: session.auto_compact_count ?? 0, + slashCommands, + }; + const humanMessageCount = messages.filter(m => m.type === 'user').length; + const assistantMessageCount = messages.filter(m => m.type === 'assistant').length; + const toolExchangeCount = messages.filter(m => m.tool_calls).length; + + // ── Pass 1: Session analysis ────────────────────────────────────────────── + + const sessionInstructions = buildSessionAnalysisInstructions( + session.project_name, + session.summary, + sessionMeta, + ); + const sessionUserPrompt = `${buildCacheableConversationBlock(formattedMessages).text}\n${sessionInstructions}`; + + const sessionResult = await runner.runAnalysis({ + systemPrompt: SHARED_ANALYST_SYSTEM_PROMPT, + userPrompt: sessionUserPrompt, + }); + + const parsedSession = parseAnalysisResponse(sessionResult.rawJson); + if (!parsedSession.success) { + throw new Error(`Session analysis failed: ${parsedSession.error.error_message}`); + } + + // Save session insights (upsert: insert new, delete old) + const sessionInsights = convertToInsightRows(parsedSession.data, session); + saveInsightsToDb(sessionInsights); + deleteSessionInsights(session.id, { + excludeTypes: ['prompt_quality'], + excludeIds: sessionInsights.map(i => i.id), + }); + + if (parsedSession.data.facets) { + saveFacetsToDb(session.id, parsedSession.data.facets); + } + + saveAnalysisUsage({ + session_id: session.id, + analysis_type: 'session', + provider: sessionResult.provider, + model: sessionResult.model, + input_tokens: sessionResult.inputTokens, + output_tokens: sessionResult.outputTokens, + cache_creation_tokens: sessionResult.cacheCreationTokens, + cache_read_tokens: sessionResult.cacheReadTokens, + estimated_cost_usd: 0, + duration_ms: sessionResult.durationMs, + session_message_count: session.message_count, + }); + + // ── Pass 2: Prompt quality analysis ────────────────────────────────────── + + const pqInstructions = buildPromptQualityInstructions( + session.project_name, + { humanMessageCount, assistantMessageCount, toolExchangeCount }, + sessionMeta, + ); + const pqUserPrompt = `${buildCacheableConversationBlock(formattedMessages).text}\n${pqInstructions}`; + + const pqResult = await runner.runAnalysis({ + systemPrompt: SHARED_ANALYST_SYSTEM_PROMPT, + userPrompt: pqUserPrompt, + }); + + const parsedPQ = parsePromptQualityResponse(pqResult.rawJson); + if (!parsedPQ.success) { + throw new Error(`Prompt quality analysis failed: ${parsedPQ.error.error_message}`); + } + + const pqInsight = convertPQToInsightRow(parsedPQ.data, session); + saveInsightsToDb([pqInsight]); + deleteSessionInsights(session.id, { + excludeTypes: ['summary', 'decision', 'learning'], + excludeIds: [pqInsight.id], + }); + + saveAnalysisUsage({ + session_id: session.id, + analysis_type: 'prompt_quality', + provider: pqResult.provider, + model: pqResult.model, + input_tokens: pqResult.inputTokens, + output_tokens: pqResult.outputTokens, + cache_creation_tokens: pqResult.cacheCreationTokens, + cache_read_tokens: pqResult.cacheReadTokens, + estimated_cost_usd: 0, + duration_ms: pqResult.durationMs, + session_message_count: session.message_count, + }); + + // ── Summary line ────────────────────────────────────────────────────────── + + // Non-PQ insight count (excludes summary's own entry which is always saved) + const insightCount = sessionInsights.length; + const pqScore = parsedPQ.data.efficiency_score; + log(chalk.green(`[Code Insights] Session analyzed: ${insightCount} insights, PQ ${pqScore}/100`)); +} + +// ── CLI command entry point ─────────────────────────────────────────────────── + +export async function insightsCommand( + sessionId: string | undefined, + opts: { + native?: boolean; + hook?: boolean; + source?: string; + force?: boolean; + quiet?: boolean; + } +): Promise { + const quiet = opts.quiet ?? false; + const log = quiet ? () => {} : console.log.bind(console); + + try { + let resolvedSessionId: string; + + if (opts.hook) { + // Hook mode: read { session_id, transcript_path, cwd } from stdin + const stdinData = await readStdin(); + let parsed: { session_id?: string; transcript_path?: string; cwd?: string }; + try { + parsed = JSON.parse(stdinData); + } catch { + throw new Error('--hook mode requires valid JSON on stdin (got: ' + stdinData.slice(0, 100) + ')'); + } + + if (!parsed.session_id) { + throw new Error('--hook stdin JSON missing required field: session_id'); + } + + resolvedSessionId = parsed.session_id; + + // Sync the single file before analysis + if (parsed.transcript_path) { + const { syncSingleFile } = await import('./sync.js'); + await syncSingleFile({ filePath: parsed.transcript_path, sourceTool: opts.source, quiet }); + } + } else { + if (!sessionId) { + throw new Error('Session ID is required (or use --hook to read from stdin)'); + } + resolvedSessionId = sessionId; + } + + await runInsightsCommand({ + sessionId: resolvedSessionId, + native: opts.native ?? false, + hookMode: opts.hook ?? false, + force: opts.force ?? false, + quiet, + source: opts.source, + }); + } catch (error) { + if (!quiet) { + console.error(chalk.red(`[Code Insights] ${error instanceof Error ? error.message : 'Analysis failed'}`)); + } + process.exit(1); + } +} + +// ── Subcommand: insights check ──────────────────────────────────────────────── + +export function insightsCheckCommand(opts: { days?: number; quiet?: boolean }): void { + const days = opts.days ?? 7; + const quiet = opts.quiet ?? false; + const log = quiet ? () => {} : console.log.bind(console); + + try { + const db = getDb(); + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + + const rows = db.prepare(` + SELECT s.id + FROM sessions s + LEFT JOIN analysis_usage au ON au.session_id = s.id AND au.analysis_type = 'session' + WHERE s.started_at >= ? + AND s.deleted_at IS NULL + AND au.analysis_type IS NULL + ORDER BY s.started_at DESC + `).all(cutoff) as Array<{ id: string }>; + + const count = rows.length; + + if (count === 0) { + // Silent — all sessions analyzed + return; + } + + if (quiet) { + process.stdout.write(String(count) + '\n'); + return; + } + + log(chalk.yellow(`[Code Insights] ${count} unanalyzed session${count > 1 ? 's' : ''} in the last ${days} days.`)); + log(chalk.dim(` Run: code-insights insights --native to analyze the most recent session.`)); + } catch (error) { + if (!quiet) { + console.error(chalk.red(`[Code Insights] ${error instanceof Error ? error.message : 'Check failed'}`)); + } + process.exit(1); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + if (process.stdin.isTTY) { + resolve('{}'); + return; + } + let data = ''; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => resolve(data.trim())); + process.stdin.on('error', reject); + }); +} From 1c629eac51966a61e8f2ab88c63a2f38af4e0409 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 11:15:51 +0530 Subject: [PATCH 4/6] feat(cli): register insights command + update V8 migration tests Registers the insights command and insights check subcommand in cli/src/index.ts. Updates schema.test.ts and migrate.test.ts to expect schema version 8 (and v8Applied in MigrationResult). Co-Authored-By: Claude Sonnet 4.6 --- cli/src/db/__tests__/migrate.test.ts | 2 +- cli/src/db/schema.test.ts | 6 ++++-- cli/src/index.ts | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/cli/src/db/__tests__/migrate.test.ts b/cli/src/db/__tests__/migrate.test.ts index 2b6cbb9..12010c0 100644 --- a/cli/src/db/__tests__/migrate.test.ts +++ b/cli/src/db/__tests__/migrate.test.ts @@ -33,7 +33,7 @@ describe('runMigrations — idempotency', () => { .all() as Array<{ version: number }>; // One row per version, no duplicates - expect(rows.map(r => r.version)).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(rows.map(r => r.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); db.close(); }); }); diff --git a/cli/src/db/schema.test.ts b/cli/src/db/schema.test.ts index a4f17ef..fd986b2 100644 --- a/cli/src/db/schema.test.ts +++ b/cli/src/db/schema.test.ts @@ -298,12 +298,12 @@ describe('runMigrations', () => { db.close(); }); - it('V7 schema version is 7 after migration', () => { + it('V8 schema version is 8 after migration', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number }; - expect(row.v).toBe(7); + expect(row.v).toBe(8); db.close(); }); @@ -313,6 +313,7 @@ describe('runMigrations', () => { const result = runMigrations(db); expect(result.v6Applied).toBe(true); expect(result.v7Applied).toBe(true); + expect(result.v8Applied).toBe(true); db.close(); }); @@ -322,6 +323,7 @@ describe('runMigrations', () => { const result = runMigrations(db); // second run — nothing to apply expect(result.v6Applied).toBe(false); expect(result.v7Applied).toBe(false); + expect(result.v8Applied).toBe(false); db.close(); }); }); diff --git a/cli/src/index.ts b/cli/src/index.ts index ff0a437..32b0671 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,6 +13,7 @@ import { statsCommand } from './commands/stats/index.js'; import { configCommand } from './commands/config.js'; import { telemetryCommand } from './commands/telemetry.js'; import { reflectCommand } from './commands/reflect.js'; +import { insightsCommand, insightsCheckCommand } from './commands/insights.js'; import { showTelemetryNoticeIfNeeded } from './utils/telemetry.js'; const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')); @@ -116,6 +117,29 @@ program.addCommand(configCommand); program.addCommand(telemetryCommand); program.addCommand(reflectCommand); + +// insights command — analyze a session using native claude -p or configured LLM +const insightsCmd = program + .command('insights [session_id]') + .description('Analyze a session with AI — extracts insights and prompt quality score') + .option('--native', 'Use claude -p (your Claude subscription, no API key required)') + .option('--hook', 'Read session context from stdin (for Claude Code SessionEnd hook)') + .option('-s, --source ', 'Source tool identifier (default: claude-code)') + .option('--force', 'Re-analyze even if already analyzed at this session length') + .option('-q, --quiet', 'Suppress output') + .action(async (sessionId: string | undefined, opts) => { + await insightsCommand(sessionId, opts); + }); + +insightsCmd + .command('check') + .description('Check for unanalyzed sessions in the last N days') + .option('--days ', 'Lookback window in days', '7') + .option('-q, --quiet', 'Machine-readable output (just count)') + .action((opts) => { + insightsCheckCommand({ days: opts.days ? parseInt(opts.days, 10) : 7, quiet: opts.quiet }); + }); + // Default action: running `code-insights` with no arguments opens the dashboard. // Dashboard auto-syncs sessions first, giving "1 command to value" on first run. program.action(async () => { From 473981c99414a2d1c680c515ee05b9b509536309 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 16:11:59 +0530 Subject: [PATCH 5/6] fix(db): restore chronological order of migration functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyV8 was defined before applyV7 due to insertion order during development. Move applyV8 after applyV7 to follow project convention (migration functions in chronological order V1...VN). No logic change — runMigrations() call order was always correct. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/db/migrate.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cli/src/db/migrate.ts b/cli/src/db/migrate.ts index bb25dd1..edfe420 100644 --- a/cli/src/db/migrate.ts +++ b/cli/src/db/migrate.ts @@ -142,11 +142,6 @@ function applyV6(db: Database.Database): void { } -function applyV8(db: Database.Database): void { - db.exec(`ALTER TABLE analysis_usage ADD COLUMN session_message_count INTEGER`); - db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(8); -} - function applyV7(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS analysis_usage ( @@ -171,3 +166,7 @@ function applyV7(db: Database.Database): void { `); db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(7); } +function applyV8(db: Database.Database): void { + db.exec(`ALTER TABLE analysis_usage ADD COLUMN session_message_count INTEGER`); + db.prepare('INSERT OR IGNORE INTO schema_version (version) VALUES (?)').run(8); +} From 5a56b485de46971e36be8462c2510e080734eb9e Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 16:31:12 +0530 Subject: [PATCH 6/6] fix(db): use ON CONFLICT DO UPDATE in saveAnalysisUsage to preserve session_message_count INSERT OR REPLACE does a DELETE+INSERT, clobbering columns not included in the write (specifically session_message_count written by the CLI). Switch both server and CLI to INSERT ... ON CONFLICT DO UPDATE so each writer only touches the columns it owns. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/insights.ts | 13 ++++++++++++- server/src/llm/analysis-usage-db.ts | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/insights.ts b/cli/src/commands/insights.ts index ebc70ed..ca9d40a 100644 --- a/cli/src/commands/insights.ts +++ b/cli/src/commands/insights.ts @@ -180,11 +180,22 @@ function saveAnalysisUsage(data: { }): void { const db = getDb(); db.prepare(` - INSERT OR REPLACE INTO analysis_usage + INSERT INTO analysis_usage (session_id, analysis_type, provider, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, estimated_cost_usd, duration_ms, chunk_count, session_message_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + ON CONFLICT(session_id, analysis_type) DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_creation_tokens = excluded.cache_creation_tokens, + cache_read_tokens = excluded.cache_read_tokens, + estimated_cost_usd = excluded.estimated_cost_usd, + duration_ms = excluded.duration_ms, + chunk_count = excluded.chunk_count, + session_message_count = excluded.session_message_count `).run( data.session_id, data.analysis_type, diff --git a/server/src/llm/analysis-usage-db.ts b/server/src/llm/analysis-usage-db.ts index fdeb598..5e27c73 100644 --- a/server/src/llm/analysis-usage-db.ts +++ b/server/src/llm/analysis-usage-db.ts @@ -41,17 +41,28 @@ export interface SaveAnalysisUsageData { /** * Persist analysis token usage to SQLite. - * Uses INSERT OR REPLACE — re-analysis overwrites the previous row (latest cost only). - * The composite PK (session_id, analysis_type) enforces one row per type per session. + * Uses INSERT ... ON CONFLICT DO UPDATE — preserves columns not in this write + * (e.g. session_message_count written by the CLI insights command). + * INSERT OR REPLACE would DELETE+INSERT, clobbering those columns. */ export function saveAnalysisUsage(data: SaveAnalysisUsageData): void { const db = getDb(); db.prepare(` - INSERT OR REPLACE INTO analysis_usage + INSERT INTO analysis_usage (session_id, analysis_type, provider, model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, estimated_cost_usd, duration_ms, chunk_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, analysis_type) DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + cache_creation_tokens = excluded.cache_creation_tokens, + cache_read_tokens = excluded.cache_read_tokens, + estimated_cost_usd = excluded.estimated_cost_usd, + duration_ms = excluded.duration_ms, + chunk_count = excluded.chunk_count `).run( data.session_id, data.analysis_type,