@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.
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { SIGNAL_KINDS, type Signal } from '@unpolarize/code-sessions-schema';
2
+ import { INTENTS, SIGNAL_KINDS, type Signal } from '@unpolarize/code-sessions-schema';
3
3
  import type { LabelRequest, LabelResult, Provider } from './provider';
4
4
 
5
5
  /** Runs a prompt against some agent CLI/API and returns its raw text output. */
@@ -27,10 +27,12 @@ export function buildPrompt(req: LabelRequest): string {
27
27
 
28
28
  return [
29
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 ' +
30
+ '{"topic": string, "intent": one of ' +
31
+ INTENTS.join('|') +
32
+ ', "tags": string[], "projects": string[], "summary": string, "signals": [{"kind": one of ' +
31
33
  SIGNAL_KINDS.join('|') +
32
34
  ', "severity": "info"|"warn"|"critical", "note": string}]}',
33
- 'topic: 3-6 words. tags: tools/themes. summary: <=1 sentence. signals: only notable ones.',
35
+ 'topic: 3-6 words. intent: what the user wanted. tags: tools/themes. projects: repo/dir names touched. summary: <=1 sentence. signals: only notable ones.',
34
36
  '',
35
37
  'Transcript:',
36
38
  transcript,
@@ -42,19 +44,25 @@ const KIND_SET = new Set<string>(SIGNAL_KINDS);
42
44
  export function parseLabelJson(out: string): LabelResult {
43
45
  const start = out.indexOf('{');
44
46
  const end = out.lastIndexOf('}');
45
- if (start < 0 || end <= start) return { tags: [], signals: [] };
47
+ if (start < 0 || end <= start) return { tags: [], projects: [], signals: [] };
46
48
  let obj: Record<string, unknown>;
47
49
  try {
48
50
  obj = JSON.parse(out.slice(start, end + 1)) as Record<string, unknown>;
49
51
  } catch {
50
- return { tags: [], signals: [] };
52
+ return { tags: [], projects: [], signals: [] };
51
53
  }
52
54
  const result: LabelResult = {
53
55
  tags: Array.isArray(obj.tags) ? obj.tags.filter((t): t is string => typeof t === 'string') : [],
56
+ projects: Array.isArray(obj.projects)
57
+ ? obj.projects.filter((t): t is string => typeof t === 'string')
58
+ : [],
54
59
  signals: coerceSignals(obj.signals),
55
60
  };
56
61
  if (typeof obj.topic === 'string') result.topic = obj.topic;
57
62
  if (typeof obj.summary === 'string') result.summary = obj.summary;
63
+ if (typeof obj.intent === 'string' && (INTENTS as readonly string[]).includes(obj.intent)) {
64
+ result.intent = obj.intent as LabelResult['intent'];
65
+ }
58
66
  return result;
59
67
  }
60
68
 
@@ -1,5 +1,5 @@
1
- import type { Signal, Turn } from '@unpolarize/code-sessions-schema';
2
- import { deriveTags, guessTopic } from './heuristics';
1
+ import type { Intent, Signal, Turn } from '@unpolarize/code-sessions-schema';
2
+ import { deriveIntent, deriveProjects, deriveTags, guessTopic } from './heuristics';
3
3
 
4
4
  export interface LabelRequest {
5
5
  sessionId: string;
@@ -9,7 +9,9 @@ export interface LabelRequest {
9
9
 
10
10
  export interface LabelResult {
11
11
  topic?: string;
12
+ intent?: Intent;
12
13
  tags: string[];
14
+ projects: string[];
13
15
  signals: Signal[];
14
16
  summary?: string;
15
17
  }
@@ -26,10 +28,13 @@ export class FakeProvider implements Provider {
26
28
  async label(req: LabelRequest): Promise<LabelResult> {
27
29
  const result: LabelResult = {
28
30
  tags: deriveTags(req.turns),
31
+ projects: deriveProjects(req.turns),
29
32
  signals: [],
30
33
  };
31
34
  const topic = guessTopic(req.turns);
32
35
  if (topic) result.topic = topic;
36
+ const intent = deriveIntent(req.turns);
37
+ if (intent) result.intent = intent;
33
38
  const assistantText = req.turns.find((t) => t.role === 'assistant')?.text;
34
39
  if (assistantText) result.summary = assistantText.slice(0, 160);
35
40
  return result;
@@ -0,0 +1,2 @@
1
+ export * from './templates';
2
+ export * from './install';
@@ -0,0 +1,52 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { buildClaudeSkill, buildPromptFile } from './templates';
5
+
6
+ export type SkillAgent = 'claude' | 'codex' | 'grok' | 'all';
7
+
8
+ export interface SkillInstallResult {
9
+ installed: string[];
10
+ }
11
+
12
+ /** Resolve where each agent expects user skills/prompts. */
13
+ function targetsFor(agent: SkillAgent, home: string): { agent: string; path: string; content: string }[] {
14
+ const out: { agent: string; path: string; content: string }[] = [];
15
+ const want = (a: string) => agent === 'all' || agent === a;
16
+ if (want('claude')) {
17
+ out.push({
18
+ agent: 'claude',
19
+ path: join(home, '.claude', 'skills', 'cs-label-session', 'SKILL.md'),
20
+ content: buildClaudeSkill(),
21
+ });
22
+ }
23
+ if (want('codex')) {
24
+ out.push({
25
+ agent: 'codex',
26
+ path: join(home, '.codex', 'prompts', 'cs-label-session.md'),
27
+ content: buildPromptFile(),
28
+ });
29
+ }
30
+ if (want('grok')) {
31
+ out.push({
32
+ agent: 'grok',
33
+ path: join(home, '.grok', 'prompts', 'cs-label-session.md'),
34
+ content: buildPromptFile(),
35
+ });
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /** Install the CS labeling skill into the requested agents' skill/prompt dirs. */
41
+ export function installSkills(
42
+ opts: { agent?: SkillAgent; home?: string } = {},
43
+ ): SkillInstallResult {
44
+ const home = opts.home ?? homedir();
45
+ const installed: string[] = [];
46
+ for (const t of targetsFor(opts.agent ?? 'all', home)) {
47
+ mkdirSync(dirname(t.path), { recursive: true });
48
+ writeFileSync(t.path, t.content);
49
+ installed.push(t.path);
50
+ }
51
+ return { installed };
52
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { withTempDir } from '../test/tmp';
5
+ import { buildClaudeSkill, buildLabelSkillBody } from './templates';
6
+ import { installSkills } from './install';
7
+
8
+ describe('skill templates', () => {
9
+ it('embeds the intent + signal taxonomy', () => {
10
+ const body = buildLabelSkillBody();
11
+ expect(body).toContain('feature | bugfix | refactor');
12
+ expect(body).toContain('stuck-loop');
13
+ expect(body).toContain('"projects"');
14
+ });
15
+
16
+ it('claude skill carries frontmatter', () => {
17
+ const s = buildClaudeSkill();
18
+ expect(s).toMatch(/^---\nname: cs-label-session/);
19
+ });
20
+ });
21
+
22
+ describe('installSkills', () => {
23
+ it('installs into all agents under the given home', () => {
24
+ withTempDir((home) => {
25
+ const res = installSkills({ agent: 'all', home });
26
+ expect(res.installed).toHaveLength(3);
27
+ expect(existsSync(join(home, '.claude', 'skills', 'cs-label-session', 'SKILL.md'))).toBe(true);
28
+ expect(existsSync(join(home, '.codex', 'prompts', 'cs-label-session.md'))).toBe(true);
29
+ expect(existsSync(join(home, '.grok', 'prompts', 'cs-label-session.md'))).toBe(true);
30
+ const claude = readFileSync(join(home, '.claude', 'skills', 'cs-label-session', 'SKILL.md'), 'utf8');
31
+ expect(claude).toContain('intent');
32
+ });
33
+ });
34
+
35
+ it('can target a single agent', () => {
36
+ withTempDir((home) => {
37
+ const res = installSkills({ agent: 'claude', home });
38
+ expect(res.installed).toHaveLength(1);
39
+ expect(res.installed[0]).toContain('.claude');
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,48 @@
1
+ import { INTENTS, SIGNAL_KINDS } from '@unpolarize/code-sessions-schema';
2
+
3
+ /**
4
+ * The canonical "label a session" skill, generated from the CS labels taxonomy
5
+ * so it never drifts from the schema. Installed into each agent so it (or the
6
+ * daemon's configured provider) can label topics, intent, projects-touched, and
7
+ * signals, emitting the exact JSON the CS insights pipeline ingests.
8
+ */
9
+ export function buildLabelSkillBody(): string {
10
+ return `You are labeling a coding-agent session for the code-sessions (CS) store.
11
+
12
+ Read the provided session transcript and emit ONLY a single JSON object — no prose, no code fence:
13
+
14
+ \`\`\`json
15
+ {
16
+ "topic": "3-6 word summary of what the session was about",
17
+ "intent": "one of: ${INTENTS.join(' | ')}",
18
+ "tags": ["short", "themes", "or", "tool", "names"],
19
+ "projects": ["repo-or-dir-names-touched"],
20
+ "signals": [
21
+ { "kind": "one of: ${SIGNAL_KINDS.join(' | ')}", "severity": "info|warn|critical", "note": "why" }
22
+ ],
23
+ "summary": "one sentence"
24
+ }
25
+ \`\`\`
26
+
27
+ Guidance:
28
+ - **intent** = what the user wanted (a feature, a bug fixed, a refactor, research, docs, ops, a review, a chore).
29
+ - **projects** = the projects/repos the session actually edited (from file paths it wrote to), not everything mentioned.
30
+ - **signals** = only notable ones: stuck loops, error-recovery, unusually high cost, very long sessions, tool-heavy stretches, strong negative/positive affect.
31
+ - Keep it terse and machine-parseable. Output the JSON and nothing else.`;
32
+ }
33
+
34
+ /** Claude SKILL.md with frontmatter. */
35
+ export function buildClaudeSkill(): string {
36
+ return `---
37
+ name: cs-label-session
38
+ description: Label a coding session (topic, intent, tags, projects touched, signals) as JSON for the code-sessions store. Use when asked to classify or summarize a session.
39
+ ---
40
+
41
+ ${buildLabelSkillBody()}
42
+ `;
43
+ }
44
+
45
+ /** Codex / Grok plain prompt file (no frontmatter). */
46
+ export function buildPromptFile(): string {
47
+ return `# cs-label-session\n\n${buildLabelSkillBody()}\n`;
48
+ }