@gobing-ai/ts-llm-jsonl-importer 0.4.35 → 0.4.36

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/mappers.d.ts CHANGED
@@ -5,7 +5,7 @@ export declare function argsDigest(args: unknown): string;
5
5
  /** Map a Claude Code JSONL record into one or more ETL split entries. */
6
6
  export declare function claudeSplit(raw: Record<string, unknown>): readonly SplitEntry[];
7
7
  /** Map a Pi JSONL record into one or more ETL split entries. */
8
- export declare function piSplit(raw: unknown): readonly SplitEntry[];
8
+ export declare function piSplit(raw: Record<string, unknown>, context?: TransformContext): readonly SplitEntry[];
9
9
  /** Map an OMP JSONL record into one or more ETL split entries. */
10
10
  export declare function ompSplit(raw: Record<string, unknown>, context?: TransformContext): readonly SplitEntry[];
11
11
  /** Timing signals carried by an OMP toolResult message envelope (task 0564 R1). */
@@ -1 +1 @@
1
- {"version":3,"file":"mappers.d.ts","sourceRoot":"","sources":["../src/mappers.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"mappers.d.ts","sourceRoot":"","sources":["../src/mappers.ts"],"names":[],"mappings":"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"}
package/dist/mappers.js CHANGED
@@ -226,24 +226,62 @@ export function claudeSplit(raw) {
226
226
  // Pi mapper
227
227
  // ---------------------------------------------------------------------------
228
228
  /** Map a Pi JSONL record into one or more ETL split entries. */
229
- export function piSplit(raw) {
229
+ export function piSplit(raw, context) {
230
+ // Pi session files are top-level event envelopes: `{type, id, parentId, timestamp,
231
+ // message: {role, content, model, usage, provider, ...}}`. Newer records carry `recordType`
232
+ // instead of `type` with `role`/`text` at the top level. The top-level `id` is an event id,
233
+ // never a session id; the session key and sequence come from the source file (context).
234
+ const msg = raw.message;
235
+ const recordType = String(raw.type ?? raw.recordType ?? '');
236
+ const sessionId = sessionIdFromContext(context, raw);
237
+ const seq = context?.sourceLine ?? (typeof raw.seq === 'number' ? raw.seq : 0);
238
+ // Prefer the ISO `timestamp`; a numeric epoch-ms `ts` is converted, never stringified.
239
+ const ts = s(raw.timestamp, raw.createdAt) ??
240
+ (typeof raw.ts === 'number' && Number.isFinite(raw.ts) ? new Date(raw.ts).toISOString() : s(raw.ts)) ??
241
+ new Date(0).toISOString();
242
+ // Meta lifecycle/custom records collapse to one meta row keyed by the source session,
243
+ // never the unique event id, and never a guessed role.
244
+ if (recordType === 'title' ||
245
+ recordType === 'title_change' ||
246
+ recordType === 'service_tier_change' ||
247
+ recordType === 'session_info' ||
248
+ recordType === 'ttsr_injection' ||
249
+ recordType === 'session_init' ||
250
+ recordType === 'session' ||
251
+ recordType === 'model_change' ||
252
+ recordType === 'thinking_level_change' ||
253
+ recordType === 'compaction' ||
254
+ recordType === 'custom' ||
255
+ recordType === 'custom_message' ||
256
+ recordType.startsWith('custom.')) {
257
+ return [
258
+ {
259
+ targetTable: 'history_message',
260
+ record: {
261
+ session_id: sessionId,
262
+ seq,
263
+ role: 'meta',
264
+ record_type: recordType,
265
+ disposition: 'meta',
266
+ ts,
267
+ provenance: 'ambient',
268
+ },
269
+ },
270
+ ];
271
+ }
230
272
  const entries = [];
231
- const r = raw;
232
- const sessionId = s(r.id, o(r.session).id) ?? 'unknown';
233
- const seq = typeof r.seq === 'number' ? r.seq : 0;
234
- const ts = s(r.ts, r.timestamp, r.createdAt) ?? new Date(0).toISOString();
235
- const role = mapRole(r.type ?? r.role);
236
- const recordType = String(r.type ?? '');
237
- const model = s(r.model, o(r.message).model);
238
- const cwd = s(r.cwd, r.dir);
239
- const msg = r.message;
240
- const usage = msg?.usage;
273
+ const role = piRole(msg?.role ?? raw.role ?? recordType);
274
+ const model = s(raw.model, o(msg).model);
275
+ const cwd = s(raw.cwd, raw.dir);
276
+ const usage = (msg?.usage ?? raw.usage);
241
277
  const inputTokens = (usage?.input ?? usage?.input_tokens ?? undefined);
242
278
  const outputTokens = (usage?.output ?? usage?.output_tokens ?? undefined);
243
279
  const cacheRead = (usage?.cacheRead ?? usage?.cache_read_tokens ?? undefined);
244
280
  const cacheWrite = (usage?.cacheWrite ?? usage?.cache_write_tokens ?? undefined);
245
- const costObj = (msg?.cost ?? r.cost);
281
+ const costObj = (msg?.cost ?? raw.cost);
246
282
  const costUsd = typeof costObj?.total === 'number' ? costObj.total : computeCost(inputTokens, outputTokens, model);
283
+ const contentBlocks = msg?.content ?? raw.content;
284
+ const durationMs = typeof msg?.duration === 'number' && Number.isFinite(msg.duration) ? msg.duration : undefined;
247
285
  const messageSplitIndex = entries.length;
248
286
  entries.push({
249
287
  targetTable: 'history_message',
@@ -254,45 +292,64 @@ export function piSplit(raw) {
254
292
  record_type: recordType,
255
293
  disposition: 'keep',
256
294
  ts,
257
- duration_ms: undefined,
295
+ duration_ms: durationMs,
258
296
  model: model ?? null,
259
297
  input_tokens: inputTokens ?? null,
260
298
  output_tokens: outputTokens ?? null,
261
299
  cache_read_tokens: cacheRead ?? null,
262
300
  cache_write_tokens: cacheWrite ?? null,
263
301
  cost_usd: costUsd ?? null,
264
- content_text: s(r.content, r.text, msg?.content) ?? null,
302
+ content_text: extractContentText(contentBlocks) ?? s(raw.content, raw.text, msg?.content) ?? null,
265
303
  cwd: cwd ?? null,
266
304
  provenance: 'ambient',
267
305
  },
268
306
  });
269
- // Tool calls from toolCall blocks in assistant content
270
- if (Array.isArray(r.content) && role === 'assistant') {
271
- for (const block of r.content) {
272
- if (block?.toolCall) {
273
- const tc = block.toolCall;
274
- entries.push({
275
- targetTable: 'history_tool_call',
276
- record: {
277
- _messageSplitIndex: messageSplitIndex,
278
- session_id: sessionId,
279
- seq,
280
- tool_name: String(tc.name ?? ''),
281
- args_digest: argsDigest(tc.input ?? tc.arguments),
282
- args_raw: maybeArgsRaw('pi', String(tc.name ?? ''), tc.input ?? tc.arguments),
283
- status: 'ok',
284
- started_at: undefined,
285
- completed_at: undefined,
286
- duration_ms: undefined,
287
- result_bytes: undefined,
288
- error_text: undefined,
289
- },
290
- });
291
- }
307
+ // Tool calls from content blocks in assistant messages. normalizeOmpToolCall handles both
308
+ // the legacy nested `{toolCall: {...}}` block and pi's flat `{type: "toolCall", id, name,
309
+ // arguments}` block.
310
+ if (Array.isArray(contentBlocks) && role === 'assistant') {
311
+ for (const block of contentBlocks) {
312
+ const call = normalizeOmpToolCall(block);
313
+ if (call === null)
314
+ continue;
315
+ entries.push({
316
+ targetTable: 'history_tool_call',
317
+ record: {
318
+ _messageSplitIndex: messageSplitIndex,
319
+ session_id: sessionId,
320
+ seq,
321
+ tool_name: String(call.name ?? ''),
322
+ // The tool's own call id is the exact join key a toolResult's `toolCallId`
323
+ // matches (see ompSplit / task 0564 R1).
324
+ call_id: s(call.id),
325
+ args_digest: argsDigest(call.input ?? call.arguments),
326
+ args_raw: maybeArgsRaw('pi', String(call.name ?? ''), call.input ?? call.arguments),
327
+ status: 'ok',
328
+ started_at: undefined,
329
+ completed_at: undefined,
330
+ duration_ms: undefined,
331
+ result_bytes: undefined,
332
+ error_text: undefined,
333
+ },
334
+ });
292
335
  }
293
336
  }
294
337
  return entries;
295
338
  }
339
+ /**
340
+ * Canonicalize a pi message role. pi tool results and bash executions ride user turns (Claude
341
+ * protocol shape); everything unrecognized clamps to `unknown` so pi record types never leak
342
+ * into the role column (task 0577 AC2).
343
+ */
344
+ function piRole(type) {
345
+ const t = String(type ?? '').toLowerCase();
346
+ if (t === 'toolresult' || t === 'tool_result' || t === 'bashexecution' || t === 'tool')
347
+ return 'user';
348
+ const mapped = mapRole(t);
349
+ if (mapped === 'user' || mapped === 'assistant' || mapped === 'system')
350
+ return mapped;
351
+ return 'unknown';
352
+ }
296
353
  // ---------------------------------------------------------------------------
297
354
  // OMP mapper (near-identical to pi)
298
355
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/ts-llm-jsonl-importer",
3
- "version": "0.4.35",
3
+ "version": "0.4.36",
4
4
  "description": "@gobing-ai/ts-llm-jsonl-importer — Generic JSONL importer for LLM agent history files.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -47,9 +47,9 @@
47
47
  "release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-llm-jsonl-importer-v<version> && git push --tags' && exit 1"
48
48
  },
49
49
  "dependencies": {
50
- "@gobing-ai/ts-db": "^0.4.35",
51
- "@gobing-ai/ts-runtime": "^0.4.35",
52
- "@gobing-ai/ts-utils": "^0.4.35",
50
+ "@gobing-ai/ts-db": "^0.4.36",
51
+ "@gobing-ai/ts-runtime": "^0.4.36",
52
+ "@gobing-ai/ts-utils": "^0.4.36",
53
53
  "zod": "^4.1.0"
54
54
  },
55
55
  "devDependencies": {
package/src/mappers.ts CHANGED
@@ -241,25 +241,67 @@ export function claudeSplit(raw: Record<string, unknown>): readonly SplitEntry[]
241
241
  // ---------------------------------------------------------------------------
242
242
 
243
243
  /** Map a Pi JSONL record into one or more ETL split entries. */
244
- export function piSplit(raw: unknown): readonly SplitEntry[] {
244
+ export function piSplit(raw: Record<string, unknown>, context?: TransformContext): readonly SplitEntry[] {
245
+ // Pi session files are top-level event envelopes: `{type, id, parentId, timestamp,
246
+ // message: {role, content, model, usage, provider, ...}}`. Newer records carry `recordType`
247
+ // instead of `type` with `role`/`text` at the top level. The top-level `id` is an event id,
248
+ // never a session id; the session key and sequence come from the source file (context).
249
+ const msg = raw.message as Record<string, unknown> | undefined;
250
+ const recordType = String(raw.type ?? raw.recordType ?? '');
251
+ const sessionId = sessionIdFromContext(context, raw);
252
+ const seq = context?.sourceLine ?? (typeof raw.seq === 'number' ? raw.seq : 0);
253
+ // Prefer the ISO `timestamp`; a numeric epoch-ms `ts` is converted, never stringified.
254
+ const ts =
255
+ s(raw.timestamp, raw.createdAt) ??
256
+ (typeof raw.ts === 'number' && Number.isFinite(raw.ts) ? new Date(raw.ts).toISOString() : s(raw.ts)) ??
257
+ new Date(0).toISOString();
258
+
259
+ // Meta lifecycle/custom records collapse to one meta row keyed by the source session,
260
+ // never the unique event id, and never a guessed role.
261
+ if (
262
+ recordType === 'title' ||
263
+ recordType === 'title_change' ||
264
+ recordType === 'service_tier_change' ||
265
+ recordType === 'session_info' ||
266
+ recordType === 'ttsr_injection' ||
267
+ recordType === 'session_init' ||
268
+ recordType === 'session' ||
269
+ recordType === 'model_change' ||
270
+ recordType === 'thinking_level_change' ||
271
+ recordType === 'compaction' ||
272
+ recordType === 'custom' ||
273
+ recordType === 'custom_message' ||
274
+ recordType.startsWith('custom.')
275
+ ) {
276
+ return [
277
+ {
278
+ targetTable: 'history_message',
279
+ record: {
280
+ session_id: sessionId,
281
+ seq,
282
+ role: 'meta',
283
+ record_type: recordType,
284
+ disposition: 'meta',
285
+ ts,
286
+ provenance: 'ambient',
287
+ },
288
+ },
289
+ ];
290
+ }
291
+
245
292
  const entries: SplitEntry[] = [];
246
- const r = raw as Record<string, unknown>;
247
- const sessionId = s(r.id, o(r.session).id) ?? 'unknown';
248
- const seq = typeof r.seq === 'number' ? r.seq : 0;
249
- const ts = s(r.ts, r.timestamp, r.createdAt) ?? new Date(0).toISOString();
250
- const role = mapRole(r.type ?? r.role);
251
- const recordType = String(r.type ?? '');
252
- const model = s(r.model, o(r.message).model);
253
- const cwd = s(r.cwd, r.dir);
254
-
255
- const msg = r.message as Record<string, unknown> | undefined;
256
- const usage = msg?.usage as Record<string, unknown> | undefined;
293
+ const role = piRole(msg?.role ?? raw.role ?? recordType);
294
+ const model = s(raw.model, o(msg).model);
295
+ const cwd = s(raw.cwd, raw.dir);
296
+ const usage = (msg?.usage ?? raw.usage) as Record<string, unknown> | undefined;
257
297
  const inputTokens = (usage?.input ?? usage?.input_tokens ?? undefined) as number | undefined;
258
298
  const outputTokens = (usage?.output ?? usage?.output_tokens ?? undefined) as number | undefined;
259
299
  const cacheRead = (usage?.cacheRead ?? usage?.cache_read_tokens ?? undefined) as number | undefined;
260
300
  const cacheWrite = (usage?.cacheWrite ?? usage?.cache_write_tokens ?? undefined) as number | undefined;
261
- const costObj = (msg?.cost ?? r.cost) as Record<string, unknown> | undefined;
301
+ const costObj = (msg?.cost ?? raw.cost) as Record<string, unknown> | undefined;
262
302
  const costUsd = typeof costObj?.total === 'number' ? costObj.total : computeCost(inputTokens, outputTokens, model);
303
+ const contentBlocks = msg?.content ?? raw.content;
304
+ const durationMs = typeof msg?.duration === 'number' && Number.isFinite(msg.duration) ? msg.duration : undefined;
263
305
 
264
306
  const messageSplitIndex = entries.length;
265
307
 
@@ -272,48 +314,65 @@ export function piSplit(raw: unknown): readonly SplitEntry[] {
272
314
  record_type: recordType,
273
315
  disposition: 'keep',
274
316
  ts,
275
- duration_ms: undefined,
317
+ duration_ms: durationMs,
276
318
  model: model ?? null,
277
319
  input_tokens: inputTokens ?? null,
278
320
  output_tokens: outputTokens ?? null,
279
321
  cache_read_tokens: cacheRead ?? null,
280
322
  cache_write_tokens: cacheWrite ?? null,
281
323
  cost_usd: costUsd ?? null,
282
- content_text: s(r.content, r.text, msg?.content) ?? null,
324
+ content_text: extractContentText(contentBlocks) ?? s(raw.content, raw.text, msg?.content) ?? null,
283
325
  cwd: cwd ?? null,
284
326
  provenance: 'ambient',
285
327
  },
286
328
  });
287
329
 
288
- // Tool calls from toolCall blocks in assistant content
289
- if (Array.isArray(r.content) && role === 'assistant') {
290
- for (const block of r.content as Record<string, unknown>[]) {
291
- if (block?.toolCall) {
292
- const tc = block.toolCall as Record<string, unknown>;
293
- entries.push({
294
- targetTable: 'history_tool_call',
295
- record: {
296
- _messageSplitIndex: messageSplitIndex,
297
- session_id: sessionId,
298
- seq,
299
- tool_name: String(tc.name ?? ''),
300
- args_digest: argsDigest(tc.input ?? tc.arguments),
301
- args_raw: maybeArgsRaw('pi', String(tc.name ?? ''), tc.input ?? tc.arguments),
302
- status: 'ok',
303
- started_at: undefined,
304
- completed_at: undefined,
305
- duration_ms: undefined,
306
- result_bytes: undefined,
307
- error_text: undefined,
308
- },
309
- });
310
- }
330
+ // Tool calls from content blocks in assistant messages. normalizeOmpToolCall handles both
331
+ // the legacy nested `{toolCall: {...}}` block and pi's flat `{type: "toolCall", id, name,
332
+ // arguments}` block.
333
+ if (Array.isArray(contentBlocks) && role === 'assistant') {
334
+ for (const block of contentBlocks as Record<string, unknown>[]) {
335
+ const call = normalizeOmpToolCall(block);
336
+ if (call === null) continue;
337
+ entries.push({
338
+ targetTable: 'history_tool_call',
339
+ record: {
340
+ _messageSplitIndex: messageSplitIndex,
341
+ session_id: sessionId,
342
+ seq,
343
+ tool_name: String(call.name ?? ''),
344
+ // The tool's own call id is the exact join key a toolResult's `toolCallId`
345
+ // matches (see ompSplit / task 0564 R1).
346
+ call_id: s(call.id),
347
+ args_digest: argsDigest(call.input ?? call.arguments),
348
+ args_raw: maybeArgsRaw('pi', String(call.name ?? ''), call.input ?? call.arguments),
349
+ status: 'ok',
350
+ started_at: undefined,
351
+ completed_at: undefined,
352
+ duration_ms: undefined,
353
+ result_bytes: undefined,
354
+ error_text: undefined,
355
+ },
356
+ });
311
357
  }
312
358
  }
313
359
 
314
360
  return entries;
315
361
  }
316
362
 
363
+ /**
364
+ * Canonicalize a pi message role. pi tool results and bash executions ride user turns (Claude
365
+ * protocol shape); everything unrecognized clamps to `unknown` so pi record types never leak
366
+ * into the role column (task 0577 AC2).
367
+ */
368
+ function piRole(type: unknown): string {
369
+ const t = String(type ?? '').toLowerCase();
370
+ if (t === 'toolresult' || t === 'tool_result' || t === 'bashexecution' || t === 'tool') return 'user';
371
+ const mapped = mapRole(t);
372
+ if (mapped === 'user' || mapped === 'assistant' || mapped === 'system') return mapped;
373
+ return 'unknown';
374
+ }
375
+
317
376
  // ---------------------------------------------------------------------------
318
377
  // OMP mapper (near-identical to pi)
319
378
  // ---------------------------------------------------------------------------