@unpolarize/code-sessions 0.1.0

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 (67) hide show
  1. package/bin/code-sessions.mjs +20 -0
  2. package/dist/chunk-ZJG2DWAK.js +2321 -0
  3. package/dist/cli.js +308 -0
  4. package/dist/index.js +162 -0
  5. package/package.json +21 -0
  6. package/src/adapters/adapters.test.ts +121 -0
  7. package/src/adapters/codex.ts +228 -0
  8. package/src/adapters/grok.ts +179 -0
  9. package/src/adapters/import.ts +79 -0
  10. package/src/adapters/index.ts +3 -0
  11. package/src/analytics/analytics.test.ts +94 -0
  12. package/src/analytics/command.ts +38 -0
  13. package/src/analytics/digest.ts +48 -0
  14. package/src/analytics/rollup.ts +114 -0
  15. package/src/analytics/site.ts +41 -0
  16. package/src/capture.test.ts +103 -0
  17. package/src/capture.ts +121 -0
  18. package/src/cli.ts +118 -0
  19. package/src/cliargs.test.ts +31 -0
  20. package/src/cliargs.ts +77 -0
  21. package/src/commands.test.ts +99 -0
  22. package/src/commands.ts +266 -0
  23. package/src/config.test.ts +36 -0
  24. package/src/config.ts +158 -0
  25. package/src/daemon.test.ts +130 -0
  26. package/src/daemon.ts +216 -0
  27. package/src/hooks/install.test.ts +47 -0
  28. package/src/hooks/install.ts +81 -0
  29. package/src/hooks/shim.test.ts +57 -0
  30. package/src/hooks/shim.ts +26 -0
  31. package/src/hygiene.test.ts +78 -0
  32. package/src/hygiene.ts +107 -0
  33. package/src/index.ts +21 -0
  34. package/src/index_store/db.test.ts +108 -0
  35. package/src/index_store/db.ts +289 -0
  36. package/src/index_store/index.ts +2 -0
  37. package/src/index_store/sync.test.ts +88 -0
  38. package/src/index_store/sync.ts +83 -0
  39. package/src/insights/heuristics.test.ts +71 -0
  40. package/src/insights/heuristics.ts +106 -0
  41. package/src/insights/index.ts +4 -0
  42. package/src/insights/labeler.test.ts +105 -0
  43. package/src/insights/labeler.ts +136 -0
  44. package/src/insights/llm.test.ts +77 -0
  45. package/src/insights/llm.ts +130 -0
  46. package/src/insights/provider.ts +37 -0
  47. package/src/ipc.test.ts +35 -0
  48. package/src/ipc.ts +70 -0
  49. package/src/pricing.test.ts +28 -0
  50. package/src/pricing.ts +45 -0
  51. package/src/state.test.ts +46 -0
  52. package/src/state.ts +89 -0
  53. package/src/store/git.test.ts +99 -0
  54. package/src/store/git.ts +138 -0
  55. package/src/store/paths.ts +45 -0
  56. package/src/store/scan.ts +39 -0
  57. package/src/store/writer.test.ts +93 -0
  58. package/src/store/writer.ts +135 -0
  59. package/src/tail.test.ts +50 -0
  60. package/src/tail.ts +47 -0
  61. package/src/telemetry/exporter.test.ts +104 -0
  62. package/src/telemetry/exporter.ts +64 -0
  63. package/src/telemetry/index.ts +2 -0
  64. package/src/telemetry/otlp.test.ts +123 -0
  65. package/src/telemetry/otlp.ts +215 -0
  66. package/src/test/e2e.test.ts +112 -0
  67. package/src/test/tmp.ts +36 -0
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Insights, SessionEnvelope, Turn } from '@unpolarize/code-sessions-schema';
3
+ import { SessionIndex } from './db';
4
+
5
+ function env(id: string, agent: SessionEnvelope['agent'], over: Partial<SessionEnvelope> = {}): SessionEnvelope {
6
+ return {
7
+ schema: 'session-store/session@1',
8
+ session_id: id,
9
+ host: 'h',
10
+ agent,
11
+ project_path: '/p',
12
+ model: 'claude-opus-4-8',
13
+ started_at: '2026-06-20T08:00:00Z',
14
+ ended_at: '2026-06-20T08:05:00Z',
15
+ turn_count: 2,
16
+ tool_call_count: 1,
17
+ totals: { input_tokens: 100, output_tokens: 20, cost_usd: 0.5 },
18
+ title: `session ${id}`,
19
+ labels: ['debugging'],
20
+ native_ref: { format: 'claude-jsonl', uuid: id },
21
+ ...over,
22
+ };
23
+ }
24
+
25
+ function turn(sid: string, i: number, role: Turn['role'], text: string): Turn {
26
+ return {
27
+ schema: 'session-store/turn@1',
28
+ session_id: sid,
29
+ host: 'h',
30
+ agent: 'claude-code',
31
+ turn_index: i,
32
+ ts: `2026-06-20T08:0${i}:00Z`,
33
+ role,
34
+ text,
35
+ tool_calls: role === 'assistant' ? [{ name: 'Edit' }] : [],
36
+ usage: { input_tokens: 50, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0 },
37
+ scrubbed: false,
38
+ raw_ref: null,
39
+ };
40
+ }
41
+
42
+ const src = { source_path: '/s/x.json', mtime_ms: 1000, size_bytes: 200, indexed_at: 1 };
43
+
44
+ describe('SessionIndex', () => {
45
+ it('upserts sessions/turns/insights and queries them', () => {
46
+ const idx = new SessionIndex(':memory:');
47
+ try {
48
+ idx.upsertSession(env('s1', 'claude-code'), { ...src, topic: 'fixing a bug' });
49
+ idx.replaceTurns('s1', [turn('s1', 0, 'user', 'fix the parser bug'), turn('s1', 1, 'assistant', 'done')]);
50
+ idx.upsertInsight({
51
+ schema: 'session-store/insights@1',
52
+ session_id: 's1',
53
+ host: 'h',
54
+ generated_at: 't',
55
+ provider: 'fake',
56
+ topic: 'fixing a bug',
57
+ tags: ['Edit'],
58
+ signals: [{ kind: 'high-cost-turn', severity: 'warn' }],
59
+ } as Insights);
60
+
61
+ const got = idx.getSession('s1')!;
62
+ expect(got.agent).toBe('claude-code');
63
+ expect(got.input_tokens).toBe(100);
64
+ expect(got.labels).toEqual(['debugging']);
65
+ expect(got.topic).toBe('fixing a bug');
66
+
67
+ expect(idx.listRecent(10)).toHaveLength(1);
68
+ expect(idx.searchTurns('parser')).toHaveLength(1);
69
+ expect(idx.searchTurns('nonexistent')).toHaveLength(0);
70
+
71
+ const s = idx.stats();
72
+ expect(s.sessions).toBe(1);
73
+ expect(s.turns).toBe(2);
74
+ expect(s.byAgent['claude-code']).toBe(1);
75
+ } finally {
76
+ idx.close();
77
+ }
78
+ });
79
+
80
+ it('is idempotent on re-upsert and supports incremental invalidation keys', () => {
81
+ const idx = new SessionIndex(':memory:');
82
+ try {
83
+ idx.upsertSession(env('s1', 'grok'), src);
84
+ idx.upsertSession(env('s1', 'grok', { title: 'updated' }), { ...src, mtime_ms: 2000 });
85
+ expect(idx.listRecent(10)).toHaveLength(1);
86
+ expect(idx.getSession('s1')!.title).toBe('updated');
87
+ const known = idx.knownSources();
88
+ expect(known.get('s1')!.mtime_ms).toBe(2000);
89
+ } finally {
90
+ idx.close();
91
+ }
92
+ });
93
+
94
+ it('filters by agent and deletes sessions (cascade turns)', () => {
95
+ const idx = new SessionIndex(':memory:');
96
+ try {
97
+ idx.upsertSession(env('c1', 'claude-code'), src);
98
+ idx.upsertSession(env('g1', 'grok'), { ...src, source_path: '/s/g.json' });
99
+ idx.replaceTurns('c1', [turn('c1', 0, 'user', 'hi')]);
100
+ expect(idx.listRecent(10, 'grok')).toHaveLength(1);
101
+ idx.deleteSessions(['c1']);
102
+ expect(idx.getSession('c1')).toBeUndefined();
103
+ expect(idx.stats().turns).toBe(0); // cascade removed c1's turn
104
+ } finally {
105
+ idx.close();
106
+ }
107
+ });
108
+ });
@@ -0,0 +1,289 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname } from 'node:path';
4
+ import type { DatabaseSync as DatabaseSyncT } from 'node:sqlite';
5
+ import type { Insights, SessionEnvelope, Turn } from '@unpolarize/code-sessions-schema';
6
+
7
+ // Load node:sqlite at runtime via createRequire so the bundler never rewrites
8
+ // the `node:` specifier (esbuild doesn't yet know `node:sqlite` is a builtin and
9
+ // would strip the prefix to a bare `sqlite` package). Types come from the
10
+ // type-only import above.
11
+ const nodeRequire = createRequire(import.meta.url);
12
+ const { DatabaseSync } = nodeRequire('node:sqlite') as {
13
+ DatabaseSync: new (path: string) => DatabaseSyncT;
14
+ };
15
+
16
+ /**
17
+ * Internal SQLite index for the CS library — a queryable projection of the git
18
+ * `sessions` store. Built on node:sqlite (Node's built-in, zero native deps).
19
+ * This is a CACHE, rebuildable from the store at any time; the git files remain
20
+ * the source of truth. Mirrors the shape CS-vscode's cache uses so a consumer
21
+ * can share the model.
22
+ */
23
+
24
+ const SCHEMA_VERSION = 1;
25
+
26
+ export interface SessionIndexRow {
27
+ session_id: string;
28
+ host: string;
29
+ agent: string;
30
+ project_path: string;
31
+ model: string | null;
32
+ started_at: number | null;
33
+ ended_at: number | null;
34
+ turn_count: number;
35
+ tool_call_count: number;
36
+ input_tokens: number;
37
+ output_tokens: number;
38
+ cost_usd: number;
39
+ title: string | null;
40
+ labels: string[];
41
+ topic: string | null;
42
+ source_path: string;
43
+ }
44
+
45
+ function toMs(iso: string | undefined): number | null {
46
+ if (!iso) return null;
47
+ const v = Date.parse(iso);
48
+ return Number.isNaN(v) ? null : v;
49
+ }
50
+
51
+ export class SessionIndex {
52
+ readonly db: DatabaseSyncT;
53
+
54
+ constructor(path: string) {
55
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
56
+ this.db = new DatabaseSync(path);
57
+ this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
58
+ this.migrate();
59
+ }
60
+
61
+ private migrate(): void {
62
+ const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number };
63
+ if ((row?.user_version ?? 0) < 1) {
64
+ this.db.exec(`
65
+ CREATE TABLE IF NOT EXISTS session (
66
+ session_id TEXT PRIMARY KEY,
67
+ host TEXT NOT NULL,
68
+ agent TEXT NOT NULL,
69
+ project_path TEXT NOT NULL DEFAULT '',
70
+ model TEXT,
71
+ started_at INTEGER,
72
+ ended_at INTEGER,
73
+ turn_count INTEGER NOT NULL DEFAULT 0,
74
+ tool_call_count INTEGER NOT NULL DEFAULT 0,
75
+ input_tokens INTEGER NOT NULL DEFAULT 0,
76
+ output_tokens INTEGER NOT NULL DEFAULT 0,
77
+ cost_usd REAL NOT NULL DEFAULT 0,
78
+ title TEXT,
79
+ labels_json TEXT NOT NULL DEFAULT '[]',
80
+ topic TEXT,
81
+ source_path TEXT NOT NULL,
82
+ mtime_ms INTEGER NOT NULL DEFAULT 0,
83
+ size_bytes INTEGER NOT NULL DEFAULT 0,
84
+ indexed_at INTEGER NOT NULL DEFAULT 0
85
+ );
86
+ CREATE INDEX IF NOT EXISTS idx_session_started ON session(started_at DESC);
87
+ CREATE INDEX IF NOT EXISTS idx_session_agent ON session(agent);
88
+ CREATE TABLE IF NOT EXISTS turn (
89
+ turn_uuid TEXT PRIMARY KEY,
90
+ session_id TEXT NOT NULL REFERENCES session(session_id) ON DELETE CASCADE,
91
+ turn_index INTEGER NOT NULL,
92
+ ts INTEGER,
93
+ role TEXT NOT NULL,
94
+ text TEXT NOT NULL DEFAULT '',
95
+ tool_names_csv TEXT NOT NULL DEFAULT '',
96
+ input_tokens INTEGER NOT NULL DEFAULT 0,
97
+ output_tokens INTEGER NOT NULL DEFAULT 0,
98
+ cost_usd REAL NOT NULL DEFAULT 0
99
+ );
100
+ CREATE INDEX IF NOT EXISTS idx_turn_session ON turn(session_id, turn_index);
101
+ CREATE TABLE IF NOT EXISTS insight (
102
+ session_id TEXT PRIMARY KEY REFERENCES session(session_id) ON DELETE CASCADE,
103
+ topic TEXT,
104
+ tags_json TEXT NOT NULL DEFAULT '[]',
105
+ signals_json TEXT NOT NULL DEFAULT '[]',
106
+ provider TEXT,
107
+ generated_at TEXT
108
+ );
109
+ `);
110
+ this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
111
+ }
112
+ }
113
+
114
+ /** session_id -> {mtime_ms, size_bytes} for incremental sync invalidation. */
115
+ knownSources(): Map<string, { mtime_ms: number; size_bytes: number }> {
116
+ const rows = this.db.prepare('SELECT session_id, mtime_ms, size_bytes FROM session').all() as Array<{
117
+ session_id: string;
118
+ mtime_ms: number;
119
+ size_bytes: number;
120
+ }>;
121
+ const m = new Map<string, { mtime_ms: number; size_bytes: number }>();
122
+ for (const r of rows) m.set(r.session_id, { mtime_ms: r.mtime_ms, size_bytes: r.size_bytes });
123
+ return m;
124
+ }
125
+
126
+ upsertSession(
127
+ env: SessionEnvelope,
128
+ src: { source_path: string; mtime_ms: number; size_bytes: number; indexed_at: number; topic?: string },
129
+ ): void {
130
+ this.db
131
+ .prepare(
132
+ `INSERT INTO session (session_id, host, agent, project_path, model, started_at, ended_at,
133
+ turn_count, tool_call_count, input_tokens, output_tokens, cost_usd, title, labels_json,
134
+ topic, source_path, mtime_ms, size_bytes, indexed_at)
135
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
136
+ ON CONFLICT(session_id) DO UPDATE SET
137
+ host=excluded.host, agent=excluded.agent, project_path=excluded.project_path,
138
+ model=excluded.model, started_at=excluded.started_at, ended_at=excluded.ended_at,
139
+ turn_count=excluded.turn_count, tool_call_count=excluded.tool_call_count,
140
+ input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens,
141
+ cost_usd=excluded.cost_usd, title=excluded.title, labels_json=excluded.labels_json,
142
+ topic=excluded.topic, source_path=excluded.source_path, mtime_ms=excluded.mtime_ms,
143
+ size_bytes=excluded.size_bytes, indexed_at=excluded.indexed_at`,
144
+ )
145
+ .run(
146
+ env.session_id,
147
+ env.host,
148
+ env.agent,
149
+ env.project_path,
150
+ env.model ?? null,
151
+ toMs(env.started_at),
152
+ toMs(env.ended_at),
153
+ env.turn_count,
154
+ env.tool_call_count,
155
+ env.totals.input_tokens,
156
+ env.totals.output_tokens,
157
+ env.totals.cost_usd,
158
+ env.title ?? null,
159
+ JSON.stringify(env.labels ?? []),
160
+ src.topic ?? null,
161
+ src.source_path,
162
+ src.mtime_ms,
163
+ src.size_bytes,
164
+ src.indexed_at,
165
+ );
166
+ }
167
+
168
+ replaceTurns(sessionId: string, turns: Turn[]): void {
169
+ this.db.prepare('DELETE FROM turn WHERE session_id = ?').run(sessionId);
170
+ const stmt = this.db.prepare(
171
+ `INSERT OR REPLACE INTO turn (turn_uuid, session_id, turn_index, ts, role, text,
172
+ tool_names_csv, input_tokens, output_tokens, cost_usd) VALUES (?,?,?,?,?,?,?,?,?,?)`,
173
+ );
174
+ for (const t of turns) {
175
+ stmt.run(
176
+ `${sessionId}#${t.turn_index}`,
177
+ sessionId,
178
+ t.turn_index,
179
+ toMs(t.ts),
180
+ t.role,
181
+ t.text.slice(0, 8192),
182
+ t.tool_calls.map((c) => c.name).join(','),
183
+ t.usage.input_tokens,
184
+ t.usage.output_tokens,
185
+ t.telemetry?.cost_usd ?? 0,
186
+ );
187
+ }
188
+ }
189
+
190
+ upsertInsight(ins: Insights): void {
191
+ this.db
192
+ .prepare(
193
+ `INSERT OR REPLACE INTO insight (session_id, topic, tags_json, signals_json, provider, generated_at)
194
+ VALUES (?,?,?,?,?,?)`,
195
+ )
196
+ .run(
197
+ ins.session_id,
198
+ ins.topic ?? null,
199
+ JSON.stringify(ins.tags ?? []),
200
+ JSON.stringify(ins.signals ?? []),
201
+ ins.provider,
202
+ ins.generated_at,
203
+ );
204
+ }
205
+
206
+ deleteSessions(ids: string[]): void {
207
+ if (ids.length === 0) return;
208
+ const stmt = this.db.prepare('DELETE FROM session WHERE session_id = ?');
209
+ for (const id of ids) stmt.run(id);
210
+ }
211
+
212
+ private rowToIndex(r: any): SessionIndexRow {
213
+ return {
214
+ session_id: r.session_id,
215
+ host: r.host,
216
+ agent: r.agent,
217
+ project_path: r.project_path,
218
+ model: r.model ?? null,
219
+ started_at: r.started_at ?? null,
220
+ ended_at: r.ended_at ?? null,
221
+ turn_count: r.turn_count,
222
+ tool_call_count: r.tool_call_count,
223
+ input_tokens: r.input_tokens,
224
+ output_tokens: r.output_tokens,
225
+ cost_usd: r.cost_usd,
226
+ title: r.title ?? null,
227
+ labels: safeJson(r.labels_json),
228
+ topic: r.topic ?? null,
229
+ source_path: r.source_path,
230
+ };
231
+ }
232
+
233
+ listRecent(limit = 50, agent?: string): SessionIndexRow[] {
234
+ const rows = agent
235
+ ? this.db
236
+ .prepare('SELECT * FROM session WHERE agent = ? ORDER BY started_at DESC LIMIT ?')
237
+ .all(agent, limit)
238
+ : this.db.prepare('SELECT * FROM session ORDER BY started_at DESC LIMIT ?').all(limit);
239
+ return (rows as any[]).map((r) => this.rowToIndex(r));
240
+ }
241
+
242
+ getSession(id: string): SessionIndexRow | undefined {
243
+ const r = this.db.prepare('SELECT * FROM session WHERE session_id = ?').get(id);
244
+ return r ? this.rowToIndex(r) : undefined;
245
+ }
246
+
247
+ /** Full-text-ish search over turn text + session titles. */
248
+ searchTurns(query: string, limit = 50): SessionIndexRow[] {
249
+ const like = `%${query}%`;
250
+ const rows = this.db
251
+ .prepare(
252
+ `SELECT DISTINCT s.* FROM session s
253
+ LEFT JOIN turn t ON t.session_id = s.session_id
254
+ WHERE t.text LIKE ? OR s.title LIKE ?
255
+ ORDER BY s.started_at DESC LIMIT ?`,
256
+ )
257
+ .all(like, like, limit);
258
+ return (rows as any[]).map((r) => this.rowToIndex(r));
259
+ }
260
+
261
+ stats(): { sessions: number; turns: number; cost_usd: number; byAgent: Record<string, number> } {
262
+ const s = this.db.prepare('SELECT COUNT(*) c, COALESCE(SUM(cost_usd),0) cost FROM session').get() as {
263
+ c: number;
264
+ cost: number;
265
+ };
266
+ const t = this.db.prepare('SELECT COUNT(*) c FROM turn').get() as { c: number };
267
+ const agents = this.db.prepare('SELECT agent, COUNT(*) c FROM session GROUP BY agent').all() as Array<{
268
+ agent: string;
269
+ c: number;
270
+ }>;
271
+ const byAgent: Record<string, number> = {};
272
+ for (const a of agents) byAgent[a.agent] = a.c;
273
+ return { sessions: s.c, turns: t.c, cost_usd: Math.round(s.cost * 1e6) / 1e6, byAgent };
274
+ }
275
+
276
+ close(): void {
277
+ this.db.close();
278
+ }
279
+ }
280
+
281
+ function safeJson(s: unknown): string[] {
282
+ if (typeof s !== 'string') return [];
283
+ try {
284
+ const v = JSON.parse(s);
285
+ return Array.isArray(v) ? v : [];
286
+ } catch {
287
+ return [];
288
+ }
289
+ }
@@ -0,0 +1,2 @@
1
+ export * from './db';
2
+ export * from './sync';
@@ -0,0 +1,88 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { Turn } from '@unpolarize/code-sessions-schema';
4
+ import { FakeProvider } from '../insights/provider';
5
+ import { labelSession } from '../insights/labeler';
6
+ import { sessionDir } from '../store/paths';
7
+ import { rebuildEnvelope, writeTurnFile } from '../store/writer';
8
+ import { makeConfig, withTempDirAsync } from '../test/tmp';
9
+ import { SessionIndex } from './db';
10
+ import { syncIndex } from './sync';
11
+
12
+ function turn(i: number, over: Partial<Turn> = {}): Turn {
13
+ return {
14
+ schema: 'session-store/turn@1',
15
+ session_id: 's1',
16
+ host: 'h',
17
+ agent: 'claude-code',
18
+ turn_index: i,
19
+ ts: `2026-06-20T08:0${i}:00Z`,
20
+ role: 'assistant',
21
+ text: '',
22
+ tool_calls: [],
23
+ usage: { input_tokens: 100, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0 },
24
+ scrubbed: false,
25
+ raw_ref: null,
26
+ ...over,
27
+ };
28
+ }
29
+
30
+ async function seed(store: string, id: string): Promise<string> {
31
+ const dir = sessionDir(store, 'h', '2026-06', id);
32
+ writeTurnFile(dir, turn(0, { session_id: id, role: 'user', text: 'fix the parser bug' }));
33
+ writeTurnFile(dir, turn(1, { session_id: id, tool_calls: [{ name: 'Edit' }] }));
34
+ rebuildEnvelope(store, 'h', '2026-06', id, { model: 'claude-opus-4-8' }, {
35
+ session_id: id,
36
+ host: 'h',
37
+ agent: 'claude-code',
38
+ native_uuid: id,
39
+ });
40
+ await labelSession(dir, { sessionId: id, host: 'h' }, new FakeProvider(), { now: '2026-06-20T09:00:00Z' });
41
+ return dir;
42
+ }
43
+
44
+ describe('syncIndex', () => {
45
+ it('projects the git store into the SQLite index', async () => {
46
+ await withTempDirAsync(async (store) => {
47
+ await seed(store, 's1');
48
+ await seed(store, 's2');
49
+ const cfg = makeConfig(store);
50
+
51
+ const stats = syncIndex(cfg);
52
+ expect(stats.total).toBe(2);
53
+ expect(stats.indexed).toBe(2);
54
+
55
+ const idx = new SessionIndex(cfg.indexPath);
56
+ try {
57
+ expect(idx.stats().sessions).toBe(2);
58
+ expect(idx.searchTurns('parser').length).toBeGreaterThan(0);
59
+ expect(idx.listRecent(10)[0]!.topic).toContain('fix the parser bug');
60
+ } finally {
61
+ idx.close();
62
+ }
63
+ });
64
+ });
65
+
66
+ it('is incremental (unchanged on re-sync) and removes deleted sessions', async () => {
67
+ await withTempDirAsync(async (store) => {
68
+ const dir = await seed(store, 's1');
69
+ await seed(store, 's2');
70
+ const cfg = makeConfig(store);
71
+ syncIndex(cfg);
72
+
73
+ const again = syncIndex(cfg);
74
+ expect(again.indexed).toBe(0);
75
+ expect(again.unchanged).toBe(2);
76
+
77
+ rmSync(dir, { recursive: true, force: true });
78
+ const after = syncIndex(cfg);
79
+ expect(after.removed).toBe(1);
80
+ const idx = new SessionIndex(cfg.indexPath);
81
+ try {
82
+ expect(idx.stats().sessions).toBe(1);
83
+ } finally {
84
+ idx.close();
85
+ }
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { safeParseInsights, safeParseSession } from '@unpolarize/code-sessions-schema';
3
+ import type { CodeSessionsConfig } from '../config';
4
+ import { envelopeFile, insightsFile } from '../store/paths';
5
+ import { listSessionDirs } from '../store/scan';
6
+ import { readTurns } from '../store/writer';
7
+ import { SessionIndex } from './db';
8
+
9
+ export interface IndexSyncStats {
10
+ total: number;
11
+ indexed: number;
12
+ unchanged: number;
13
+ removed: number;
14
+ }
15
+
16
+ /**
17
+ * Project the git store into the SQLite index, incrementally (mtime/size
18
+ * invalidation per session.json). Rebuildable any time; the git files stay
19
+ * authoritative.
20
+ */
21
+ export function syncIndex(
22
+ cfg: CodeSessionsConfig,
23
+ opts: { index?: SessionIndex; now?: number } = {},
24
+ ): IndexSyncStats {
25
+ const index = opts.index ?? new SessionIndex(cfg.indexPath);
26
+ const ownsIndex = !opts.index;
27
+ const now = opts.now ?? Date.now();
28
+ try {
29
+ const refs = listSessionDirs(cfg.storeDir);
30
+ const known = index.knownSources();
31
+ const seen = new Set<string>();
32
+ let indexed = 0;
33
+ let unchanged = 0;
34
+
35
+ for (const ref of refs) {
36
+ const envPath = envelopeFile(ref.dir);
37
+ if (!existsSync(envPath)) continue;
38
+ const st = statSync(envPath);
39
+ const mtime_ms = Math.floor(st.mtimeMs);
40
+ const size_bytes = st.size;
41
+ seen.add(ref.sessionId);
42
+
43
+ const cached = known.get(ref.sessionId);
44
+ if (cached && cached.mtime_ms === mtime_ms && cached.size_bytes === size_bytes) {
45
+ unchanged++;
46
+ continue;
47
+ }
48
+
49
+ const parsed = safeParseSession(JSON.parse(readFileSync(envPath, 'utf8')));
50
+ if (!parsed.success) continue;
51
+ const env = parsed.data;
52
+
53
+ let topic: string | undefined;
54
+ const insPath = insightsFile(ref.dir);
55
+ let insights = undefined;
56
+ if (existsSync(insPath)) {
57
+ const pi = safeParseInsights(JSON.parse(readFileSync(insPath, 'utf8')));
58
+ if (pi.success) {
59
+ insights = pi.data;
60
+ topic = pi.data.topic;
61
+ }
62
+ }
63
+
64
+ index.upsertSession(env, {
65
+ source_path: envPath,
66
+ mtime_ms,
67
+ size_bytes,
68
+ indexed_at: now,
69
+ ...(topic ? { topic } : {}),
70
+ });
71
+ index.replaceTurns(env.session_id, readTurns(ref.dir));
72
+ if (insights) index.upsertInsight(insights);
73
+ indexed++;
74
+ }
75
+
76
+ const removed = [...known.keys()].filter((id) => !seen.has(id));
77
+ index.deleteSessions(removed);
78
+
79
+ return { total: refs.length, indexed, unchanged, removed: removed.length };
80
+ } finally {
81
+ if (ownsIndex) index.close();
82
+ }
83
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Turn } from '@unpolarize/code-sessions-schema';
3
+ import { deriveSignals, deriveTags, guessTopic } from './heuristics';
4
+
5
+ function turn(i: number, over: Partial<Turn> = {}): Turn {
6
+ return {
7
+ schema: 'session-store/turn@1',
8
+ session_id: 's',
9
+ host: 'h',
10
+ agent: 'claude-code',
11
+ turn_index: i,
12
+ ts: 't',
13
+ role: 'assistant',
14
+ text: '',
15
+ tool_calls: [],
16
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
17
+ scrubbed: false,
18
+ raw_ref: null,
19
+ ...over,
20
+ };
21
+ }
22
+
23
+ describe('deriveSignals', () => {
24
+ it('flags a stuck loop on repeated identical actions', () => {
25
+ const turns = [
26
+ turn(0, { tool_calls: [{ name: 'Bash', input: { command: 'ls' } }] }),
27
+ turn(1, { tool_calls: [{ name: 'Bash', input: { command: 'ls' } }] }),
28
+ turn(2, { tool_calls: [{ name: 'Bash', input: { command: 'ls' } }] }),
29
+ ];
30
+ const s = deriveSignals(turns);
31
+ expect(s.some((x) => x.kind === 'stuck-loop' && x.severity === 'warn')).toBe(true);
32
+ });
33
+
34
+ it('flags error-recovery when a turn mentions an error', () => {
35
+ const turns = [turn(0, { role: 'tool', text: 'TypeError: cannot read foo' })];
36
+ expect(deriveSignals(turns).some((x) => x.kind === 'error-recovery')).toBe(true);
37
+ });
38
+
39
+ it('flags high-cost turns', () => {
40
+ const turns = [turn(0, { telemetry: { cost_usd: 0.9 } })];
41
+ expect(deriveSignals(turns).some((x) => x.kind === 'high-cost-turn')).toBe(true);
42
+ });
43
+
44
+ it('flags tool-heavy sessions', () => {
45
+ const turns = [
46
+ turn(0, { tool_calls: [{ name: 'Read' }, { name: 'Edit' }] }),
47
+ turn(1, { tool_calls: [{ name: 'Bash' }] }),
48
+ ];
49
+ expect(deriveSignals(turns).some((x) => x.kind === 'tool-heavy')).toBe(true);
50
+ });
51
+
52
+ it('produces nothing notable for a calm short session', () => {
53
+ const turns = [turn(0, { role: 'user', text: 'hi' }), turn(1, { text: 'hello' })];
54
+ expect(deriveSignals(turns)).toEqual([]);
55
+ });
56
+ });
57
+
58
+ describe('guessTopic / deriveTags', () => {
59
+ it('guesses a topic from the first user turn', () => {
60
+ const topic = guessTopic([turn(0, { role: 'user', text: 'Fix the bug in foo.ts please now' })]);
61
+ expect(topic).toContain('Fix the bug');
62
+ });
63
+
64
+ it('collects distinct tool names as tags', () => {
65
+ const tags = deriveTags([
66
+ turn(0, { tool_calls: [{ name: 'Read' }, { name: 'Edit' }] }),
67
+ turn(1, { tool_calls: [{ name: 'Read' }] }),
68
+ ]);
69
+ expect(tags.sort()).toEqual(['Edit', 'Read']);
70
+ });
71
+ });