@unpolarize/code-sessions 0.1.0 → 0.3.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.
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { Turn } from '@unpolarize/code-sessions-schema';
4
+ import { forkSession } from './fork';
5
+ import { envelopeFile, sessionDir, turnFile } from './store/paths';
6
+ import { rebuildEnvelope, readTurns, writeTurnFile } from './store/writer';
7
+ import { makeConfig, withTempDir } from './test/tmp';
8
+
9
+ function turn(i: number, role: Turn['role'], text: string): Turn {
10
+ return {
11
+ schema: 'session-store/turn@1',
12
+ session_id: 'src',
13
+ host: 'h',
14
+ agent: 'claude-code',
15
+ turn_index: i,
16
+ ts: `2026-06-20T08:0${i}:00Z`,
17
+ role,
18
+ text,
19
+ tool_calls: [],
20
+ usage: { input_tokens: 100, output_tokens: 10, cache_read_tokens: 0, cache_write_tokens: 0 },
21
+ scrubbed: false,
22
+ raw_ref: null,
23
+ };
24
+ }
25
+
26
+ function seed(store: string): void {
27
+ const dir = sessionDir(store, 'test-host', '2026-06', 'src');
28
+ writeTurnFile(dir, turn(0, 'user', 'start the feature'));
29
+ writeTurnFile(dir, turn(1, 'assistant', 'working on it'));
30
+ writeTurnFile(dir, turn(2, 'user', 'change of plan'));
31
+ writeTurnFile(dir, turn(3, 'assistant', 'ok redoing'));
32
+ rebuildEnvelope(store, 'test-host', '2026-06', 'src', { model: 'claude-opus-4-8', title: 'feature work' }, {
33
+ session_id: 'src',
34
+ host: 'test-host',
35
+ agent: 'claude-code',
36
+ native_uuid: 'src',
37
+ });
38
+ }
39
+
40
+ describe('forkSession', () => {
41
+ it('branches a session at a turn with lineage', () => {
42
+ withTempDir((store) => {
43
+ seed(store);
44
+ const cfg = makeConfig(store);
45
+ const res = forkSession(cfg, { sessionId: 'src', atTurn: 1, newSessionId: 'fork1' });
46
+ expect(res.newSessionId).toBe('fork1');
47
+ expect(res.turns).toBe(2); // turns 0 and 1 only
48
+ expect(res.forkedFrom).toEqual({ session_id: 'src', turn_index: 1 });
49
+
50
+ const dir = sessionDir(store, 'test-host', '2026-06', 'fork1');
51
+ expect(existsSync(turnFile(dir, 0))).toBe(true);
52
+ expect(existsSync(turnFile(dir, 1))).toBe(true);
53
+ expect(existsSync(turnFile(dir, 2))).toBe(false); // not copied
54
+
55
+ const forkTurns = readTurns(dir);
56
+ expect(forkTurns.every((t) => t.session_id === 'fork1')).toBe(true);
57
+
58
+ const env = JSON.parse(readFileSync(envelopeFile(dir), 'utf8'));
59
+ expect(env.forked_from).toEqual({ session_id: 'src', turn_index: 1 });
60
+ expect(env.title).toBe('fork: feature work');
61
+ expect(env.native_ref.format).toBe('fork');
62
+ });
63
+ });
64
+
65
+ it('can fork into a different agent', () => {
66
+ withTempDir((store) => {
67
+ seed(store);
68
+ const res = forkSession(makeConfig(store), { sessionId: 'src', atTurn: 0, agent: 'grok' });
69
+ const env = JSON.parse(readFileSync(envelopeFile(res.sessionDir), 'utf8'));
70
+ expect(env.agent).toBe('grok');
71
+ expect(res.turns).toBe(1);
72
+ });
73
+ });
74
+
75
+ it('throws for a missing session', () => {
76
+ withTempDir((store) => {
77
+ expect(() => forkSession(makeConfig(store), { sessionId: 'nope', atTurn: 0 })).toThrow(/not found/);
78
+ });
79
+ });
80
+ });
package/src/fork.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+ import {
6
+ SCHEMA_VERSIONS,
7
+ safeParseSession,
8
+ type SessionEnvelope,
9
+ type Turn,
10
+ } from '@unpolarize/code-sessions-schema';
11
+ import type { CodeSessionsConfig } from './config';
12
+ import { computeEnvelope, readTurns, writeTurnFile } from './store/writer';
13
+ import { envelopeFile, monthOf, sessionDir } from './store/paths';
14
+ import { listSessionDirs } from './store/scan';
15
+
16
+ export interface ForkResult {
17
+ newSessionId: string;
18
+ sessionDir: string;
19
+ turns: number;
20
+ forkedFrom: { session_id: string; turn_index: number };
21
+ }
22
+
23
+ function locateSession(storeDir: string, sessionId: string): { dir: string } | undefined {
24
+ const ref = listSessionDirs(storeDir).find((r) => r.sessionId === sessionId);
25
+ return ref ? { dir: ref.dir } : undefined;
26
+ }
27
+
28
+ function loadEnvelope(dir: string): SessionEnvelope | undefined {
29
+ const p = envelopeFile(dir);
30
+ if (!existsSync(p)) return undefined;
31
+ const parsed = safeParseSession(JSON.parse(readFileSync(p, 'utf8')));
32
+ return parsed.success ? parsed.data : undefined;
33
+ }
34
+
35
+ function writeJsonAtomic(path: string, value: unknown): void {
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ const tmp = `${path}.tmp`;
38
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
39
+ renameSync(tmp, path);
40
+ }
41
+
42
+ /**
43
+ * Fork a session at a turn — "git for sessions". Copies turns [0..atTurn] of the
44
+ * source into a NEW session keyed on this host, stamped with `forked_from`. The
45
+ * new session can be resumed/continued in any agent from that branch point; all
46
+ * turns stay discoverable via the index.
47
+ */
48
+ export function forkSession(
49
+ cfg: CodeSessionsConfig,
50
+ opts: { sessionId: string; atTurn: number; newSessionId?: string; agent?: SessionEnvelope['agent'] },
51
+ ): ForkResult {
52
+ const located = locateSession(cfg.storeDir, opts.sessionId);
53
+ if (!located) throw new Error(`session not found in store: ${opts.sessionId}`);
54
+ const srcEnv = loadEnvelope(located.dir);
55
+ const allTurns = readTurns(located.dir);
56
+ const prefix = allTurns.filter((t) => t.turn_index <= opts.atTurn);
57
+ if (prefix.length === 0) throw new Error(`no turns at or before index ${opts.atTurn}`);
58
+
59
+ const newId = opts.newSessionId ?? randomUUID();
60
+ const agent = opts.agent ?? srcEnv?.agent ?? 'claude-code';
61
+ const month = monthOf(srcEnv?.started_at ?? prefix[0]?.ts);
62
+ const dir = sessionDir(cfg.storeDir, cfg.host, month, newId);
63
+
64
+ const newTurns: Turn[] = prefix.map((t) => ({
65
+ ...t,
66
+ session_id: newId,
67
+ host: cfg.host,
68
+ agent,
69
+ }));
70
+ for (const t of newTurns) writeTurnFile(dir, t);
71
+
72
+ const env = computeEnvelope(
73
+ newTurns,
74
+ {
75
+ ...(srcEnv?.model ? { model: srcEnv.model } : {}),
76
+ ...(srcEnv?.project_path ? { project_path: srcEnv.project_path } : {}),
77
+ ...(srcEnv?.title ? { title: `fork: ${srcEnv.title}` } : {}),
78
+ },
79
+ { session_id: newId, host: cfg.host, agent, native_uuid: newId },
80
+ );
81
+ env.forked_from = { session_id: opts.sessionId, turn_index: opts.atTurn };
82
+ env.native_ref.format = 'fork';
83
+ writeJsonAtomic(envelopeFile(dir), env);
84
+
85
+ return {
86
+ newSessionId: newId,
87
+ sessionDir: dir,
88
+ turns: newTurns.length,
89
+ forkedFrom: env.forked_from,
90
+ };
91
+ }
package/src/index.ts CHANGED
@@ -17,5 +17,7 @@ export * from './insights/index';
17
17
  export * from './telemetry/index';
18
18
  export * from './adapters/index';
19
19
  export * from './index_store/index';
20
+ export * from './skills/index';
21
+ export * from './fork';
20
22
  export * from './hooks/install';
21
23
  export * from './hooks/shim';
@@ -91,6 +91,31 @@ describe('SessionIndex', () => {
91
91
  }
92
92
  });
93
93
 
94
+ it('aggregates usage by agent, day, project, and top-cost', () => {
95
+ const idx = new SessionIndex(':memory:');
96
+ try {
97
+ idx.upsertSession(env('c1', 'claude-code', { totals: { input_tokens: 100, output_tokens: 20, cost_usd: 2 } }), {
98
+ ...src,
99
+ projects: ['foo'],
100
+ });
101
+ idx.upsertSession(env('g1', 'grok', { totals: { input_tokens: 50, output_tokens: 5, cost_usd: 0.5 } }), {
102
+ ...src,
103
+ source_path: '/s/g.json',
104
+ projects: ['foo', 'bar'],
105
+ });
106
+ const u = idx.usageSummary();
107
+ expect(u.totals.sessions).toBe(2);
108
+ expect(u.totals.cost_usd).toBe(2.5);
109
+ expect(u.byAgent['claude-code']!.cost_usd).toBe(2);
110
+ expect(u.byProject['foo']!.sessions).toBe(2);
111
+ expect(u.byProject['bar']!.sessions).toBe(1);
112
+ expect(u.byDay[0]!.day).toBe('2026-06-20');
113
+ expect(u.topByCost[0]!.session_id).toBe('c1'); // highest cost first
114
+ } finally {
115
+ idx.close();
116
+ }
117
+ });
118
+
94
119
  it('filters by agent and deletes sessions (cascade turns)', () => {
95
120
  const idx = new SessionIndex(':memory:');
96
121
  try {
@@ -21,7 +21,7 @@ const { DatabaseSync } = nodeRequire('node:sqlite') as {
21
21
  * can share the model.
22
22
  */
23
23
 
24
- const SCHEMA_VERSION = 1;
24
+ const SCHEMA_VERSION = 2;
25
25
 
26
26
  export interface SessionIndexRow {
27
27
  session_id: string;
@@ -39,15 +39,36 @@ export interface SessionIndexRow {
39
39
  title: string | null;
40
40
  labels: string[];
41
41
  topic: string | null;
42
+ intent: string | null;
43
+ projects: string[];
42
44
  source_path: string;
43
45
  }
44
46
 
47
+ export interface UsageBucket {
48
+ sessions: number;
49
+ input_tokens: number;
50
+ output_tokens: number;
51
+ cost_usd: number;
52
+ }
53
+
54
+ export interface UsageSummary {
55
+ totals: { sessions: number; input_tokens: number; output_tokens: number; cost_usd: number };
56
+ byAgent: Record<string, UsageBucket>;
57
+ byDay: Array<{ day: string } & UsageBucket>;
58
+ byProject: Record<string, UsageBucket>;
59
+ topByCost: Array<{ session_id: string; agent: string; cost_usd: number; label: string }>;
60
+ }
61
+
45
62
  function toMs(iso: string | undefined): number | null {
46
63
  if (!iso) return null;
47
64
  const v = Date.parse(iso);
48
65
  return Number.isNaN(v) ? null : v;
49
66
  }
50
67
 
68
+ function round6(n: number): number {
69
+ return Math.round(n * 1e6) / 1e6;
70
+ }
71
+
51
72
  export class SessionIndex {
52
73
  readonly db: DatabaseSyncT;
53
74
 
@@ -60,7 +81,8 @@ export class SessionIndex {
60
81
 
61
82
  private migrate(): void {
62
83
  const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number };
63
- if ((row?.user_version ?? 0) < 1) {
84
+ const cur = row?.user_version ?? 0;
85
+ if (cur < 1) {
64
86
  this.db.exec(`
65
87
  CREATE TABLE IF NOT EXISTS session (
66
88
  session_id TEXT PRIMARY KEY,
@@ -78,6 +100,8 @@ export class SessionIndex {
78
100
  title TEXT,
79
101
  labels_json TEXT NOT NULL DEFAULT '[]',
80
102
  topic TEXT,
103
+ intent TEXT,
104
+ projects_json TEXT NOT NULL DEFAULT '[]',
81
105
  source_path TEXT NOT NULL,
82
106
  mtime_ms INTEGER NOT NULL DEFAULT 0,
83
107
  size_bytes INTEGER NOT NULL DEFAULT 0,
@@ -101,13 +125,24 @@ export class SessionIndex {
101
125
  CREATE TABLE IF NOT EXISTS insight (
102
126
  session_id TEXT PRIMARY KEY REFERENCES session(session_id) ON DELETE CASCADE,
103
127
  topic TEXT,
128
+ intent TEXT,
104
129
  tags_json TEXT NOT NULL DEFAULT '[]',
130
+ projects_json TEXT NOT NULL DEFAULT '[]',
105
131
  signals_json TEXT NOT NULL DEFAULT '[]',
106
132
  provider TEXT,
107
133
  generated_at TEXT
108
134
  );
109
135
  `);
110
136
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
137
+ } else if (cur < 2) {
138
+ // additive v1 -> v2: intent + projects columns
139
+ this.db.exec(`
140
+ ALTER TABLE session ADD COLUMN intent TEXT;
141
+ ALTER TABLE session ADD COLUMN projects_json TEXT NOT NULL DEFAULT '[]';
142
+ ALTER TABLE insight ADD COLUMN intent TEXT;
143
+ ALTER TABLE insight ADD COLUMN projects_json TEXT NOT NULL DEFAULT '[]';
144
+ PRAGMA user_version = ${SCHEMA_VERSION};
145
+ `);
111
146
  }
112
147
  }
113
148
 
@@ -125,21 +160,30 @@ export class SessionIndex {
125
160
 
126
161
  upsertSession(
127
162
  env: SessionEnvelope,
128
- src: { source_path: string; mtime_ms: number; size_bytes: number; indexed_at: number; topic?: string },
163
+ src: {
164
+ source_path: string;
165
+ mtime_ms: number;
166
+ size_bytes: number;
167
+ indexed_at: number;
168
+ topic?: string;
169
+ intent?: string;
170
+ projects?: string[];
171
+ },
129
172
  ): void {
130
173
  this.db
131
174
  .prepare(
132
175
  `INSERT INTO session (session_id, host, agent, project_path, model, started_at, ended_at,
133
176
  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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
177
+ topic, intent, projects_json, source_path, mtime_ms, size_bytes, indexed_at)
178
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
136
179
  ON CONFLICT(session_id) DO UPDATE SET
137
180
  host=excluded.host, agent=excluded.agent, project_path=excluded.project_path,
138
181
  model=excluded.model, started_at=excluded.started_at, ended_at=excluded.ended_at,
139
182
  turn_count=excluded.turn_count, tool_call_count=excluded.tool_call_count,
140
183
  input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens,
141
184
  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,
185
+ topic=excluded.topic, intent=excluded.intent, projects_json=excluded.projects_json,
186
+ source_path=excluded.source_path, mtime_ms=excluded.mtime_ms,
143
187
  size_bytes=excluded.size_bytes, indexed_at=excluded.indexed_at`,
144
188
  )
145
189
  .run(
@@ -158,6 +202,8 @@ export class SessionIndex {
158
202
  env.title ?? null,
159
203
  JSON.stringify(env.labels ?? []),
160
204
  src.topic ?? null,
205
+ src.intent ?? null,
206
+ JSON.stringify(src.projects ?? []),
161
207
  src.source_path,
162
208
  src.mtime_ms,
163
209
  src.size_bytes,
@@ -190,13 +236,15 @@ export class SessionIndex {
190
236
  upsertInsight(ins: Insights): void {
191
237
  this.db
192
238
  .prepare(
193
- `INSERT OR REPLACE INTO insight (session_id, topic, tags_json, signals_json, provider, generated_at)
194
- VALUES (?,?,?,?,?,?)`,
239
+ `INSERT OR REPLACE INTO insight (session_id, topic, intent, tags_json, projects_json, signals_json, provider, generated_at)
240
+ VALUES (?,?,?,?,?,?,?,?)`,
195
241
  )
196
242
  .run(
197
243
  ins.session_id,
198
244
  ins.topic ?? null,
245
+ ins.intent ?? null,
199
246
  JSON.stringify(ins.tags ?? []),
247
+ JSON.stringify(ins.projects ?? []),
200
248
  JSON.stringify(ins.signals ?? []),
201
249
  ins.provider,
202
250
  ins.generated_at,
@@ -226,6 +274,8 @@ export class SessionIndex {
226
274
  title: r.title ?? null,
227
275
  labels: safeJson(r.labels_json),
228
276
  topic: r.topic ?? null,
277
+ intent: r.intent ?? null,
278
+ projects: safeJson(r.projects_json),
229
279
  source_path: r.source_path,
230
280
  };
231
281
  }
@@ -258,6 +308,59 @@ export class SessionIndex {
258
308
  return (rows as any[]).map((r) => this.rowToIndex(r));
259
309
  }
260
310
 
311
+ /** Aggregated usage for a Usage panel: totals, by agent, by day, by project, top cost. */
312
+ usageSummary(opts: { days?: number; topN?: number } = {}): UsageSummary {
313
+ const rows = this.db
314
+ .prepare(
315
+ 'SELECT session_id, agent, started_at, input_tokens, output_tokens, cost_usd, projects_json, topic, title FROM session',
316
+ )
317
+ .all() as any[];
318
+ const totals = { sessions: rows.length, input_tokens: 0, output_tokens: 0, cost_usd: 0 };
319
+ const byAgent: Record<string, UsageBucket> = {};
320
+ const byDay: Record<string, UsageBucket> = {};
321
+ const byProject: Record<string, UsageBucket> = {};
322
+ const add = (m: Record<string, UsageBucket>, key: string, r: any) => {
323
+ const b = (m[key] ??= { sessions: 0, input_tokens: 0, output_tokens: 0, cost_usd: 0 });
324
+ b.sessions++;
325
+ b.input_tokens += r.input_tokens;
326
+ b.output_tokens += r.output_tokens;
327
+ b.cost_usd += r.cost_usd;
328
+ };
329
+ for (const r of rows) {
330
+ totals.input_tokens += r.input_tokens;
331
+ totals.output_tokens += r.output_tokens;
332
+ totals.cost_usd += r.cost_usd;
333
+ add(byAgent, r.agent || 'unknown', r);
334
+ if (r.started_at) add(byDay, new Date(r.started_at).toISOString().slice(0, 10), r);
335
+ for (const p of safeJson(r.projects_json)) add(byProject, p, r);
336
+ }
337
+ totals.cost_usd = round6(totals.cost_usd);
338
+ for (const m of [byAgent, byDay, byProject]) for (const b of Object.values(m)) b.cost_usd = round6(b.cost_usd);
339
+
340
+ const days = opts.days ?? 30;
341
+ const recentDays = Object.entries(byDay)
342
+ .sort((a, b) => (a[0] < b[0] ? 1 : -1))
343
+ .slice(0, days)
344
+ .map(([day, b]) => ({ day, ...b }));
345
+ const topByCost = rows
346
+ .map((r) => ({
347
+ session_id: r.session_id,
348
+ agent: r.agent,
349
+ cost_usd: round6(r.cost_usd),
350
+ label: r.topic || r.title || r.session_id,
351
+ }))
352
+ .sort((a, b) => b.cost_usd - a.cost_usd)
353
+ .slice(0, opts.topN ?? 10);
354
+
355
+ return {
356
+ totals,
357
+ byAgent,
358
+ byDay: recentDays,
359
+ byProject,
360
+ topByCost,
361
+ };
362
+ }
363
+
261
364
  stats(): { sessions: number; turns: number; cost_usd: number; byAgent: Record<string, number> } {
262
365
  const s = this.db.prepare('SELECT COUNT(*) c, COALESCE(SUM(cost_usd),0) cost FROM session').get() as {
263
366
  c: number;
@@ -51,6 +51,8 @@ export function syncIndex(
51
51
  const env = parsed.data;
52
52
 
53
53
  let topic: string | undefined;
54
+ let intent: string | undefined;
55
+ let projects: string[] = [];
54
56
  const insPath = insightsFile(ref.dir);
55
57
  let insights = undefined;
56
58
  if (existsSync(insPath)) {
@@ -58,6 +60,8 @@ export function syncIndex(
58
60
  if (pi.success) {
59
61
  insights = pi.data;
60
62
  topic = pi.data.topic;
63
+ intent = pi.data.intent;
64
+ projects = pi.data.projects ?? [];
61
65
  }
62
66
  }
63
67
 
@@ -66,7 +70,9 @@ export function syncIndex(
66
70
  mtime_ms,
67
71
  size_bytes,
68
72
  indexed_at: now,
73
+ projects,
69
74
  ...(topic ? { topic } : {}),
75
+ ...(intent ? { intent } : {}),
70
76
  });
71
77
  index.replaceTurns(env.session_id, readTurns(ref.dir));
72
78
  if (insights) index.upsertInsight(insights);
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Turn } from '@unpolarize/code-sessions-schema';
3
- import { deriveSignals, deriveTags, guessTopic } from './heuristics';
3
+ import { deriveIntent, deriveProjects, deriveSignals, deriveTags, guessTopic } from './heuristics';
4
4
 
5
5
  function turn(i: number, over: Partial<Turn> = {}): Turn {
6
6
  return {
@@ -69,3 +69,25 @@ describe('guessTopic / deriveTags', () => {
69
69
  expect(tags.sort()).toEqual(['Edit', 'Read']);
70
70
  });
71
71
  });
72
+
73
+ describe('deriveIntent', () => {
74
+ it('classifies intent from the first user prompt', () => {
75
+ expect(deriveIntent([turn(0, { role: 'user', text: 'fix the parser bug' })])).toBe('bugfix');
76
+ expect(deriveIntent([turn(0, { role: 'user', text: 'add a dark mode feature' })])).toBe('feature');
77
+ expect(deriveIntent([turn(0, { role: 'user', text: 'refactor the auth module' })])).toBe('refactor');
78
+ expect(deriveIntent([turn(0, { role: 'user', text: 'research the best vector db' })])).toBe('research');
79
+ expect(deriveIntent([turn(0, { role: 'user', text: 'xyzzy' })])).toBe('other');
80
+ expect(deriveIntent([])).toBeUndefined();
81
+ });
82
+ });
83
+
84
+ describe('deriveProjects', () => {
85
+ it('derives project ids from edited file paths', () => {
86
+ const projects = deriveProjects([
87
+ turn(0, { tool_calls: [{ name: 'Edit', input: { file_path: '/Users/x/projects/foo/a.ts' } }] }),
88
+ turn(1, { tool_calls: [{ name: 'Write', input: { file_path: '/Users/x/projects/ai/bar/b.ts' } }] }),
89
+ turn(2, { tool_calls: [{ name: 'Read', input: { path: '/Users/x/docs/notes.md' } }] }),
90
+ ]);
91
+ expect(projects).toEqual(['ai/bar', 'docs', 'foo']);
92
+ });
93
+ });
@@ -1,4 +1,4 @@
1
- import type { Signal, Turn } from '@unpolarize/code-sessions-schema';
1
+ import type { Intent, Signal, Turn } from '@unpolarize/code-sessions-schema';
2
2
 
3
3
  /**
4
4
  * Deterministic, LLM-free signal derivation. Runs regardless of provider so the
@@ -104,3 +104,50 @@ export function deriveTags(turns: Turn[]): string[] {
104
104
  for (const t of turns) for (const c of t.tool_calls) tags.add(c.name);
105
105
  return [...tags].slice(0, 12);
106
106
  }
107
+
108
+ /** Map an edited file path to a coarse project id (…/projects/<id>, …/docs → docs). */
109
+ export function projectIdFromPath(p: string): string | null {
110
+ const segs = p.split('/').filter(Boolean);
111
+ const i = segs.indexOf('projects');
112
+ if (i >= 0 && segs[i + 1] === 'ai' && segs[i + 2]) return `ai/${segs[i + 2]}`;
113
+ if (i >= 0 && segs[i + 1]) return segs[i + 1]!;
114
+ if (segs.includes('docs')) return 'docs';
115
+ return null;
116
+ }
117
+
118
+ /** Projects the session touched, from Edit/Write/Read tool file paths. */
119
+ export function deriveProjects(turns: Turn[]): string[] {
120
+ const set = new Set<string>();
121
+ for (const t of turns) {
122
+ for (const c of t.tool_calls) {
123
+ const fp = (c.input as { file_path?: string; path?: string } | undefined)?.file_path
124
+ ?? (c.input as { path?: string } | undefined)?.path;
125
+ if (typeof fp === 'string') {
126
+ const id = projectIdFromPath(fp);
127
+ if (id) set.add(id);
128
+ }
129
+ }
130
+ }
131
+ return [...set].sort().slice(0, 12);
132
+ }
133
+
134
+ const INTENT_PATTERNS: [Intent, RegExp][] = [
135
+ ['bugfix', /\b(fix|bug|broken|error|crash|regression|failing|stack ?trace)\b/i],
136
+ ['feature', /\b(add|implement|build|create|feature|support|introduce|new )\b/i],
137
+ ['refactor', /\b(refactor|clean ?up|simplify|rename|restructure|extract|dedupe)\b/i],
138
+ ['research', /\b(research|investigate|explore|compare|evaluate|find out|how (do|does|to)|why)\b/i],
139
+ ['docs', /\b(document|docs|readme|write[ -]?up|notes|comment)\b/i],
140
+ ['review', /\b(review|audit|critique|check|inspect)\b/i],
141
+ ['ops', /\b(deploy|release|publish|install|configure|ci\/?cd|pipeline|infra)\b/i],
142
+ ];
143
+
144
+ /** Classify the session's intent from the first substantive user prompt. */
145
+ export function deriveIntent(turns: Turn[]): Intent | undefined {
146
+ const firstUser = turns.find((t) => t.role === 'user' && t.text.trim().length > 0);
147
+ if (!firstUser) return undefined;
148
+ const text = firstUser.text;
149
+ for (const [intent, re] of INTENT_PATTERNS) {
150
+ if (re.test(text)) return intent;
151
+ }
152
+ return 'other';
153
+ }
@@ -52,8 +52,11 @@ describe('labelSession', () => {
52
52
  expect(ins!.generated_at).toBe('2026-06-20T09:00:00Z');
53
53
  expect(existsSync(insightsFile(dir))).toBe(true);
54
54
 
55
+ expect(ins!.intent).toBe('bugfix');
56
+ expect(ins!.tags).toContain('Edit'); // tags live on the insights record
55
57
  const env = JSON.parse(readFileSync(envelopeFile(dir), 'utf8'));
56
- expect(env.labels).toContain('Edit');
58
+ expect(env.labels).toContain('intent:bugfix'); // envelope labels = intent/topic/projects
59
+ expect(env.labels.some((l: string) => l.startsWith('intent:'))).toBe(true);
57
60
  });
58
61
  });
59
62
 
@@ -11,7 +11,7 @@ import type { CodeSessionsConfig } from '../config';
11
11
  import { envelopeFile, insightsFile } from '../store/paths';
12
12
  import { listSessionDirs } from '../store/scan';
13
13
  import { readTurns } from '../store/writer';
14
- import { deriveSignals, deriveTags, guessTopic } from './heuristics';
14
+ import { deriveIntent, deriveProjects, deriveSignals, deriveTags, guessTopic } from './heuristics';
15
15
  import { FakeProvider, type LabelResult, type Provider } from './provider';
16
16
  import { LlmProvider, claudeRunner, grokRunner, ollamaRunner } from './llm';
17
17
 
@@ -73,7 +73,7 @@ export async function labelSession(
73
73
  if (turns.length === 0) return undefined;
74
74
 
75
75
  const heuristicSignals = deriveSignals(turns);
76
- let provided: LabelResult = { tags: [], signals: [] };
76
+ let provided: LabelResult = { tags: [], projects: [], signals: [] };
77
77
  try {
78
78
  provided = await provider.label({ sessionId: identity.sessionId, host: identity.host, turns });
79
79
  } catch {
@@ -81,7 +81,9 @@ export async function labelSession(
81
81
  }
82
82
 
83
83
  const topic = provided.topic ?? guessTopic(turns);
84
+ const intent = provided.intent ?? deriveIntent(turns);
84
85
  const tags = [...new Set([...provided.tags, ...deriveTags(turns)])].slice(0, 16);
86
+ const projects = [...new Set([...provided.projects, ...deriveProjects(turns)])].slice(0, 16);
85
87
  const signals = dedupeSignals([...heuristicSignals, ...provided.signals]);
86
88
 
87
89
  const insights: Insights = {
@@ -91,17 +93,22 @@ export async function labelSession(
91
93
  generated_at: opts.now ?? new Date().toISOString(),
92
94
  provider: provider.name,
93
95
  tags,
96
+ projects,
94
97
  signals,
95
98
  };
96
99
  if (topic) insights.topic = topic;
100
+ if (intent) insights.intent = intent;
97
101
  if (provided.summary) insights.summary = provided.summary;
98
102
 
99
103
  writeJsonAtomic(insightsFile(sessionDir), insights);
100
- updateEnvelopeLabels(sessionDir, topic, tags);
104
+ updateEnvelopeLabels(sessionDir, { topic, intent, projects });
101
105
  return insights;
102
106
  }
103
107
 
104
- function updateEnvelopeLabels(sessionDir: string, topic: string | undefined, tags: string[]): void {
108
+ function updateEnvelopeLabels(
109
+ sessionDir: string,
110
+ l: { topic?: string; intent?: string; projects: string[] },
111
+ ): void {
105
112
  const path = envelopeFile(sessionDir);
106
113
  if (!existsSync(path)) return;
107
114
  let env: SessionEnvelope;
@@ -110,7 +117,13 @@ function updateEnvelopeLabels(sessionDir: string, topic: string | undefined, tag
110
117
  } catch {
111
118
  return;
112
119
  }
113
- env.labels = [...new Set([...(topic ? [topic] : []), ...tags])].slice(0, 16);
120
+ env.labels = [
121
+ ...new Set([
122
+ ...(l.intent ? [`intent:${l.intent}`] : []),
123
+ ...(l.topic ? [l.topic] : []),
124
+ ...l.projects.map((p) => `project:${p}`),
125
+ ]),
126
+ ].slice(0, 16);
114
127
  writeJsonAtomic(path, env);
115
128
  }
116
129
 
@@ -54,7 +54,7 @@ describe('parseLabelJson', () => {
54
54
  });
55
55
 
56
56
  it('returns empty on non-JSON', () => {
57
- expect(parseLabelJson('no json here')).toEqual({ tags: [], signals: [] });
57
+ expect(parseLabelJson('no json here')).toEqual({ tags: [], projects: [], signals: [] });
58
58
  });
59
59
  });
60
60