@unpolarize/code-sessions 0.1.0 → 0.2.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.
package/dist/index.js CHANGED
@@ -9,16 +9,21 @@ import {
9
9
  StateStore,
10
10
  THRESHOLDS,
11
11
  applyHygiene,
12
+ buildClaudeSkill,
13
+ buildLabelSkillBody,
12
14
  buildMetricPayload,
13
15
  buildPrompt,
16
+ buildPromptFile,
14
17
  buildTracePayload,
15
18
  claudeRunner,
16
19
  cmdBackfill,
17
20
  cmdDoctor,
18
21
  cmdExport,
22
+ cmdFork,
19
23
  cmdIndex,
20
24
  cmdInit,
21
25
  cmdInstallHooks,
26
+ cmdInstallSkills,
22
27
  cmdQuery,
23
28
  cmdReindex,
24
29
  cmdSearch,
@@ -26,6 +31,8 @@ import {
26
31
  codexSessionsRoot,
27
32
  computeEnvelope,
28
33
  defaultConfig,
34
+ deriveIntent,
35
+ deriveProjects,
29
36
  deriveSignals,
30
37
  deriveTags,
31
38
  discoverCodexSessions,
@@ -35,12 +42,14 @@ import {
35
42
  exportSession,
36
43
  exportStore,
37
44
  findTranscript,
45
+ forkSession,
38
46
  grokRunner,
39
47
  grokSessionsRoot,
40
48
  guessTopic,
41
49
  handleHookInput,
42
50
  insightsFile,
43
51
  installHooks,
52
+ installSkills,
44
53
  isSessionEndEvent,
45
54
  isoNano,
46
55
  labelSession,
@@ -59,6 +68,7 @@ import {
59
68
  parseLabelJson,
60
69
  postOtlp,
61
70
  priceFor,
71
+ projectIdFromPath,
62
72
  rawBlobFile,
63
73
  readEntries,
64
74
  readNewLines,
@@ -78,7 +88,7 @@ import {
78
88
  writeBlobFile,
79
89
  writeImportedSession,
80
90
  writeTurnFile
81
- } from "./chunk-ZJG2DWAK.js";
91
+ } from "./chunk-HV6FQJPS.js";
82
92
  export {
83
93
  CaptureEngine,
84
94
  DEFAULT_HOOK_EVENTS,
@@ -90,16 +100,21 @@ export {
90
100
  StateStore,
91
101
  THRESHOLDS,
92
102
  applyHygiene,
103
+ buildClaudeSkill,
104
+ buildLabelSkillBody,
93
105
  buildMetricPayload,
94
106
  buildPrompt,
107
+ buildPromptFile,
95
108
  buildTracePayload,
96
109
  claudeRunner,
97
110
  cmdBackfill,
98
111
  cmdDoctor,
99
112
  cmdExport,
113
+ cmdFork,
100
114
  cmdIndex,
101
115
  cmdInit,
102
116
  cmdInstallHooks,
117
+ cmdInstallSkills,
103
118
  cmdQuery,
104
119
  cmdReindex,
105
120
  cmdSearch,
@@ -107,6 +122,8 @@ export {
107
122
  codexSessionsRoot,
108
123
  computeEnvelope,
109
124
  defaultConfig,
125
+ deriveIntent,
126
+ deriveProjects,
110
127
  deriveSignals,
111
128
  deriveTags,
112
129
  discoverCodexSessions,
@@ -116,12 +133,14 @@ export {
116
133
  exportSession,
117
134
  exportStore,
118
135
  findTranscript,
136
+ forkSession,
119
137
  grokRunner,
120
138
  grokSessionsRoot,
121
139
  guessTopic,
122
140
  handleHookInput,
123
141
  insightsFile,
124
142
  installHooks,
143
+ installSkills,
125
144
  isSessionEndEvent,
126
145
  isoNano,
127
146
  labelSession,
@@ -140,6 +159,7 @@ export {
140
159
  parseLabelJson,
141
160
  postOtlp,
142
161
  priceFor,
162
+ projectIdFromPath,
143
163
  rawBlobFile,
144
164
  readEntries,
145
165
  readNewLines,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unpolarize/code-sessions",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Headless, event-driven cross-agent session capture agent (daemon + CLI)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,14 +8,24 @@
8
8
  "code-sessions": "./bin/code-sessions.mjs"
9
9
  },
10
10
  "main": "./dist/index.js",
11
- "files": ["dist", "src", "bin"],
12
- "publishConfig": { "access": "public" },
13
- "repository": { "type": "git", "url": "git+https://github.com/unpolarize/code-sessions.git", "directory": "packages/agent" },
11
+ "files": [
12
+ "dist",
13
+ "src",
14
+ "bin"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/unpolarize/code-sessions.git",
22
+ "directory": "packages/agent"
23
+ },
14
24
  "scripts": {
15
25
  "build": "tsup src/index.ts src/cli.ts --format esm --clean --out-dir dist"
16
26
  },
17
27
  "dependencies": {
18
- "@unpolarize/code-sessions-schema": "^0.1.0",
28
+ "@unpolarize/code-sessions-schema": "^0.2.0",
19
29
  "zod": "^3.23.8"
20
30
  }
21
31
  }
package/src/cli.ts CHANGED
@@ -4,9 +4,11 @@ import {
4
4
  cmdBackfill,
5
5
  cmdDoctor,
6
6
  cmdExport,
7
+ cmdFork,
7
8
  cmdIndex,
8
9
  cmdInit,
9
10
  cmdInstallHooks,
11
+ cmdInstallSkills,
10
12
  cmdQuery,
11
13
  cmdReindex,
12
14
  cmdSearch,
@@ -45,6 +47,9 @@ export async function main(argv: string[]): Promise<void> {
45
47
  }),
46
48
  );
47
49
  break;
50
+ case 'install-skills':
51
+ emit(cmdInstallSkills(typeof flags.agent === 'string' ? { agent: flags.agent as 'claude' | 'codex' | 'grok' | 'all' } : {}));
52
+ break;
48
53
  case 'backfill':
49
54
  emit(
50
55
  await cmdBackfill(cfg, {
@@ -78,6 +83,17 @@ export async function main(argv: string[]): Promise<void> {
78
83
  emit(cmdSearch(cfg, { query: q, ...(typeof flags.limit === 'string' ? { limit: Number(flags.limit) } : {}) }));
79
84
  break;
80
85
  }
86
+ case 'fork': {
87
+ const sid = argv.slice(1).find((a) => !a.startsWith('--')) ?? '';
88
+ emit(
89
+ cmdFork(cfg, {
90
+ sessionId: sid,
91
+ atTurn: typeof flags.at === 'string' ? Number(flags.at) : NaN,
92
+ ...(typeof flags.id === 'string' ? { newId: flags.id } : {}),
93
+ }),
94
+ );
95
+ break;
96
+ }
81
97
  case 'hook': {
82
98
  // Never fail the agent: swallow everything, always exit 0.
83
99
  try {
package/src/cliargs.ts CHANGED
@@ -51,6 +51,7 @@ Commands:
51
51
  init Initialize the git-backed store (~/.sessions)
52
52
  start Run the capture daemon (foreground)
53
53
  install-hooks Install Claude Code hooks that feed the daemon
54
+ install-skills Install the cs-label-session skill into agents [--agent claude|codex|grok|all]
54
55
  hook (internal) forward a hook payload from stdin to the daemon
55
56
  backfill Import existing transcripts into the store [--agent claude|grok|codex|all]
56
57
  reindex (Re)derive insights for stored sessions [--since YYYY-MM]
@@ -58,6 +59,7 @@ Commands:
58
59
  index (Re)build the internal SQLite index from the git store
59
60
  query List recent sessions from the index [--limit N] [--agent X]
60
61
  search Full-text search session turns <text> [--limit N]
62
+ fork Fork a session at a turn ("git for sessions") <session-id> --at N [--id X]
61
63
  analytics Compute MVP-2 rollups + digest into analytics/
62
64
  status Show daemon/store status
63
65
  doctor Environment checks
package/src/commands.ts CHANGED
@@ -15,6 +15,8 @@ import { discoverCodexSessions, parseCodexSession } from './adapters/codex';
15
15
  import { writeImportedSession } from './adapters/import';
16
16
  import { SessionIndex, type SessionIndexRow } from './index_store/db';
17
17
  import { syncIndex } from './index_store/sync';
18
+ import { installSkills, type SkillAgent } from './skills/install';
19
+ import { forkSession } from './fork';
18
20
 
19
21
  export interface CommandResult {
20
22
  code: number;
@@ -192,6 +194,35 @@ export async function cmdExport(
192
194
  };
193
195
  }
194
196
 
197
+ export function cmdFork(
198
+ cfg: CodeSessionsConfig,
199
+ opts: { sessionId: string; atTurn: number; newId?: string },
200
+ ): CommandResult {
201
+ if (!opts.sessionId || Number.isNaN(opts.atTurn)) {
202
+ return { code: 1, output: 'usage: code-sessions fork <session-id> --at <turn> [--id <new-id>]' };
203
+ }
204
+ try {
205
+ const res = forkSession(cfg, {
206
+ sessionId: opts.sessionId,
207
+ atTurn: opts.atTurn,
208
+ ...(opts.newId ? { newSessionId: opts.newId } : {}),
209
+ });
210
+ const git = gitStoreFor(cfg);
211
+ if (git.isRepo()) git.commit(`fork ${opts.sessionId}@${opts.atTurn} -> ${res.newSessionId}`);
212
+ return {
213
+ code: 0,
214
+ output: `Forked ${opts.sessionId} at turn ${opts.atTurn} → ${res.newSessionId} (${res.turns} turns) at ${res.sessionDir}`,
215
+ };
216
+ } catch (e) {
217
+ return { code: 1, output: `fork failed: ${e instanceof Error ? e.message : String(e)}` };
218
+ }
219
+ }
220
+
221
+ export function cmdInstallSkills(opts: { agent?: SkillAgent } = {}): CommandResult {
222
+ const res = installSkills(opts.agent ? { agent: opts.agent } : {});
223
+ return { code: 0, output: `Installed cs-label-session skill:\n ${res.installed.join('\n ')}` };
224
+ }
225
+
195
226
  export function cmdIndex(cfg: CodeSessionsConfig): CommandResult {
196
227
  const stats = syncIndex(cfg);
197
228
  return {
@@ -205,8 +236,9 @@ function fmtRow(r: SessionIndexRow): string {
205
236
  const agent = (r.agent || '?').padEnd(11).slice(0, 11);
206
237
  const tok = String(r.input_tokens + r.output_tokens).padStart(8);
207
238
  const cost = `$${r.cost_usd.toFixed(2)}`.padStart(8);
208
- const title = (r.topic || r.title || r.session_id).slice(0, 48);
209
- return `${date} ${agent} ${tok} ${cost} ${title}`;
239
+ const intent = (r.intent || '·').padEnd(8).slice(0, 8);
240
+ const title = (r.topic || r.title || r.session_id).slice(0, 44);
241
+ return `${date} ${agent} ${intent} ${tok} ${cost} ${title}`;
210
242
  }
211
243
 
212
244
  export function cmdQuery(
@@ -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';
@@ -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,6 +39,8 @@ 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
 
@@ -60,7 +62,8 @@ export class SessionIndex {
60
62
 
61
63
  private migrate(): void {
62
64
  const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number };
63
- if ((row?.user_version ?? 0) < 1) {
65
+ const cur = row?.user_version ?? 0;
66
+ if (cur < 1) {
64
67
  this.db.exec(`
65
68
  CREATE TABLE IF NOT EXISTS session (
66
69
  session_id TEXT PRIMARY KEY,
@@ -78,6 +81,8 @@ export class SessionIndex {
78
81
  title TEXT,
79
82
  labels_json TEXT NOT NULL DEFAULT '[]',
80
83
  topic TEXT,
84
+ intent TEXT,
85
+ projects_json TEXT NOT NULL DEFAULT '[]',
81
86
  source_path TEXT NOT NULL,
82
87
  mtime_ms INTEGER NOT NULL DEFAULT 0,
83
88
  size_bytes INTEGER NOT NULL DEFAULT 0,
@@ -101,13 +106,24 @@ export class SessionIndex {
101
106
  CREATE TABLE IF NOT EXISTS insight (
102
107
  session_id TEXT PRIMARY KEY REFERENCES session(session_id) ON DELETE CASCADE,
103
108
  topic TEXT,
109
+ intent TEXT,
104
110
  tags_json TEXT NOT NULL DEFAULT '[]',
111
+ projects_json TEXT NOT NULL DEFAULT '[]',
105
112
  signals_json TEXT NOT NULL DEFAULT '[]',
106
113
  provider TEXT,
107
114
  generated_at TEXT
108
115
  );
109
116
  `);
110
117
  this.db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
118
+ } else if (cur < 2) {
119
+ // additive v1 -> v2: intent + projects columns
120
+ this.db.exec(`
121
+ ALTER TABLE session ADD COLUMN intent TEXT;
122
+ ALTER TABLE session ADD COLUMN projects_json TEXT NOT NULL DEFAULT '[]';
123
+ ALTER TABLE insight ADD COLUMN intent TEXT;
124
+ ALTER TABLE insight ADD COLUMN projects_json TEXT NOT NULL DEFAULT '[]';
125
+ PRAGMA user_version = ${SCHEMA_VERSION};
126
+ `);
111
127
  }
112
128
  }
113
129
 
@@ -125,21 +141,30 @@ export class SessionIndex {
125
141
 
126
142
  upsertSession(
127
143
  env: SessionEnvelope,
128
- src: { source_path: string; mtime_ms: number; size_bytes: number; indexed_at: number; topic?: string },
144
+ src: {
145
+ source_path: string;
146
+ mtime_ms: number;
147
+ size_bytes: number;
148
+ indexed_at: number;
149
+ topic?: string;
150
+ intent?: string;
151
+ projects?: string[];
152
+ },
129
153
  ): void {
130
154
  this.db
131
155
  .prepare(
132
156
  `INSERT INTO session (session_id, host, agent, project_path, model, started_at, ended_at,
133
157
  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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
158
+ topic, intent, projects_json, source_path, mtime_ms, size_bytes, indexed_at)
159
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
136
160
  ON CONFLICT(session_id) DO UPDATE SET
137
161
  host=excluded.host, agent=excluded.agent, project_path=excluded.project_path,
138
162
  model=excluded.model, started_at=excluded.started_at, ended_at=excluded.ended_at,
139
163
  turn_count=excluded.turn_count, tool_call_count=excluded.tool_call_count,
140
164
  input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens,
141
165
  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,
166
+ topic=excluded.topic, intent=excluded.intent, projects_json=excluded.projects_json,
167
+ source_path=excluded.source_path, mtime_ms=excluded.mtime_ms,
143
168
  size_bytes=excluded.size_bytes, indexed_at=excluded.indexed_at`,
144
169
  )
145
170
  .run(
@@ -158,6 +183,8 @@ export class SessionIndex {
158
183
  env.title ?? null,
159
184
  JSON.stringify(env.labels ?? []),
160
185
  src.topic ?? null,
186
+ src.intent ?? null,
187
+ JSON.stringify(src.projects ?? []),
161
188
  src.source_path,
162
189
  src.mtime_ms,
163
190
  src.size_bytes,
@@ -190,13 +217,15 @@ export class SessionIndex {
190
217
  upsertInsight(ins: Insights): void {
191
218
  this.db
192
219
  .prepare(
193
- `INSERT OR REPLACE INTO insight (session_id, topic, tags_json, signals_json, provider, generated_at)
194
- VALUES (?,?,?,?,?,?)`,
220
+ `INSERT OR REPLACE INTO insight (session_id, topic, intent, tags_json, projects_json, signals_json, provider, generated_at)
221
+ VALUES (?,?,?,?,?,?,?,?)`,
195
222
  )
196
223
  .run(
197
224
  ins.session_id,
198
225
  ins.topic ?? null,
226
+ ins.intent ?? null,
199
227
  JSON.stringify(ins.tags ?? []),
228
+ JSON.stringify(ins.projects ?? []),
200
229
  JSON.stringify(ins.signals ?? []),
201
230
  ins.provider,
202
231
  ins.generated_at,
@@ -226,6 +255,8 @@ export class SessionIndex {
226
255
  title: r.title ?? null,
227
256
  labels: safeJson(r.labels_json),
228
257
  topic: r.topic ?? null,
258
+ intent: r.intent ?? null,
259
+ projects: safeJson(r.projects_json),
229
260
  source_path: r.source_path,
230
261
  };
231
262
  }
@@ -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
+ });