@unpolarize/code-sessions-schema 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.
- package/dist/index.d.ts +625 -0
- package/dist/index.js +277 -0
- package/package.json +26 -0
- package/src/index.ts +3 -0
- package/src/normalize.test.ts +122 -0
- package/src/normalize.ts +215 -0
- package/src/schemas.test.ts +120 -0
- package/src/schemas.ts +148 -0
- package/src/validators.ts +17 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/schemas.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var AGENTS = ["claude-code", "codex", "grok", "unknown"];
|
|
4
|
+
var ROLES = ["user", "assistant", "tool", "system"];
|
|
5
|
+
var UsageSchema = z.object({
|
|
6
|
+
input_tokens: z.number().int().nonnegative().default(0),
|
|
7
|
+
output_tokens: z.number().int().nonnegative().default(0),
|
|
8
|
+
cache_read_tokens: z.number().int().nonnegative().default(0),
|
|
9
|
+
cache_write_tokens: z.number().int().nonnegative().default(0)
|
|
10
|
+
}).strict();
|
|
11
|
+
var ToolCallSchema = z.object({
|
|
12
|
+
name: z.string(),
|
|
13
|
+
input: z.unknown().optional(),
|
|
14
|
+
id: z.string().optional()
|
|
15
|
+
}).strict();
|
|
16
|
+
var TelemetrySchema = z.object({
|
|
17
|
+
latency_ms: z.number().nonnegative().optional(),
|
|
18
|
+
cost_usd: z.number().nonnegative().optional()
|
|
19
|
+
}).strict();
|
|
20
|
+
var TurnSchema = z.object({
|
|
21
|
+
schema: z.literal("session-store/turn@1"),
|
|
22
|
+
session_id: z.string().min(1),
|
|
23
|
+
host: z.string().min(1),
|
|
24
|
+
agent: z.enum(AGENTS),
|
|
25
|
+
turn_index: z.number().int().nonnegative(),
|
|
26
|
+
ts: z.string().min(1),
|
|
27
|
+
role: z.enum(ROLES),
|
|
28
|
+
text: z.string().default(""),
|
|
29
|
+
tool_calls: z.array(ToolCallSchema).default([]),
|
|
30
|
+
usage: UsageSchema.default({}),
|
|
31
|
+
telemetry: TelemetrySchema.optional(),
|
|
32
|
+
/** true when secret-scrubbing redacted content in this turn */
|
|
33
|
+
scrubbed: z.boolean().default(false),
|
|
34
|
+
/** sha256 pointer when a large tool output was externalized to raw/ */
|
|
35
|
+
raw_ref: z.string().nullable().default(null),
|
|
36
|
+
/** verbatim native event for lossless tier-1 resume */
|
|
37
|
+
raw: z.unknown().optional()
|
|
38
|
+
}).strict();
|
|
39
|
+
var TotalsSchema = z.object({
|
|
40
|
+
input_tokens: z.number().int().nonnegative().default(0),
|
|
41
|
+
output_tokens: z.number().int().nonnegative().default(0),
|
|
42
|
+
cost_usd: z.number().nonnegative().default(0)
|
|
43
|
+
}).strict();
|
|
44
|
+
var NativeRefSchema = z.object({
|
|
45
|
+
format: z.string(),
|
|
46
|
+
uuid: z.string()
|
|
47
|
+
}).strict();
|
|
48
|
+
var SessionSchema = z.object({
|
|
49
|
+
schema: z.literal("session-store/session@1"),
|
|
50
|
+
session_id: z.string().min(1),
|
|
51
|
+
host: z.string().min(1),
|
|
52
|
+
agent: z.enum(AGENTS),
|
|
53
|
+
project_path: z.string().default(""),
|
|
54
|
+
git_branch: z.string().optional(),
|
|
55
|
+
model: z.string().optional(),
|
|
56
|
+
started_at: z.string().optional(),
|
|
57
|
+
ended_at: z.string().optional(),
|
|
58
|
+
turn_count: z.number().int().nonnegative().default(0),
|
|
59
|
+
tool_call_count: z.number().int().nonnegative().default(0),
|
|
60
|
+
totals: TotalsSchema.default({}),
|
|
61
|
+
title: z.string().optional(),
|
|
62
|
+
labels: z.array(z.string()).default([]),
|
|
63
|
+
native_ref: NativeRefSchema
|
|
64
|
+
}).strict();
|
|
65
|
+
var SIGNAL_KINDS = [
|
|
66
|
+
"stuck-loop",
|
|
67
|
+
"error-recovery",
|
|
68
|
+
"high-cost-turn",
|
|
69
|
+
"long-session",
|
|
70
|
+
"affect-negative",
|
|
71
|
+
"affect-positive",
|
|
72
|
+
"tool-heavy",
|
|
73
|
+
"other"
|
|
74
|
+
];
|
|
75
|
+
var SignalSchema = z.object({
|
|
76
|
+
kind: z.enum(SIGNAL_KINDS),
|
|
77
|
+
severity: z.enum(["info", "warn", "critical"]).default("info"),
|
|
78
|
+
turn_index: z.number().int().nonnegative().optional(),
|
|
79
|
+
note: z.string().optional()
|
|
80
|
+
}).strict();
|
|
81
|
+
var InsightsSchema = z.object({
|
|
82
|
+
schema: z.literal("session-store/insights@1"),
|
|
83
|
+
session_id: z.string().min(1),
|
|
84
|
+
host: z.string().min(1),
|
|
85
|
+
generated_at: z.string(),
|
|
86
|
+
provider: z.string(),
|
|
87
|
+
topic: z.string().optional(),
|
|
88
|
+
tags: z.array(z.string()).default([]),
|
|
89
|
+
signals: z.array(SignalSchema).default([]),
|
|
90
|
+
summary: z.string().optional()
|
|
91
|
+
}).strict();
|
|
92
|
+
var SCHEMA_VERSIONS = {
|
|
93
|
+
turn: "session-store/turn@1",
|
|
94
|
+
session: "session-store/session@1",
|
|
95
|
+
insights: "session-store/insights@1"
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/normalize.ts
|
|
99
|
+
var ZERO_USAGE = {
|
|
100
|
+
input_tokens: 0,
|
|
101
|
+
output_tokens: 0,
|
|
102
|
+
cache_read_tokens: 0,
|
|
103
|
+
cache_write_tokens: 0
|
|
104
|
+
};
|
|
105
|
+
function mapUsage(u) {
|
|
106
|
+
if (!u || typeof u !== "object") return { ...ZERO_USAGE };
|
|
107
|
+
const num = (v) => typeof v === "number" && v >= 0 ? Math.floor(v) : 0;
|
|
108
|
+
return {
|
|
109
|
+
input_tokens: num(u.input_tokens),
|
|
110
|
+
output_tokens: num(u.output_tokens),
|
|
111
|
+
cache_read_tokens: num(u.cache_read_input_tokens ?? u.cache_read_tokens),
|
|
112
|
+
cache_write_tokens: num(u.cache_creation_input_tokens ?? u.cache_write_tokens)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function blocksToText(content) {
|
|
116
|
+
if (typeof content === "string") return content;
|
|
117
|
+
if (!Array.isArray(content)) return "";
|
|
118
|
+
const parts = [];
|
|
119
|
+
for (const block of content) {
|
|
120
|
+
if (typeof block === "string") {
|
|
121
|
+
parts.push(block);
|
|
122
|
+
} else if (block && typeof block === "object") {
|
|
123
|
+
const b = block;
|
|
124
|
+
if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
|
|
125
|
+
else if (b.type === "tool_result") parts.push(toolResultText(b.content));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return parts.join("\n");
|
|
129
|
+
}
|
|
130
|
+
function toolResultText(content) {
|
|
131
|
+
if (typeof content === "string") return content;
|
|
132
|
+
if (Array.isArray(content)) {
|
|
133
|
+
return content.map(
|
|
134
|
+
(c) => c && typeof c === "object" && typeof c.text === "string" ? c.text : typeof c === "string" ? c : JSON.stringify(c)
|
|
135
|
+
).join("\n");
|
|
136
|
+
}
|
|
137
|
+
return content == null ? "" : JSON.stringify(content);
|
|
138
|
+
}
|
|
139
|
+
function extractToolCalls(content) {
|
|
140
|
+
if (!Array.isArray(content)) return [];
|
|
141
|
+
const calls = [];
|
|
142
|
+
for (const block of content) {
|
|
143
|
+
if (block && typeof block === "object" && block.type === "tool_use") {
|
|
144
|
+
const b = block;
|
|
145
|
+
const call = { name: typeof b.name === "string" ? b.name : "unknown" };
|
|
146
|
+
if (b.input !== void 0) call.input = b.input;
|
|
147
|
+
if (typeof b.id === "string") call.id = b.id;
|
|
148
|
+
calls.push(call);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return calls;
|
|
152
|
+
}
|
|
153
|
+
function hasToolResult(content) {
|
|
154
|
+
return Array.isArray(content) && content.some((b) => b && typeof b === "object" && b.type === "tool_result");
|
|
155
|
+
}
|
|
156
|
+
function normalizeClaudeEvent(raw, fallbackTs = "") {
|
|
157
|
+
if (!raw || typeof raw !== "object") return null;
|
|
158
|
+
const ev = raw;
|
|
159
|
+
const ts = typeof ev.timestamp === "string" ? ev.timestamp : fallbackTs;
|
|
160
|
+
const message = ev.message && typeof ev.message === "object" ? ev.message : void 0;
|
|
161
|
+
if (ev.type === "assistant" && message) {
|
|
162
|
+
return {
|
|
163
|
+
ts,
|
|
164
|
+
role: "assistant",
|
|
165
|
+
text: blocksToText(message.content),
|
|
166
|
+
tool_calls: extractToolCalls(message.content),
|
|
167
|
+
usage: mapUsage(message.usage),
|
|
168
|
+
raw
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (ev.type === "user" && message) {
|
|
172
|
+
const isTool = hasToolResult(message.content);
|
|
173
|
+
return {
|
|
174
|
+
ts,
|
|
175
|
+
role: isTool ? "tool" : "user",
|
|
176
|
+
text: blocksToText(message.content),
|
|
177
|
+
tool_calls: [],
|
|
178
|
+
usage: { ...ZERO_USAGE },
|
|
179
|
+
raw
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
function buildTurn(ev, ctx) {
|
|
185
|
+
return {
|
|
186
|
+
schema: SCHEMA_VERSIONS.turn,
|
|
187
|
+
session_id: ctx.session_id,
|
|
188
|
+
host: ctx.host,
|
|
189
|
+
agent: ctx.agent,
|
|
190
|
+
turn_index: ctx.turn_index,
|
|
191
|
+
ts: ev.ts,
|
|
192
|
+
role: ev.role,
|
|
193
|
+
text: ev.text,
|
|
194
|
+
tool_calls: ev.tool_calls,
|
|
195
|
+
usage: ev.usage,
|
|
196
|
+
scrubbed: false,
|
|
197
|
+
raw_ref: null,
|
|
198
|
+
raw: ev.raw
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function extractClaudeSessionMeta(rawLines) {
|
|
202
|
+
const meta = {};
|
|
203
|
+
for (const raw of rawLines) {
|
|
204
|
+
if (!raw || typeof raw !== "object") continue;
|
|
205
|
+
const ev = raw;
|
|
206
|
+
if (typeof ev.sessionId === "string" && !meta.session_id) meta.session_id = ev.sessionId;
|
|
207
|
+
if (typeof ev.cwd === "string") meta.project_path = ev.cwd;
|
|
208
|
+
if (typeof ev.gitBranch === "string") meta.git_branch = ev.gitBranch;
|
|
209
|
+
if (ev.message && typeof ev.message.model === "string") meta.model = ev.message.model;
|
|
210
|
+
if (ev.type === "ai-title") {
|
|
211
|
+
const t = ev.title ?? ev.message ?? ev.content;
|
|
212
|
+
if (typeof t === "string") meta.title = t;
|
|
213
|
+
}
|
|
214
|
+
if (typeof ev.timestamp === "string") {
|
|
215
|
+
if (!meta.started_at) meta.started_at = ev.timestamp;
|
|
216
|
+
meta.ended_at = ev.timestamp;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return meta;
|
|
220
|
+
}
|
|
221
|
+
function normalizeClaudeLines(rawLines, ctx) {
|
|
222
|
+
let idx = ctx.startIndex ?? 0;
|
|
223
|
+
const turns = [];
|
|
224
|
+
for (const raw of rawLines) {
|
|
225
|
+
const norm = normalizeClaudeEvent(raw);
|
|
226
|
+
if (!norm) continue;
|
|
227
|
+
turns.push(
|
|
228
|
+
buildTurn(norm, {
|
|
229
|
+
session_id: ctx.session_id,
|
|
230
|
+
host: ctx.host,
|
|
231
|
+
agent: ctx.agent,
|
|
232
|
+
turn_index: idx++
|
|
233
|
+
})
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
return { turns, meta: extractClaudeSessionMeta(rawLines) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/validators.ts
|
|
240
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
241
|
+
var turnJsonSchema = zodToJsonSchema(TurnSchema, "Turn");
|
|
242
|
+
var sessionJsonSchema = zodToJsonSchema(SessionSchema, "Session");
|
|
243
|
+
var insightsJsonSchema = zodToJsonSchema(InsightsSchema, "Insights");
|
|
244
|
+
var parseTurn = (data) => TurnSchema.parse(data);
|
|
245
|
+
var parseSession = (data) => SessionSchema.parse(data);
|
|
246
|
+
var parseInsights = (data) => InsightsSchema.parse(data);
|
|
247
|
+
var safeParseTurn = (data) => TurnSchema.safeParse(data);
|
|
248
|
+
var safeParseSession = (data) => SessionSchema.safeParse(data);
|
|
249
|
+
var safeParseInsights = (data) => InsightsSchema.safeParse(data);
|
|
250
|
+
export {
|
|
251
|
+
AGENTS,
|
|
252
|
+
InsightsSchema,
|
|
253
|
+
NativeRefSchema,
|
|
254
|
+
ROLES,
|
|
255
|
+
SCHEMA_VERSIONS,
|
|
256
|
+
SIGNAL_KINDS,
|
|
257
|
+
SessionSchema,
|
|
258
|
+
SignalSchema,
|
|
259
|
+
TelemetrySchema,
|
|
260
|
+
ToolCallSchema,
|
|
261
|
+
TotalsSchema,
|
|
262
|
+
TurnSchema,
|
|
263
|
+
UsageSchema,
|
|
264
|
+
buildTurn,
|
|
265
|
+
extractClaudeSessionMeta,
|
|
266
|
+
insightsJsonSchema,
|
|
267
|
+
normalizeClaudeEvent,
|
|
268
|
+
normalizeClaudeLines,
|
|
269
|
+
parseInsights,
|
|
270
|
+
parseSession,
|
|
271
|
+
parseTurn,
|
|
272
|
+
safeParseInsights,
|
|
273
|
+
safeParseSession,
|
|
274
|
+
safeParseTurn,
|
|
275
|
+
sessionJsonSchema,
|
|
276
|
+
turnJsonSchema
|
|
277
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unpolarize/code-sessions-schema",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Canonical wire schema for cross-agent session records (turns, envelopes, insights)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": ["dist", "src"],
|
|
17
|
+
"publishConfig": { "access": "public" },
|
|
18
|
+
"repository": { "type": "git", "url": "git+https://github.com/unpolarize/code-sessions.git", "directory": "packages/schema" },
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/index.ts --format esm --dts --clean --out-dir dist"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"zod": "^3.23.8",
|
|
24
|
+
"zod-to-json-schema": "^3.23.5"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
buildTurn,
|
|
6
|
+
extractClaudeSessionMeta,
|
|
7
|
+
normalizeClaudeEvent,
|
|
8
|
+
normalizeClaudeLines,
|
|
9
|
+
} from './normalize';
|
|
10
|
+
import { parseTurn } from './validators';
|
|
11
|
+
|
|
12
|
+
const fixturePath = fileURLToPath(
|
|
13
|
+
new URL('../test/fixtures/claude-session.jsonl', import.meta.url),
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
function loadFixtureLines(): unknown[] {
|
|
17
|
+
return readFileSync(fixturePath, 'utf8')
|
|
18
|
+
.split('\n')
|
|
19
|
+
.filter((l) => l.trim().length > 0)
|
|
20
|
+
.map((l) => JSON.parse(l));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('normalizeClaudeEvent', () => {
|
|
24
|
+
it('maps an assistant line to an assistant turn with tool calls and usage', () => {
|
|
25
|
+
const lines = loadFixtureLines();
|
|
26
|
+
const assistant = lines[2];
|
|
27
|
+
const ev = normalizeClaudeEvent(assistant);
|
|
28
|
+
expect(ev).not.toBeNull();
|
|
29
|
+
expect(ev!.role).toBe('assistant');
|
|
30
|
+
expect(ev!.text).toContain("I'll read the file.");
|
|
31
|
+
expect(ev!.tool_calls).toHaveLength(1);
|
|
32
|
+
expect(ev!.tool_calls[0]).toMatchObject({ name: 'Read', id: 'tu_1' });
|
|
33
|
+
expect(ev!.usage).toEqual({
|
|
34
|
+
input_tokens: 1200,
|
|
35
|
+
output_tokens: 45,
|
|
36
|
+
cache_read_tokens: 8000,
|
|
37
|
+
cache_write_tokens: 100,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('maps a tool_result user line to a tool turn', () => {
|
|
42
|
+
const lines = loadFixtureLines();
|
|
43
|
+
const ev = normalizeClaudeEvent(lines[3]);
|
|
44
|
+
expect(ev!.role).toBe('tool');
|
|
45
|
+
expect(ev!.text).toContain('export const foo = 1;');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('maps a plain user line to a user turn', () => {
|
|
49
|
+
const lines = loadFixtureLines();
|
|
50
|
+
const ev = normalizeClaudeEvent(lines[0]);
|
|
51
|
+
expect(ev!.role).toBe('user');
|
|
52
|
+
expect(ev!.text).toBe('Fix the bug in foo.ts');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null for metadata lines (system, ai-title)', () => {
|
|
56
|
+
const lines = loadFixtureLines();
|
|
57
|
+
expect(normalizeClaudeEvent(lines[1])).toBeNull(); // system
|
|
58
|
+
expect(normalizeClaudeEvent(lines[4])).toBeNull(); // ai-title
|
|
59
|
+
expect(normalizeClaudeEvent(null)).toBeNull();
|
|
60
|
+
expect(normalizeClaudeEvent({ type: 'permission-mode' })).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('normalizeClaudeLines', () => {
|
|
65
|
+
it('produces conversational turns with sequential indices', () => {
|
|
66
|
+
const lines = loadFixtureLines();
|
|
67
|
+
const { turns } = normalizeClaudeLines(lines, {
|
|
68
|
+
session_id: 'sess-abc',
|
|
69
|
+
host: 'test-host',
|
|
70
|
+
agent: 'claude-code',
|
|
71
|
+
});
|
|
72
|
+
// 1 user + 1 assistant + 1 tool + 1 assistant = 4 (system + ai-title dropped)
|
|
73
|
+
expect(turns.map((t) => t.role)).toEqual(['user', 'assistant', 'tool', 'assistant']);
|
|
74
|
+
expect(turns.map((t) => t.turn_index)).toEqual([0, 1, 2, 3]);
|
|
75
|
+
for (const t of turns) {
|
|
76
|
+
expect(() => parseTurn(t)).not.toThrow();
|
|
77
|
+
expect(t.session_id).toBe('sess-abc');
|
|
78
|
+
expect(t.host).toBe('test-host');
|
|
79
|
+
expect(t.agent).toBe('claude-code');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('respects a startIndex for incremental capture', () => {
|
|
84
|
+
const lines = loadFixtureLines();
|
|
85
|
+
const { turns } = normalizeClaudeLines(lines.slice(5), {
|
|
86
|
+
session_id: 'sess-abc',
|
|
87
|
+
host: 'test-host',
|
|
88
|
+
agent: 'claude-code',
|
|
89
|
+
startIndex: 7,
|
|
90
|
+
});
|
|
91
|
+
expect(turns).toHaveLength(1);
|
|
92
|
+
expect(turns[0]!.turn_index).toBe(7);
|
|
93
|
+
expect(turns[0]!.text).toBe('Fixed it.');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('extractClaudeSessionMeta', () => {
|
|
98
|
+
it('pulls envelope metadata from a batch of lines', () => {
|
|
99
|
+
const meta = extractClaudeSessionMeta(loadFixtureLines());
|
|
100
|
+
expect(meta.session_id).toBe('sess-abc');
|
|
101
|
+
expect(meta.project_path).toBe('/Users/z/proj');
|
|
102
|
+
expect(meta.git_branch).toBe('main');
|
|
103
|
+
expect(meta.model).toBe('claude-opus-4-8');
|
|
104
|
+
expect(meta.title).toBe('Fix bug in foo.ts');
|
|
105
|
+
expect(meta.started_at).toBe('2026-06-20T08:00:00Z');
|
|
106
|
+
expect(meta.ended_at).toBe('2026-06-20T08:00:10Z');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('buildTurn', () => {
|
|
111
|
+
it('assembles a schema-valid turn from a normalized event', () => {
|
|
112
|
+
const ev = normalizeClaudeEvent(loadFixtureLines()[0])!;
|
|
113
|
+
const turn = buildTurn(ev, {
|
|
114
|
+
session_id: 's',
|
|
115
|
+
host: 'h',
|
|
116
|
+
agent: 'claude-code',
|
|
117
|
+
turn_index: 0,
|
|
118
|
+
});
|
|
119
|
+
expect(turn.schema).toBe('session-store/turn@1');
|
|
120
|
+
expect(() => parseTurn(turn)).not.toThrow();
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCHEMA_VERSIONS,
|
|
3
|
+
type AgentKind,
|
|
4
|
+
type Role,
|
|
5
|
+
type ToolCall,
|
|
6
|
+
type Turn,
|
|
7
|
+
type Usage,
|
|
8
|
+
} from './schemas';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Adapter: Claude Code JSONL events -> canonical turn records.
|
|
12
|
+
*
|
|
13
|
+
* Claude writes one JSON object per line. The conversational lines are `user`
|
|
14
|
+
* and `assistant`; everything else (`ai-title`, `system`, `permission-mode`,
|
|
15
|
+
* `queue-operation`, `attachment`, `last-prompt`, `summary`) is metadata and
|
|
16
|
+
* does not become a turn.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Native Claude events are loosely typed; we narrow defensively.
|
|
20
|
+
type Raw = Record<string, any>;
|
|
21
|
+
|
|
22
|
+
export interface NormalizedEvent {
|
|
23
|
+
ts: string;
|
|
24
|
+
role: Role;
|
|
25
|
+
text: string;
|
|
26
|
+
tool_calls: ToolCall[];
|
|
27
|
+
usage: Usage;
|
|
28
|
+
raw: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface BuildTurnContext {
|
|
32
|
+
session_id: string;
|
|
33
|
+
host: string;
|
|
34
|
+
agent: AgentKind;
|
|
35
|
+
turn_index: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ZERO_USAGE: Usage = {
|
|
39
|
+
input_tokens: 0,
|
|
40
|
+
output_tokens: 0,
|
|
41
|
+
cache_read_tokens: 0,
|
|
42
|
+
cache_write_tokens: 0,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function mapUsage(u: Raw | undefined): Usage {
|
|
46
|
+
if (!u || typeof u !== 'object') return { ...ZERO_USAGE };
|
|
47
|
+
const num = (v: unknown): number => (typeof v === 'number' && v >= 0 ? Math.floor(v) : 0);
|
|
48
|
+
return {
|
|
49
|
+
input_tokens: num(u.input_tokens),
|
|
50
|
+
output_tokens: num(u.output_tokens),
|
|
51
|
+
cache_read_tokens: num(u.cache_read_input_tokens ?? u.cache_read_tokens),
|
|
52
|
+
cache_write_tokens: num(u.cache_creation_input_tokens ?? u.cache_write_tokens),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function blocksToText(content: unknown): string {
|
|
57
|
+
if (typeof content === 'string') return content;
|
|
58
|
+
if (!Array.isArray(content)) return '';
|
|
59
|
+
const parts: string[] = [];
|
|
60
|
+
for (const block of content) {
|
|
61
|
+
if (typeof block === 'string') {
|
|
62
|
+
parts.push(block);
|
|
63
|
+
} else if (block && typeof block === 'object') {
|
|
64
|
+
const b = block as Raw;
|
|
65
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
66
|
+
else if (b.type === 'tool_result') parts.push(toolResultText(b.content));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return parts.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toolResultText(content: unknown): string {
|
|
73
|
+
if (typeof content === 'string') return content;
|
|
74
|
+
if (Array.isArray(content)) {
|
|
75
|
+
return content
|
|
76
|
+
.map((c) =>
|
|
77
|
+
c && typeof c === 'object' && typeof (c as Raw).text === 'string'
|
|
78
|
+
? (c as Raw).text
|
|
79
|
+
: typeof c === 'string'
|
|
80
|
+
? c
|
|
81
|
+
: JSON.stringify(c),
|
|
82
|
+
)
|
|
83
|
+
.join('\n');
|
|
84
|
+
}
|
|
85
|
+
return content == null ? '' : JSON.stringify(content);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractToolCalls(content: unknown): ToolCall[] {
|
|
89
|
+
if (!Array.isArray(content)) return [];
|
|
90
|
+
const calls: ToolCall[] = [];
|
|
91
|
+
for (const block of content) {
|
|
92
|
+
if (block && typeof block === 'object' && (block as Raw).type === 'tool_use') {
|
|
93
|
+
const b = block as Raw;
|
|
94
|
+
const call: ToolCall = { name: typeof b.name === 'string' ? b.name : 'unknown' };
|
|
95
|
+
if (b.input !== undefined) call.input = b.input;
|
|
96
|
+
if (typeof b.id === 'string') call.id = b.id;
|
|
97
|
+
calls.push(call);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return calls;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasToolResult(content: unknown): boolean {
|
|
104
|
+
return (
|
|
105
|
+
Array.isArray(content) &&
|
|
106
|
+
content.some((b) => b && typeof b === 'object' && (b as Raw).type === 'tool_result')
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Map a single native Claude line into a normalized event, or null if it is metadata. */
|
|
111
|
+
export function normalizeClaudeEvent(raw: unknown, fallbackTs = ''): NormalizedEvent | null {
|
|
112
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
113
|
+
const ev = raw as Raw;
|
|
114
|
+
const ts: string = typeof ev.timestamp === 'string' ? ev.timestamp : fallbackTs;
|
|
115
|
+
const message: Raw | undefined =
|
|
116
|
+
ev.message && typeof ev.message === 'object' ? ev.message : undefined;
|
|
117
|
+
|
|
118
|
+
if (ev.type === 'assistant' && message) {
|
|
119
|
+
return {
|
|
120
|
+
ts,
|
|
121
|
+
role: 'assistant',
|
|
122
|
+
text: blocksToText(message.content),
|
|
123
|
+
tool_calls: extractToolCalls(message.content),
|
|
124
|
+
usage: mapUsage(message.usage),
|
|
125
|
+
raw,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (ev.type === 'user' && message) {
|
|
130
|
+
const isTool = hasToolResult(message.content);
|
|
131
|
+
return {
|
|
132
|
+
ts,
|
|
133
|
+
role: isTool ? 'tool' : 'user',
|
|
134
|
+
text: blocksToText(message.content),
|
|
135
|
+
tool_calls: [],
|
|
136
|
+
usage: { ...ZERO_USAGE },
|
|
137
|
+
raw,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Assemble a complete, schema-valid Turn from a normalized event + identity context. */
|
|
145
|
+
export function buildTurn(ev: NormalizedEvent, ctx: BuildTurnContext): Turn {
|
|
146
|
+
return {
|
|
147
|
+
schema: SCHEMA_VERSIONS.turn,
|
|
148
|
+
session_id: ctx.session_id,
|
|
149
|
+
host: ctx.host,
|
|
150
|
+
agent: ctx.agent,
|
|
151
|
+
turn_index: ctx.turn_index,
|
|
152
|
+
ts: ev.ts,
|
|
153
|
+
role: ev.role,
|
|
154
|
+
text: ev.text,
|
|
155
|
+
tool_calls: ev.tool_calls,
|
|
156
|
+
usage: ev.usage,
|
|
157
|
+
scrubbed: false,
|
|
158
|
+
raw_ref: null,
|
|
159
|
+
raw: ev.raw,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ClaudeSessionMeta {
|
|
164
|
+
session_id?: string;
|
|
165
|
+
model?: string;
|
|
166
|
+
project_path?: string;
|
|
167
|
+
git_branch?: string;
|
|
168
|
+
title?: string;
|
|
169
|
+
started_at?: string;
|
|
170
|
+
ended_at?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pull session-envelope metadata out of a batch of native Claude lines. */
|
|
174
|
+
export function extractClaudeSessionMeta(rawLines: unknown[]): ClaudeSessionMeta {
|
|
175
|
+
const meta: ClaudeSessionMeta = {};
|
|
176
|
+
for (const raw of rawLines) {
|
|
177
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
178
|
+
const ev = raw as Raw;
|
|
179
|
+
if (typeof ev.sessionId === 'string' && !meta.session_id) meta.session_id = ev.sessionId;
|
|
180
|
+
if (typeof ev.cwd === 'string') meta.project_path = ev.cwd;
|
|
181
|
+
if (typeof ev.gitBranch === 'string') meta.git_branch = ev.gitBranch;
|
|
182
|
+
if (ev.message && typeof ev.message.model === 'string') meta.model = ev.message.model;
|
|
183
|
+
if (ev.type === 'ai-title') {
|
|
184
|
+
const t = ev.title ?? ev.message ?? ev.content;
|
|
185
|
+
if (typeof t === 'string') meta.title = t;
|
|
186
|
+
}
|
|
187
|
+
if (typeof ev.timestamp === 'string') {
|
|
188
|
+
if (!meta.started_at) meta.started_at = ev.timestamp;
|
|
189
|
+
meta.ended_at = ev.timestamp;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return meta;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Convenience: normalize a full batch of lines with sequential turn indices (for tests/backfill). */
|
|
196
|
+
export function normalizeClaudeLines(
|
|
197
|
+
rawLines: unknown[],
|
|
198
|
+
ctx: { session_id: string; host: string; agent: AgentKind; startIndex?: number },
|
|
199
|
+
): { turns: Turn[]; meta: ClaudeSessionMeta } {
|
|
200
|
+
let idx = ctx.startIndex ?? 0;
|
|
201
|
+
const turns: Turn[] = [];
|
|
202
|
+
for (const raw of rawLines) {
|
|
203
|
+
const norm = normalizeClaudeEvent(raw);
|
|
204
|
+
if (!norm) continue;
|
|
205
|
+
turns.push(
|
|
206
|
+
buildTurn(norm, {
|
|
207
|
+
session_id: ctx.session_id,
|
|
208
|
+
host: ctx.host,
|
|
209
|
+
agent: ctx.agent,
|
|
210
|
+
turn_index: idx++,
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return { turns, meta: extractClaudeSessionMeta(rawLines) };
|
|
215
|
+
}
|