@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,106 @@
1
+ import type { Signal, Turn } from '@unpolarize/code-sessions-schema';
2
+
3
+ /**
4
+ * Deterministic, LLM-free signal derivation. Runs regardless of provider so the
5
+ * store always has a useful baseline; the configured agent adds topic/tags/summary
6
+ * on top. Pure functions, easy to test.
7
+ */
8
+
9
+ export const THRESHOLDS = {
10
+ stuckRepeat: 3, // N consecutive identical assistant/tool actions
11
+ highCostUsd: 0.5,
12
+ longSessionTurns: 80,
13
+ toolHeavyRatio: 1.5,
14
+ };
15
+
16
+ const ERROR_RE = /(error|exception|traceback|failed|fatal|panic)/i;
17
+
18
+ function actionKey(t: Turn): string {
19
+ if (t.tool_calls.length > 0) {
20
+ return `tool:${t.tool_calls.map((c) => `${c.name}(${JSON.stringify(c.input ?? null)})`).join(',')}`;
21
+ }
22
+ return `${t.role}:${t.text.slice(0, 120)}`;
23
+ }
24
+
25
+ export function deriveSignals(turns: Turn[]): Signal[] {
26
+ const signals: Signal[] = [];
27
+
28
+ // stuck-loop: the same action repeated >= N times in a row
29
+ let runKey = '';
30
+ let runLen = 0;
31
+ let runStart = 0;
32
+ let flaggedStuck = false;
33
+ for (let i = 0; i < turns.length; i++) {
34
+ const key = actionKey(turns[i]!);
35
+ if (key === runKey) {
36
+ runLen++;
37
+ } else {
38
+ runKey = key;
39
+ runLen = 1;
40
+ runStart = i;
41
+ }
42
+ if (runLen >= THRESHOLDS.stuckRepeat && !flaggedStuck) {
43
+ signals.push({
44
+ kind: 'stuck-loop',
45
+ severity: 'warn',
46
+ turn_index: turns[runStart]!.turn_index,
47
+ note: `repeated action ×${runLen}`,
48
+ });
49
+ flaggedStuck = true;
50
+ }
51
+ }
52
+
53
+ // error-recovery: a tool/assistant turn whose text mentions an error
54
+ for (const t of turns) {
55
+ if (ERROR_RE.test(t.text)) {
56
+ signals.push({ kind: 'error-recovery', severity: 'info', turn_index: t.turn_index });
57
+ break;
58
+ }
59
+ }
60
+
61
+ // high-cost-turn
62
+ for (const t of turns) {
63
+ const cost = t.telemetry?.cost_usd ?? 0;
64
+ if (cost >= THRESHOLDS.highCostUsd) {
65
+ signals.push({
66
+ kind: 'high-cost-turn',
67
+ severity: 'warn',
68
+ turn_index: t.turn_index,
69
+ note: `$${cost.toFixed(2)}`,
70
+ });
71
+ break;
72
+ }
73
+ }
74
+
75
+ // long-session
76
+ if (turns.length >= THRESHOLDS.longSessionTurns) {
77
+ signals.push({ kind: 'long-session', severity: 'info', note: `${turns.length} turns` });
78
+ }
79
+
80
+ // tool-heavy
81
+ const toolCalls = turns.reduce((a, t) => a + t.tool_calls.length, 0);
82
+ if (turns.length > 0 && toolCalls / turns.length >= THRESHOLDS.toolHeavyRatio) {
83
+ signals.push({
84
+ kind: 'tool-heavy',
85
+ severity: 'info',
86
+ note: `${toolCalls} tool calls / ${turns.length} turns`,
87
+ });
88
+ }
89
+
90
+ return signals;
91
+ }
92
+
93
+ /** A cheap, deterministic topic guess: first meaningful words of the first user turn. */
94
+ export function guessTopic(turns: Turn[]): string | undefined {
95
+ const firstUser = turns.find((t) => t.role === 'user' && t.text.trim().length > 0);
96
+ if (!firstUser) return undefined;
97
+ const words = firstUser.text.trim().split(/\s+/).slice(0, 8).join(' ');
98
+ return words.length > 0 ? words : undefined;
99
+ }
100
+
101
+ /** Tags from distinct tool names used in the session. */
102
+ export function deriveTags(turns: Turn[]): string[] {
103
+ const tags = new Set<string>();
104
+ for (const t of turns) for (const c of t.tool_calls) tags.add(c.name);
105
+ return [...tags].slice(0, 12);
106
+ }
@@ -0,0 +1,4 @@
1
+ export * from './heuristics';
2
+ export * from './provider';
3
+ export * from './llm';
4
+ export * from './labeler';
@@ -0,0 +1,105 @@
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 { makeConfig, withTempDirAsync } from '../test/tmp';
5
+ import { envelopeFile, insightsFile, sessionDir } from '../store/paths';
6
+ import { rebuildEnvelope, writeTurnFile } from '../store/writer';
7
+ import { FakeProvider, type Provider } from './provider';
8
+ import { labelSession, makeProvider, reindexStore } from './labeler';
9
+
10
+ function turn(i: number, over: Partial<Turn> = {}): Turn {
11
+ return {
12
+ schema: 'session-store/turn@1',
13
+ session_id: 's1',
14
+ host: 'h',
15
+ agent: 'claude-code',
16
+ turn_index: i,
17
+ ts: `2026-06-20T08:0${i}:00Z`,
18
+ role: 'assistant',
19
+ text: '',
20
+ tool_calls: [],
21
+ usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
22
+ scrubbed: false,
23
+ raw_ref: null,
24
+ ...over,
25
+ };
26
+ }
27
+
28
+ function seedSession(store: string): string {
29
+ const dir = sessionDir(store, 'h', '2026-06', 's1');
30
+ writeTurnFile(dir, turn(0, { role: 'user', text: 'Fix the bug in foo.ts' }));
31
+ writeTurnFile(dir, turn(1, { tool_calls: [{ name: 'Edit' }], telemetry: { cost_usd: 0.9 }, text: 'editing' }));
32
+ rebuildEnvelope(store, 'h', '2026-06', 's1', { model: 'claude-opus-4-8' }, {
33
+ session_id: 's1',
34
+ host: 'h',
35
+ agent: 'claude-code',
36
+ native_uuid: 's1',
37
+ });
38
+ return dir;
39
+ }
40
+
41
+ describe('labelSession', () => {
42
+ it('writes insights and reflects labels onto the envelope', async () => {
43
+ await withTempDirAsync(async (store) => {
44
+ const dir = seedSession(store);
45
+ const ins = await labelSession(dir, { sessionId: 's1', host: 'h' }, new FakeProvider(), {
46
+ now: '2026-06-20T09:00:00Z',
47
+ });
48
+ expect(ins).toBeDefined();
49
+ expect(ins!.topic).toContain('Fix the bug');
50
+ expect(ins!.tags).toContain('Edit');
51
+ expect(ins!.signals.some((s) => s.kind === 'high-cost-turn')).toBe(true);
52
+ expect(ins!.generated_at).toBe('2026-06-20T09:00:00Z');
53
+ expect(existsSync(insightsFile(dir))).toBe(true);
54
+
55
+ const env = JSON.parse(readFileSync(envelopeFile(dir), 'utf8'));
56
+ expect(env.labels).toContain('Edit');
57
+ });
58
+ });
59
+
60
+ it('degrades to heuristics when the provider throws', async () => {
61
+ await withTempDirAsync(async (store) => {
62
+ const dir = seedSession(store);
63
+ const failing: Provider = {
64
+ name: 'broken',
65
+ label: async () => {
66
+ throw new Error('no cli');
67
+ },
68
+ };
69
+ const ins = await labelSession(dir, { sessionId: 's1', host: 'h' }, failing);
70
+ expect(ins).toBeDefined();
71
+ // heuristics still produced a high-cost signal and a topic guess
72
+ expect(ins!.signals.some((s) => s.kind === 'high-cost-turn')).toBe(true);
73
+ expect(ins!.topic).toContain('Fix the bug');
74
+ });
75
+ });
76
+
77
+ it('returns undefined for an empty session', async () => {
78
+ await withTempDirAsync(async (store) => {
79
+ const dir = sessionDir(store, 'h', '2026-06', 'empty');
80
+ const ins = await labelSession(dir, { sessionId: 'empty', host: 'h' }, new FakeProvider());
81
+ expect(ins).toBeUndefined();
82
+ });
83
+ });
84
+ });
85
+
86
+ describe('reindexStore', () => {
87
+ it('labels every session in the store', async () => {
88
+ await withTempDirAsync(async (store) => {
89
+ seedSession(store);
90
+ const res = await reindexStore(makeConfig(store), new FakeProvider(), {
91
+ now: '2026-06-20T09:00:00Z',
92
+ });
93
+ expect(res.count).toBe(1);
94
+ expect(res.sessions).toContain('s1');
95
+ });
96
+ });
97
+ });
98
+
99
+ describe('makeProvider', () => {
100
+ it('returns null when disabled and a FakeProvider when configured', () => {
101
+ expect(makeProvider(makeConfig('/tmp/x', { insights: { provider: 'none' } }))).toBeNull();
102
+ expect(makeProvider(makeConfig('/tmp/x', { insights: { provider: 'fake' } }))?.name).toBe('fake');
103
+ expect(makeProvider(makeConfig('/tmp/x', { insights: { provider: 'ollama' } }))?.name).toBe('ollama');
104
+ });
105
+ });
@@ -0,0 +1,136 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import {
4
+ SCHEMA_VERSIONS,
5
+ parseSession,
6
+ type Insights,
7
+ type SessionEnvelope,
8
+ type Signal,
9
+ } from '@unpolarize/code-sessions-schema';
10
+ import type { CodeSessionsConfig } from '../config';
11
+ import { envelopeFile, insightsFile } from '../store/paths';
12
+ import { listSessionDirs } from '../store/scan';
13
+ import { readTurns } from '../store/writer';
14
+ import { deriveSignals, deriveTags, guessTopic } from './heuristics';
15
+ import { FakeProvider, type LabelResult, type Provider } from './provider';
16
+ import { LlmProvider, claudeRunner, grokRunner, ollamaRunner } from './llm';
17
+
18
+ function writeJsonAtomic(path: string, value: unknown): void {
19
+ mkdirSync(dirname(path), { recursive: true });
20
+ const tmp = `${path}.tmp`;
21
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
22
+ renameSync(tmp, path);
23
+ }
24
+
25
+ function dedupeSignals(signals: Signal[]): Signal[] {
26
+ const seen = new Set<string>();
27
+ const out: Signal[] = [];
28
+ for (const s of signals) {
29
+ const key = `${s.kind}:${s.turn_index ?? ''}`;
30
+ if (seen.has(key)) continue;
31
+ seen.add(key);
32
+ out.push(s);
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export interface LabelOptions {
38
+ /** ISO timestamp for the insights record (injectable for deterministic tests) */
39
+ now?: string;
40
+ }
41
+
42
+ /** Build the provider configured in cfg, or null when insights are disabled. */
43
+ export function makeProvider(cfg: CodeSessionsConfig): Provider | null {
44
+ const { provider, model } = cfg.insights;
45
+ switch (provider) {
46
+ case 'none':
47
+ return null;
48
+ case 'fake':
49
+ return new FakeProvider();
50
+ case 'claude':
51
+ return new LlmProvider('claude', claudeRunner(model));
52
+ case 'grok':
53
+ return new LlmProvider('grok', grokRunner(model));
54
+ case 'ollama':
55
+ return new LlmProvider('ollama', ollamaRunner(model));
56
+ default:
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Label one session: deterministic heuristics + the configured provider, written
63
+ * to insights/labels.json and reflected as envelope labels. Provider failures
64
+ * degrade gracefully to heuristics-only.
65
+ */
66
+ export async function labelSession(
67
+ sessionDir: string,
68
+ identity: { sessionId: string; host: string },
69
+ provider: Provider,
70
+ opts: LabelOptions = {},
71
+ ): Promise<Insights | undefined> {
72
+ const turns = readTurns(sessionDir);
73
+ if (turns.length === 0) return undefined;
74
+
75
+ const heuristicSignals = deriveSignals(turns);
76
+ let provided: LabelResult = { tags: [], signals: [] };
77
+ try {
78
+ provided = await provider.label({ sessionId: identity.sessionId, host: identity.host, turns });
79
+ } catch {
80
+ // provider unavailable (no CLI / API) — heuristics still apply
81
+ }
82
+
83
+ const topic = provided.topic ?? guessTopic(turns);
84
+ const tags = [...new Set([...provided.tags, ...deriveTags(turns)])].slice(0, 16);
85
+ const signals = dedupeSignals([...heuristicSignals, ...provided.signals]);
86
+
87
+ const insights: Insights = {
88
+ schema: SCHEMA_VERSIONS.insights,
89
+ session_id: identity.sessionId,
90
+ host: identity.host,
91
+ generated_at: opts.now ?? new Date().toISOString(),
92
+ provider: provider.name,
93
+ tags,
94
+ signals,
95
+ };
96
+ if (topic) insights.topic = topic;
97
+ if (provided.summary) insights.summary = provided.summary;
98
+
99
+ writeJsonAtomic(insightsFile(sessionDir), insights);
100
+ updateEnvelopeLabels(sessionDir, topic, tags);
101
+ return insights;
102
+ }
103
+
104
+ function updateEnvelopeLabels(sessionDir: string, topic: string | undefined, tags: string[]): void {
105
+ const path = envelopeFile(sessionDir);
106
+ if (!existsSync(path)) return;
107
+ let env: SessionEnvelope;
108
+ try {
109
+ env = parseSession(JSON.parse(readFileSync(path, 'utf8')));
110
+ } catch {
111
+ return;
112
+ }
113
+ env.labels = [...new Set([...(topic ? [topic] : []), ...tags])].slice(0, 16);
114
+ writeJsonAtomic(path, env);
115
+ }
116
+
117
+ export interface ReindexResult {
118
+ count: number;
119
+ sessions: string[];
120
+ }
121
+
122
+ /** Re-label every session in the store (optionally since a month). Post-processing path. */
123
+ export async function reindexStore(
124
+ cfg: CodeSessionsConfig,
125
+ provider: Provider,
126
+ opts: { sinceMonth?: string; now?: string } = {},
127
+ ): Promise<ReindexResult> {
128
+ const refs = listSessionDirs(cfg.storeDir, opts.sinceMonth ? { sinceMonth: opts.sinceMonth } : {});
129
+ const labeled: string[] = [];
130
+ for (const ref of refs) {
131
+ const labelOpts = opts.now ? { now: opts.now } : {};
132
+ const res = await labelSession(ref.dir, { sessionId: ref.sessionId, host: ref.host }, provider, labelOpts);
133
+ if (res) labeled.push(ref.sessionId);
134
+ }
135
+ return { count: labeled.length, sessions: labeled };
136
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Turn } from '@unpolarize/code-sessions-schema';
3
+ import { LlmProvider, buildPrompt, parseLabelJson } from './llm';
4
+
5
+ function turn(i: number, role: Turn['role'], text: string): 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,
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
+ };
20
+ }
21
+
22
+ describe('buildPrompt', () => {
23
+ it('includes the transcript and a JSON instruction', () => {
24
+ const p = buildPrompt({
25
+ sessionId: 's',
26
+ host: 'h',
27
+ turns: [turn(0, 'user', 'fix foo'), turn(1, 'assistant', 'done')],
28
+ });
29
+ expect(p).toContain('ONLY a JSON object');
30
+ expect(p).toContain('[user] fix foo');
31
+ expect(p).toContain('[assistant] done');
32
+ });
33
+ });
34
+
35
+ describe('parseLabelJson', () => {
36
+ it('parses a clean JSON object', () => {
37
+ const r = parseLabelJson('{"topic":"debugging","tags":["foo"],"summary":"s","signals":[]}');
38
+ expect(r.topic).toBe('debugging');
39
+ expect(r.tags).toEqual(['foo']);
40
+ expect(r.summary).toBe('s');
41
+ });
42
+
43
+ it('tolerates surrounding prose', () => {
44
+ const r = parseLabelJson('Sure! Here:\n{"topic":"x","tags":[]}\nHope that helps.');
45
+ expect(r.topic).toBe('x');
46
+ });
47
+
48
+ it('drops invalid signal kinds and coerces severity', () => {
49
+ const r = parseLabelJson(
50
+ '{"tags":[],"signals":[{"kind":"bogus"},{"kind":"stuck-loop","severity":"nope","note":"n"}]}',
51
+ );
52
+ expect(r.signals).toHaveLength(1);
53
+ expect(r.signals[0]).toMatchObject({ kind: 'stuck-loop', severity: 'info', note: 'n' });
54
+ });
55
+
56
+ it('returns empty on non-JSON', () => {
57
+ expect(parseLabelJson('no json here')).toEqual({ tags: [], signals: [] });
58
+ });
59
+ });
60
+
61
+ describe('LlmProvider', () => {
62
+ it('labels via an injected runner', async () => {
63
+ const provider = new LlmProvider('stub', async () => '{"topic":"t","tags":["a"],"signals":[]}');
64
+ const res = await provider.label({ sessionId: 's', host: 'h', turns: [turn(0, 'user', 'hi')] });
65
+ expect(res.topic).toBe('t');
66
+ expect(res.tags).toEqual(['a']);
67
+ });
68
+
69
+ it('propagates runner errors (labeler handles the fallback)', async () => {
70
+ const provider = new LlmProvider('stub', async () => {
71
+ throw new Error('no cli');
72
+ });
73
+ await expect(
74
+ provider.label({ sessionId: 's', host: 'h', turns: [turn(0, 'user', 'hi')] }),
75
+ ).rejects.toThrow('no cli');
76
+ });
77
+ });
@@ -0,0 +1,130 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { SIGNAL_KINDS, type Signal } from '@unpolarize/code-sessions-schema';
3
+ import type { LabelRequest, LabelResult, Provider } from './provider';
4
+
5
+ /** Runs a prompt against some agent CLI/API and returns its raw text output. */
6
+ export type CommandRunner = (prompt: string) => Promise<string>;
7
+
8
+ const MAX_HEAD = 40;
9
+ const MAX_TAIL = 10;
10
+ const MAX_TURN_CHARS = 400;
11
+
12
+ export function buildPrompt(req: LabelRequest): string {
13
+ const turns = req.turns;
14
+ const picked =
15
+ turns.length > MAX_HEAD + MAX_TAIL
16
+ ? [...turns.slice(0, MAX_HEAD), { role: '…', text: `(${turns.length - MAX_HEAD - MAX_TAIL} turns elided)`, tool_calls: [] as unknown[] }, ...turns.slice(-MAX_TAIL)]
17
+ : turns;
18
+ const transcript = picked
19
+ .map((t) => {
20
+ const tools = (t as { tool_calls?: { name: string }[] }).tool_calls
21
+ ?.map((c) => c.name)
22
+ .join(',');
23
+ const head = tools ? `[${t.role} tools:${tools}]` : `[${t.role}]`;
24
+ return `${head} ${String(t.text).slice(0, MAX_TURN_CHARS)}`;
25
+ })
26
+ .join('\n');
27
+
28
+ return [
29
+ 'You label a coding-agent session. Respond with ONLY a JSON object, no prose:',
30
+ '{"topic": string, "tags": string[], "summary": string, "signals": [{"kind": one of ' +
31
+ SIGNAL_KINDS.join('|') +
32
+ ', "severity": "info"|"warn"|"critical", "note": string}]}',
33
+ 'topic: 3-6 words. tags: tools/themes. summary: <=1 sentence. signals: only notable ones.',
34
+ '',
35
+ 'Transcript:',
36
+ transcript,
37
+ ].join('\n');
38
+ }
39
+
40
+ const KIND_SET = new Set<string>(SIGNAL_KINDS);
41
+
42
+ export function parseLabelJson(out: string): LabelResult {
43
+ const start = out.indexOf('{');
44
+ const end = out.lastIndexOf('}');
45
+ if (start < 0 || end <= start) return { tags: [], signals: [] };
46
+ let obj: Record<string, unknown>;
47
+ try {
48
+ obj = JSON.parse(out.slice(start, end + 1)) as Record<string, unknown>;
49
+ } catch {
50
+ return { tags: [], signals: [] };
51
+ }
52
+ const result: LabelResult = {
53
+ tags: Array.isArray(obj.tags) ? obj.tags.filter((t): t is string => typeof t === 'string') : [],
54
+ signals: coerceSignals(obj.signals),
55
+ };
56
+ if (typeof obj.topic === 'string') result.topic = obj.topic;
57
+ if (typeof obj.summary === 'string') result.summary = obj.summary;
58
+ return result;
59
+ }
60
+
61
+ function coerceSignals(raw: unknown): Signal[] {
62
+ if (!Array.isArray(raw)) return [];
63
+ const out: Signal[] = [];
64
+ for (const s of raw) {
65
+ if (!s || typeof s !== 'object') continue;
66
+ const o = s as Record<string, unknown>;
67
+ if (typeof o.kind !== 'string' || !KIND_SET.has(o.kind)) continue;
68
+ const sig: Signal = {
69
+ kind: o.kind as Signal['kind'],
70
+ severity:
71
+ o.severity === 'warn' || o.severity === 'critical' ? o.severity : 'info',
72
+ };
73
+ if (typeof o.note === 'string') sig.note = o.note;
74
+ if (typeof o.turn_index === 'number') sig.turn_index = o.turn_index;
75
+ out.push(sig);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ /** A provider that shells out to a user-configured agent CLI/API. */
81
+ export class LlmProvider implements Provider {
82
+ constructor(
83
+ readonly name: string,
84
+ private readonly runner: CommandRunner,
85
+ ) {}
86
+
87
+ async label(req: LabelRequest): Promise<LabelResult> {
88
+ const out = await this.runner(buildPrompt(req));
89
+ return parseLabelJson(out);
90
+ }
91
+ }
92
+
93
+ // ---- concrete runners (best-effort; require the tool to be installed) ----
94
+
95
+ function spawnText(cmd: string, args: string[], input?: string): string {
96
+ const res = spawnSync(cmd, args, {
97
+ encoding: 'utf8',
98
+ input,
99
+ maxBuffer: 16 * 1024 * 1024,
100
+ });
101
+ if (res.error) throw new Error(`${cmd} failed: ${res.error.message}`);
102
+ if (res.status !== 0) throw new Error(`${cmd} exited ${res.status}: ${(res.stderr ?? '').slice(0, 200)}`);
103
+ return res.stdout ?? '';
104
+ }
105
+
106
+ /** `claude -p <prompt>` (print mode). */
107
+ export const claudeRunner =
108
+ (model?: string): CommandRunner =>
109
+ async (prompt) =>
110
+ spawnText('claude', model ? ['-p', '--model', model, prompt] : ['-p', prompt]);
111
+
112
+ /** `grok` CLI in non-interactive mode (best-effort flags). */
113
+ export const grokRunner =
114
+ (model?: string): CommandRunner =>
115
+ async (prompt) =>
116
+ spawnText('grok', model ? ['--model', model, '-p', prompt] : ['-p', prompt]);
117
+
118
+ /** Local Ollama HTTP API. */
119
+ export const ollamaRunner =
120
+ (model = 'llama3.1', host = 'http://localhost:11434'): CommandRunner =>
121
+ async (prompt) => {
122
+ const res = await fetch(`${host}/api/generate`, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({ model, prompt, stream: false }),
126
+ });
127
+ if (!res.ok) throw new Error(`ollama ${res.status}`);
128
+ const data = (await res.json()) as { response?: string };
129
+ return data.response ?? '';
130
+ };
@@ -0,0 +1,37 @@
1
+ import type { Signal, Turn } from '@unpolarize/code-sessions-schema';
2
+ import { deriveTags, guessTopic } from './heuristics';
3
+
4
+ export interface LabelRequest {
5
+ sessionId: string;
6
+ host: string;
7
+ turns: Turn[];
8
+ }
9
+
10
+ export interface LabelResult {
11
+ topic?: string;
12
+ tags: string[];
13
+ signals: Signal[];
14
+ summary?: string;
15
+ }
16
+
17
+ /** An insights provider turns a session into topic/tags/summary (+ optional signals). */
18
+ export interface Provider {
19
+ readonly name: string;
20
+ label(req: LabelRequest): Promise<LabelResult>;
21
+ }
22
+
23
+ /** No-LLM provider: deterministic topic/tags from the transcript. Always available. */
24
+ export class FakeProvider implements Provider {
25
+ readonly name = 'fake';
26
+ async label(req: LabelRequest): Promise<LabelResult> {
27
+ const result: LabelResult = {
28
+ tags: deriveTags(req.turns),
29
+ signals: [],
30
+ };
31
+ const topic = guessTopic(req.turns);
32
+ if (topic) result.topic = topic;
33
+ const assistantText = req.turns.find((t) => t.role === 'assistant')?.text;
34
+ if (assistantText) result.summary = assistantText.slice(0, 160);
35
+ return result;
36
+ }
37
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isSessionEndEvent, parseHookEvent } from './ipc';
3
+
4
+ describe('parseHookEvent', () => {
5
+ it('accepts snake_case payloads', () => {
6
+ const e = parseHookEvent({
7
+ event: 'PostToolUse',
8
+ session_id: 's',
9
+ transcript_path: '/t.jsonl',
10
+ cwd: '/p',
11
+ });
12
+ expect(e).toEqual({ event: 'PostToolUse', session_id: 's', transcript_path: '/t.jsonl', cwd: '/p' });
13
+ });
14
+
15
+ it('accepts Claude hook_event_name + sessionId aliases', () => {
16
+ const e = parseHookEvent({ hook_event_name: 'Stop', sessionId: 's', transcriptPath: '/t' });
17
+ expect(e?.event).toBe('Stop');
18
+ expect(e?.session_id).toBe('s');
19
+ expect(e?.transcript_path).toBe('/t');
20
+ });
21
+
22
+ it('rejects payloads missing event or session id', () => {
23
+ expect(parseHookEvent({ event: 'Stop' })).toBeNull();
24
+ expect(parseHookEvent({ session_id: 's' })).toBeNull();
25
+ expect(parseHookEvent(null)).toBeNull();
26
+ });
27
+ });
28
+
29
+ describe('isSessionEndEvent', () => {
30
+ it('flags lifecycle-end events', () => {
31
+ expect(isSessionEndEvent('Stop')).toBe(true);
32
+ expect(isSessionEndEvent('SubagentStop')).toBe(true);
33
+ expect(isSessionEndEvent('PostToolUse')).toBe(false);
34
+ });
35
+ });