@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/{chunk-ZJG2DWAK.js → chunk-HV6FQJPS.js} +301 -35
- package/dist/cli.js +17 -1
- package/dist/index.js +21 -1
- package/package.json +15 -5
- package/src/cli.ts +16 -0
- package/src/cliargs.ts +2 -0
- package/src/commands.ts +34 -2
- package/src/fork.test.ts +80 -0
- package/src/fork.ts +91 -0
- package/src/index.ts +2 -0
- package/src/index_store/db.ts +39 -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/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-
|
|
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.
|
|
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": [
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
|
209
|
-
|
|
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(
|
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';
|
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,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
|
-
|
|
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: {
|
|
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,
|
|
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
|
}
|
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
|
+
});
|