@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.
@@ -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
+ }