@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.
- package/dist/{chunk-ZJG2DWAK.js → chunk-ON3CPW4C.js} +529 -45
- package/dist/cli.js +21 -1
- package/dist/index.js +29 -1
- package/package.json +15 -5
- package/src/adapters/adapters.test.ts +36 -0
- package/src/adapters/codebuild.ts +188 -0
- package/src/adapters/index.ts +1 -0
- package/src/cli.ts +21 -1
- package/src/cliargs.ts +4 -1
- package/src/commands.ts +71 -3
- package/src/fork.test.ts +80 -0
- package/src/fork.ts +91 -0
- package/src/index.ts +2 -0
- package/src/index_store/db.test.ts +25 -0
- package/src/index_store/db.ts +111 -8
- package/src/index_store/sync.ts +6 -0
- package/src/insights/heuristics.test.ts +23 -1
- package/src/insights/heuristics.ts +48 -1
- package/src/insights/labeler.test.ts +4 -1
- package/src/insights/labeler.ts +18 -5
- package/src/insights/llm.test.ts +1 -1
- package/src/insights/llm.ts +13 -5
- package/src/insights/provider.ts +7 -2
- package/src/skills/index.ts +2 -0
- package/src/skills/install.ts +52 -0
- package/src/skills/skills.test.ts +42 -0
- package/src/skills/templates.ts +48 -0
package/src/fork.test.ts
ADDED
|
@@ -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 {
|
package/src/index_store/db.ts
CHANGED
|
@@ -21,7 +21,7 @@ const { DatabaseSync } = nodeRequire('node:sqlite') as {
|
|
|
21
21
|
* can share the model.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
const SCHEMA_VERSION =
|
|
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
|
-
|
|
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: {
|
|
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,
|
|
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;
|
package/src/index_store/sync.ts
CHANGED
|
@@ -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('
|
|
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
|
|
package/src/insights/labeler.ts
CHANGED
|
@@ -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,
|
|
104
|
+
updateEnvelopeLabels(sessionDir, { topic, intent, projects });
|
|
101
105
|
return insights;
|
|
102
106
|
}
|
|
103
107
|
|
|
104
|
-
function updateEnvelopeLabels(
|
|
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 = [
|
|
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
|
|
package/src/insights/llm.test.ts
CHANGED
|
@@ -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
|
|