@nanobpm/agentic 0.4.0 → 0.5.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.
@@ -6,6 +6,8 @@ import {
6
6
  TRANSCRIPT_CHUNK_TABLE,
7
7
  TRANSCRIPT_SCHEMA_SQL,
8
8
  TRANSCRIPT_STREAM_TABLE,
9
+ TRANSCRIPT_TURN_SCHEMA_SQL,
10
+ TRANSCRIPT_TURN_TABLE,
9
11
  } from "./schema.ts";
10
12
  import { TranscriptStore } from "./store.ts";
11
13
  import { openTestDb } from "./test-db.ts";
@@ -29,6 +31,10 @@ const migrationPath = fileURLToPath(
29
31
  new URL("../../../../db/migrations/002_agentic_transcript.sql", import.meta.url),
30
32
  );
31
33
 
34
+ const turnMigrationPath = fileURLToPath(
35
+ new URL("../../../../db/migrations/008_agentic_transcript_turns.sql", import.meta.url),
36
+ );
37
+
32
38
  test("the boot migration and TranscriptStore's DDL do not drift", () => {
33
39
  const migrationSql = readFileSync(migrationPath, "utf8");
34
40
  assert.equal(
@@ -53,17 +59,38 @@ test("the migration takes prefix 002, after S2's 001 and before S7", () => {
53
59
  assert.match(stream, /\/002_agentic_transcript\.sql$/);
54
60
  });
55
61
 
56
- test("ensureSchema creates both transcript tables idempotently", () => {
62
+ test("the turn-view boot migration and TranscriptStore's turn DDL do not drift", () => {
63
+ const migrationSql = readFileSync(turnMigrationPath, "utf8");
64
+ assert.equal(
65
+ normaliseSql(migrationSql),
66
+ normaliseSql(TRANSCRIPT_TURN_SCHEMA_SQL),
67
+ "db/migrations/008_agentic_transcript_turns.sql must match TRANSCRIPT_TURN_SCHEMA_SQL — update both together",
68
+ );
69
+ });
70
+
71
+ test("the turn-view boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
72
+ const migrationSql = readFileSync(turnMigrationPath, "utf8");
73
+ assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS agentic_transcript_turn/);
74
+ assert.doesNotMatch(migrationSql, /\bDROP\b/i);
75
+ assert.doesNotMatch(migrationSql, /\bALTER\b/i);
76
+ });
77
+
78
+ test("the turn-view migration takes prefix 008 (next free after 007)", () => {
79
+ const stream = new URL("../../../../db/migrations/008_agentic_transcript_turns.sql", import.meta.url).pathname;
80
+ assert.match(stream, /\/008_agentic_transcript_turns\.sql$/);
81
+ });
82
+
83
+ test("ensureSchema creates all three transcript tables idempotently", () => {
57
84
  const db = openTestDb();
58
85
  const store = new TranscriptStore(db);
59
86
  store.ensureSchema();
60
87
  store.ensureSchema();
61
88
  const tables = db
62
89
  .all<{ name: string }>(
63
- "SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?) ORDER BY name",
64
- [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE],
90
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?) ORDER BY name",
91
+ [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_TABLE],
65
92
  )
66
93
  .map((r) => r.name);
67
- assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE]);
94
+ assert.deepEqual(tables, [TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_TABLE]);
68
95
  db.close();
69
96
  });
@@ -9,12 +9,15 @@
9
9
  * statement-for-statement identical — divergence is a red test, not a silent
10
10
  * production/boot mismatch.
11
11
  *
12
- * Two tables back the store:
12
+ * Three tables back the store:
13
13
  * - `agentic_transcript_stream` — one row per relay stream: its retention
14
14
  * lifecycle (`ephemeral` vs `long-lived`), its status (`open`/`completed`),
15
15
  * and the offset window (`first_offset` … `next_offset`) currently retained.
16
16
  * - `agentic_transcript_chunk` — the durable chunks, keyed `(stream, chunk_offset)`
17
17
  * so a flush/append is idempotent and reattach can slice from any offset.
18
+ * - `agentic_transcript_turn` — the additive turn-structured view (Camunda
19
+ * `AgentHistoryRecordValue` parity, issue #475), keyed `(stream, turn_sequence)`.
20
+ * It is layered over — never a replacement for — the raw chunk stream.
18
21
  *
19
22
  * `chunk_offset` (not `offset`) is deliberate: `OFFSET` is a SQLite keyword, so
20
23
  * the column is named to avoid quoting it in every statement.
@@ -26,6 +29,9 @@ export const TRANSCRIPT_STREAM_TABLE = "agentic_transcript_stream";
26
29
  /** The durable per-chunk table name. */
27
30
  export const TRANSCRIPT_CHUNK_TABLE = "agentic_transcript_chunk";
28
31
 
32
+ /** The durable per-turn (structured-view) table name. */
33
+ export const TRANSCRIPT_TURN_TABLE = "agentic_transcript_turn";
34
+
29
35
  /**
30
36
  * The canonical transcript-store DDL. Forward-only and additive; every column
31
37
  * added here must also be added to the boot migration (the drift guard enforces
@@ -49,3 +55,32 @@ CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_CHUNK_TABLE} (
49
55
  PRIMARY KEY (stream, chunk_offset)
50
56
  );
51
57
  CREATE INDEX IF NOT EXISTS idx_${TRANSCRIPT_STREAM_TABLE}_retention ON ${TRANSCRIPT_STREAM_TABLE} (lifecycle, status, completed_at);`;
58
+
59
+ /**
60
+ * The turn-structured transcript DDL — the additive, Camunda-`AgentHistoryRecordValue`
61
+ * parity view layered over the raw chunk stream (issue #475). It ships as its own
62
+ * forward-only migration `db/migrations/008_agentic_transcript_turns.sql` (the raw
63
+ * chunk stream in {@link TRANSCRIPT_SCHEMA_SQL} is untouched — additive, no regression
64
+ * to existing readers), mirrored here as the single source of truth applied by
65
+ * {@link TranscriptStore.ensureSchema} and kept in lockstep by a drift-guard test.
66
+ *
67
+ * One row per structured turn, keyed `(stream, turn_sequence)` so an append/re-record
68
+ * is idempotent (exactly the `(stream, chunk_offset)` discipline of the chunk table).
69
+ * `turn_sequence` is the stream-local append order and idempotency key;
70
+ * `loop_iteration` is the agent-loop turn counter carried as data (Camunda allows
71
+ * several role-split records — e.g. ASSISTANT then TOOL_RESULT — within one iteration).
72
+ * `content`, `tool_calls` and `metrics` hold the typed content blocks, tool calls and
73
+ * per-turn metrics as JSON.
74
+ */
75
+ export const TRANSCRIPT_TURN_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${TRANSCRIPT_TURN_TABLE} (
76
+ stream TEXT NOT NULL,
77
+ turn_sequence INTEGER NOT NULL,
78
+ loop_iteration INTEGER NOT NULL,
79
+ role TEXT NOT NULL,
80
+ content TEXT NOT NULL,
81
+ tool_calls TEXT NOT NULL,
82
+ metrics TEXT,
83
+ produced_at INTEGER,
84
+ recorded_at TEXT NOT NULL,
85
+ PRIMARY KEY (stream, turn_sequence)
86
+ );`;
@@ -22,7 +22,13 @@
22
22
  * ({@link SqliteDb}), so it works against any app DataLayer source without pulling
23
23
  * in the whole runtime.
24
24
  */
25
- import { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE } from "./schema.ts";
25
+ import {
26
+ TRANSCRIPT_CHUNK_TABLE,
27
+ TRANSCRIPT_SCHEMA_SQL,
28
+ TRANSCRIPT_STREAM_TABLE,
29
+ TRANSCRIPT_TURN_SCHEMA_SQL,
30
+ TRANSCRIPT_TURN_TABLE,
31
+ } from "./schema.ts";
26
32
 
27
33
  /**
28
34
  * The minimal synchronous SQLite handle the store needs — structurally the same
@@ -63,6 +69,88 @@ export interface TranscriptChunk {
63
69
  readonly chunk: string;
64
70
  }
65
71
 
72
+ /**
73
+ * A structured turn's author role — the additive turn-structured view's parity
74
+ * with Camunda `AgentHistoryRole` (issue #475). One pass through the agent loop
75
+ * (model reasons → selects tools → evaluates results) is recorded as one or more
76
+ * role-tagged turns sharing a `loopIteration`.
77
+ */
78
+ export type TranscriptTurnRole = "USER" | "ASSISTANT" | "TOOL_RESULT" | "CONFIGURATION" | "UNSPECIFIED";
79
+
80
+ /** Parity with Camunda `AgentHistoryContentType`: the type of a content block. */
81
+ export type TranscriptContentType = "TEXT" | "DOCUMENT" | "OBJECT" | "UNSPECIFIED";
82
+
83
+ const TURN_ROLES: readonly TranscriptTurnRole[] = [
84
+ "USER",
85
+ "ASSISTANT",
86
+ "TOOL_RESULT",
87
+ "CONFIGURATION",
88
+ "UNSPECIFIED",
89
+ ];
90
+ const CONTENT_TYPES: readonly TranscriptContentType[] = ["TEXT", "DOCUMENT", "OBJECT", "UNSPECIFIED"];
91
+
92
+ /**
93
+ * A single typed content block in a turn's message, mirroring Camunda's
94
+ * `AgentHistoryMessageContentValue`. Exactly one payload is populated per the
95
+ * `contentType`: `text` for TEXT, `documentReference` for DOCUMENT, `object`
96
+ * (any JSON value) for OBJECT.
97
+ */
98
+ export interface TranscriptContentBlock {
99
+ readonly contentType: TranscriptContentType;
100
+ /** Text payload; populated when `contentType` is TEXT. */
101
+ readonly text?: string;
102
+ /** Document reference; populated when `contentType` is DOCUMENT. */
103
+ readonly documentReference?: string;
104
+ /** JSON value payload; populated when `contentType` is OBJECT (any JSON type). */
105
+ readonly object?: unknown;
106
+ }
107
+
108
+ /**
109
+ * A tool call embedded in a turn, mirroring Camunda's
110
+ * `AgentHistoryEmbeddedToolCallValue`: `toolCallId`, `toolName`, the tool task's
111
+ * `elementId`, and the `arguments` passed to it.
112
+ */
113
+ export interface TranscriptToolCall {
114
+ readonly toolCallId: string;
115
+ readonly toolName: string;
116
+ readonly elementId?: string;
117
+ readonly arguments: Readonly<Record<string, unknown>>;
118
+ }
119
+
120
+ /**
121
+ * Per-turn metrics, mirroring Camunda's `AgentHistoryMetricsValue`: the token
122
+ * counts consumed/produced by the turn's LLM call and its wall-clock duration.
123
+ */
124
+ export interface TranscriptTurnMetrics {
125
+ readonly inputTokens: number;
126
+ readonly outputTokens: number;
127
+ readonly reasoningTokenCount: number;
128
+ readonly cacheCreationTokenCount: number;
129
+ readonly cacheReadTokenCount: number;
130
+ readonly durationMs: number;
131
+ }
132
+
133
+ /**
134
+ * A structured transcript turn — the additive Camunda `AgentHistoryRecordValue`
135
+ * parity view (issue #475). `sequence` is the stream-local append order and the
136
+ * idempotency key (mirroring a chunk's `offset`); `loopIteration` is the
137
+ * agent-loop turn counter carried as data (several role-split turns can share one
138
+ * iteration). Recording turns never touches the raw chunk stream.
139
+ */
140
+ export interface TranscriptTurn {
141
+ /** Stream-local append order + idempotency key (like a chunk's `offset`). */
142
+ readonly sequence: number;
143
+ /** The agent-loop turn counter (Camunda `loopIteration`). */
144
+ readonly loopIteration: number;
145
+ readonly role: TranscriptTurnRole;
146
+ readonly content: readonly TranscriptContentBlock[];
147
+ readonly toolCalls: readonly TranscriptToolCall[];
148
+ /** Per-turn metrics; undefined when the worker reported none for this turn. */
149
+ readonly metrics?: TranscriptTurnMetrics;
150
+ /** Epoch-millis timestamp the turn was produced; undefined when unreported. */
151
+ readonly producedAt?: number;
152
+ }
153
+
66
154
  /** Per-stream transcript metadata. */
67
155
  export interface TranscriptStream {
68
156
  readonly stream: string;
@@ -151,6 +239,17 @@ interface DbChunkRow {
151
239
  chunk: string;
152
240
  }
153
241
 
242
+ /** The raw turn row shape (snake_case columns) as read from SQLite. */
243
+ interface DbTurnRow {
244
+ turn_sequence: number;
245
+ loop_iteration: number;
246
+ role: string;
247
+ content: string;
248
+ tool_calls: string;
249
+ metrics: string | null;
250
+ produced_at: number | null;
251
+ }
252
+
154
253
  function toLifecycle(value: string): TranscriptLifecycle {
155
254
  if (value === "ephemeral" || value === "long-lived") return value;
156
255
  throw new TranscriptCorruptionError(`invalid transcript lifecycle in DB: ${JSON.stringify(value)}`);
@@ -182,6 +281,241 @@ function toStream(row: DbStreamRow): TranscriptStream {
182
281
  return out;
183
282
  }
184
283
 
284
+ /**
285
+ * A plain JSON-shaped object: an ordinary or null-prototype object, never an array or a
286
+ * class instance (e.g. `Date`, `Map`). Rejecting exotic instances at the boundary stops a
287
+ * value that would serialise via `toJSON()` into a non-object (making the stored turn
288
+ * unreadable) from ever being persisted.
289
+ */
290
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
291
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
292
+ const proto = Object.getPrototypeOf(value);
293
+ return proto === Object.prototype || proto === null;
294
+ }
295
+
296
+ function toTurnRole(value: unknown): TranscriptTurnRole {
297
+ const match = TURN_ROLES.find((role) => role === value);
298
+ if (match !== undefined) return match;
299
+ throw new TranscriptCorruptionError(`invalid transcript turn role: ${JSON.stringify(value)}`);
300
+ }
301
+
302
+ function toContentType(value: unknown): TranscriptContentType {
303
+ const match = CONTENT_TYPES.find((type) => type === value);
304
+ if (match !== undefined) return match;
305
+ throw new TranscriptCorruptionError(`invalid transcript content type: ${JSON.stringify(value)}`);
306
+ }
307
+
308
+ /**
309
+ * The payload key each content type carries: TEXT→`text`, DOCUMENT→`documentReference`,
310
+ * OBJECT→`object`; UNSPECIFIED carries none. Drives the per-`contentType` payload
311
+ * invariant enforced by {@link toContentBlock}.
312
+ */
313
+ const CONTENT_PAYLOAD_KEYS = ["text", "documentReference", "object"] as const;
314
+ const CONTENT_TYPE_PAYLOAD: Record<TranscriptContentType, (typeof CONTENT_PAYLOAD_KEYS)[number] | null> = {
315
+ TEXT: "text",
316
+ DOCUMENT: "documentReference",
317
+ OBJECT: "object",
318
+ UNSPECIFIED: null,
319
+ };
320
+
321
+ /**
322
+ * Validate a value into a canonical content block, enforcing the per-`contentType`
323
+ * payload invariant: a block carries exactly the one payload its type mandates
324
+ * (TEXT→text, DOCUMENT→documentReference, OBJECT→object) — never a missing, mismatched
325
+ * or multiple payload — and UNSPECIFIED carries none. This is the single validator both
326
+ * the write path (record) and the read path (deserialise) run through, so the two can
327
+ * never drift and a malformed block can neither persist nor round-trip. It emits only
328
+ * that one payload key, so serialise/deserialise round-trips exactly.
329
+ */
330
+ function toContentBlock(value: unknown): TranscriptContentBlock {
331
+ if (!isPlainObject(value)) {
332
+ throw new TranscriptCorruptionError(`transcript content block must be an object, got ${JSON.stringify(value)}`);
333
+ }
334
+ const contentType = toContentType(value.contentType);
335
+ const present = CONTENT_PAYLOAD_KEYS.filter((key) => key in value && value[key] !== undefined);
336
+ const expected = CONTENT_TYPE_PAYLOAD[contentType];
337
+ if (expected === null) {
338
+ if (present.length > 0) {
339
+ throw new TranscriptCorruptionError(
340
+ `${contentType} content block must carry no payload, got ${JSON.stringify(present)}`,
341
+ );
342
+ }
343
+ return { contentType };
344
+ }
345
+ if (present.length !== 1 || present[0] !== expected) {
346
+ throw new TranscriptCorruptionError(
347
+ `${contentType} content block must carry exactly its ${expected} payload, got ${JSON.stringify(present)}`,
348
+ );
349
+ }
350
+ if (expected === "text") {
351
+ const text = value.text;
352
+ if (typeof text !== "string") {
353
+ throw new TranscriptCorruptionError(`content block text must be a string, got ${JSON.stringify(text)}`);
354
+ }
355
+ return { contentType, text };
356
+ }
357
+ if (expected === "documentReference") {
358
+ const documentReference = value.documentReference;
359
+ if (typeof documentReference !== "string") {
360
+ throw new TranscriptCorruptionError(
361
+ `content block documentReference must be a string, got ${JSON.stringify(documentReference)}`,
362
+ );
363
+ }
364
+ return { contentType, documentReference };
365
+ }
366
+ return { contentType, object: value.object };
367
+ }
368
+
369
+ /**
370
+ * Validate a value into a canonical tool call — the single validator both the write and
371
+ * read paths run through. Mirrors Camunda's `AgentHistoryEmbeddedToolCallValue`.
372
+ */
373
+ function toToolCall(value: unknown): TranscriptToolCall {
374
+ if (!isPlainObject(value)) {
375
+ throw new TranscriptCorruptionError(`transcript tool call must be an object, got ${JSON.stringify(value)}`);
376
+ }
377
+ const { toolCallId, toolName, elementId } = value;
378
+ if (typeof toolCallId !== "string") {
379
+ throw new TranscriptCorruptionError(`tool call toolCallId must be a string, got ${JSON.stringify(toolCallId)}`);
380
+ }
381
+ if (typeof toolName !== "string") {
382
+ throw new TranscriptCorruptionError(`tool call toolName must be a string, got ${JSON.stringify(toolName)}`);
383
+ }
384
+ const args = value.arguments;
385
+ if (!isPlainObject(args)) {
386
+ throw new TranscriptCorruptionError(`tool call arguments must be an object, got ${JSON.stringify(args)}`);
387
+ }
388
+ const out: { toolCallId: string; toolName: string; elementId?: string; arguments: Record<string, unknown> } = {
389
+ toolCallId,
390
+ toolName,
391
+ arguments: args,
392
+ };
393
+ if (elementId !== undefined) {
394
+ if (typeof elementId !== "string") {
395
+ throw new TranscriptCorruptionError(`tool call elementId must be a string, got ${JSON.stringify(elementId)}`);
396
+ }
397
+ out.elementId = elementId;
398
+ }
399
+ return out;
400
+ }
401
+
402
+ function readMetric(source: Record<string, unknown>, key: keyof TranscriptTurnMetrics): number {
403
+ const value = source[key];
404
+ if (!isNonNegInt(value)) {
405
+ throw new TranscriptCorruptionError(
406
+ `transcript metric "${key}" must be a non-negative safe integer, got ${JSON.stringify(value)}`,
407
+ );
408
+ }
409
+ return value;
410
+ }
411
+
412
+ /**
413
+ * Validate a value into canonical per-turn metrics — the single validator both the write
414
+ * and read paths run through. Every field must be a non-negative safe integer: Camunda's
415
+ * `AgentHistoryMetricsValue` token counts and `durationMs` are integer-valued and cannot be
416
+ * negative, so an out-of-domain value is treated as corruption rather than round-tripped.
417
+ */
418
+ function toMetrics(value: unknown): TranscriptTurnMetrics {
419
+ if (!isPlainObject(value)) {
420
+ throw new TranscriptCorruptionError(`transcript turn metrics must be an object, got ${JSON.stringify(value)}`);
421
+ }
422
+ return {
423
+ inputTokens: readMetric(value, "inputTokens"),
424
+ outputTokens: readMetric(value, "outputTokens"),
425
+ reasoningTokenCount: readMetric(value, "reasoningTokenCount"),
426
+ cacheCreationTokenCount: readMetric(value, "cacheCreationTokenCount"),
427
+ cacheReadTokenCount: readMetric(value, "cacheReadTokenCount"),
428
+ durationMs: readMetric(value, "durationMs"),
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Parse a JSON column, re-raising a malformed-JSON `SyntaxError` as a
434
+ * {@link TranscriptCorruptionError} so every read-back failure surfaces through the store's
435
+ * single corruption signal rather than a raw parse error.
436
+ */
437
+ function parseJsonColumn(json: string, label: string): unknown {
438
+ try {
439
+ return JSON.parse(json);
440
+ } catch (err) {
441
+ throw new TranscriptCorruptionError(
442
+ `transcript turn ${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
443
+ );
444
+ }
445
+ }
446
+
447
+ /** Parse a JSON array column, failing with a corruption error on invalid JSON or non-arrays. */
448
+ function parseJsonArray(json: string, label: string): unknown[] {
449
+ const parsed = parseJsonColumn(json, label);
450
+ if (!Array.isArray(parsed)) {
451
+ throw new TranscriptCorruptionError(`transcript turn ${label} must be a JSON array, got ${JSON.stringify(parsed)}`);
452
+ }
453
+ return parsed;
454
+ }
455
+
456
+ /**
457
+ * Serialise a validated turn payload for storage, re-raising a non-serialisable value
458
+ * (e.g. a `bigint`, which makes `JSON.stringify` throw a `TypeError`) as a
459
+ * {@link TranscriptCorruptionError} so the untyped write boundary only ever surfaces the
460
+ * store's own error taxonomy.
461
+ */
462
+ function stringifyJson(value: unknown, label: string): string {
463
+ try {
464
+ return JSON.stringify(value);
465
+ } catch (err) {
466
+ throw new TranscriptCorruptionError(
467
+ `transcript turn ${label} is not JSON-serialisable: ${err instanceof Error ? err.message : String(err)}`,
468
+ );
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Reconstruct a structured turn from its DB row, validating every field so a corrupted
474
+ * or hand-edited row fails fast with a {@link TranscriptCorruptionError} rather than
475
+ * silently propagating an out-of-domain value. `turn_sequence`, `loop_iteration` and
476
+ * `produced_at` are held to the same non-negative-safe-integer domain the write path
477
+ * enforces.
478
+ */
479
+ function toTurn(row: DbTurnRow): TranscriptTurn {
480
+ if (!isRecordableOffset(row.turn_sequence)) {
481
+ throw new TranscriptCorruptionError(
482
+ `transcript turn sequence must be a non-negative safe integer, got ${JSON.stringify(row.turn_sequence)}`,
483
+ );
484
+ }
485
+ if (!isNonNegInt(row.loop_iteration)) {
486
+ throw new TranscriptCorruptionError(
487
+ `transcript turn loopIteration must be a non-negative safe integer, got ${JSON.stringify(row.loop_iteration)}`,
488
+ );
489
+ }
490
+ const out: {
491
+ sequence: number;
492
+ loopIteration: number;
493
+ role: TranscriptTurnRole;
494
+ content: TranscriptContentBlock[];
495
+ toolCalls: TranscriptToolCall[];
496
+ metrics?: TranscriptTurnMetrics;
497
+ producedAt?: number;
498
+ } = {
499
+ sequence: row.turn_sequence,
500
+ loopIteration: row.loop_iteration,
501
+ role: toTurnRole(row.role),
502
+ content: parseJsonArray(row.content, "content").map(toContentBlock),
503
+ toolCalls: parseJsonArray(row.tool_calls, "toolCalls").map(toToolCall),
504
+ };
505
+ if (row.metrics !== null) {
506
+ out.metrics = toMetrics(parseJsonColumn(row.metrics, "metrics"));
507
+ }
508
+ if (row.produced_at !== null) {
509
+ if (!isNonNegInt(row.produced_at)) {
510
+ throw new TranscriptCorruptionError(
511
+ `transcript turn producedAt must be a non-negative safe integer, got ${JSON.stringify(row.produced_at)}`,
512
+ );
513
+ }
514
+ out.producedAt = row.produced_at;
515
+ }
516
+ return out;
517
+ }
518
+
185
519
  /**
186
520
  * Raised when a transcript row read back from storage holds a value outside its
187
521
  * domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a
@@ -232,12 +566,15 @@ export class TranscriptStore {
232
566
 
233
567
  /**
234
568
  * Apply the canonical transcript DDL (idempotent). Callers that let the app
235
- * DataLayer migration runner apply `db/migrations/002_agentic_transcript.sql`
236
- * do not need this — but it is provided so the store is usable against a bare
237
- * source too. The DDL is identical to the migration (drift-guarded).
569
+ * DataLayer migration runner apply the transcript migrations
570
+ * (`db/migrations/002_agentic_transcript.sql` for the chunk stream and
571
+ * `db/migrations/008_agentic_transcript_turns.sql` for the turn-structured
572
+ * view) do not need this — but it is provided so the store is usable against a
573
+ * bare source too. The DDL is identical to the migrations (drift-guarded).
238
574
  */
239
575
  ensureSchema(): void {
240
576
  this.#db.exec(TRANSCRIPT_SCHEMA_SQL);
577
+ this.#db.exec(TRANSCRIPT_TURN_SCHEMA_SQL);
241
578
  }
242
579
 
243
580
  /**
@@ -381,6 +718,99 @@ export class TranscriptStore {
381
718
  .map((r): TranscriptChunk => ({ offset: r.chunk_offset, chunk: r.chunk }));
382
719
  }
383
720
 
721
+ /**
722
+ * Record structured turns into a stream's additive turn-structured view — the
723
+ * Camunda `AgentHistoryRecordValue` parity layer (issue #475). Each turn is
724
+ * keyed `(stream, sequence)` so re-recording an already-stored sequence (a
725
+ * retry, a re-emit, an overlapping reattach) is a no-op — never a duplicate,
726
+ * exactly the idempotency the chunk stream gets from `(stream, offset)`.
727
+ *
728
+ * This is purely additive: it never reads or writes the raw chunk stream or the
729
+ * stream's offset window, so it cannot regress any existing chunk reader. It
730
+ * auto-opens the stream (default `long-lived`) so the turns hang off a stream
731
+ * row; a lifecycle mismatch throws a {@link TranscriptLifecycleError} before
732
+ * writing anything (lifecycle is first-wins). The batch is atomic: an invalid
733
+ * turn (or any failed write) partway through rolls the whole call back — it
734
+ * records every turn or none. Returns the number of newly-persisted turns.
735
+ */
736
+ recordTurns(
737
+ stream: string,
738
+ turns: Iterable<TranscriptTurn>,
739
+ lifecycle: TranscriptLifecycle = "long-lived",
740
+ ): number {
741
+ const meta = this.open(stream, lifecycle);
742
+ if (meta.lifecycle !== lifecycle) {
743
+ throw new TranscriptLifecycleError(
744
+ stream,
745
+ `refusing to record turns into "${stream}" with lifecycle=${lifecycle}; the stream is ${meta.lifecycle} (lifecycle is first-wins)`,
746
+ );
747
+ }
748
+ const at = new Date(this.#clock.now()).toISOString();
749
+ return this.#atomic(() => {
750
+ let written = 0;
751
+ for (const turn of turns) {
752
+ if (!isPlainObject(turn)) {
753
+ throw new TranscriptCorruptionError(
754
+ `transcript turn must be an object, got ${JSON.stringify(turn)}`,
755
+ );
756
+ }
757
+ if (!isRecordableOffset(turn.sequence)) {
758
+ throw new RangeError(
759
+ `transcript turn sequence must be a non-negative safe integer below Number.MAX_SAFE_INTEGER, got ${turn.sequence}`,
760
+ );
761
+ }
762
+ if (!isNonNegInt(turn.loopIteration)) {
763
+ throw new RangeError(
764
+ `transcript turn loopIteration must be a non-negative safe integer, got ${turn.loopIteration}`,
765
+ );
766
+ }
767
+ const role = toTurnRole(turn.role);
768
+ if (!Array.isArray(turn.content)) {
769
+ throw new TranscriptCorruptionError(
770
+ `transcript turn content must be an array, got ${JSON.stringify(turn.content)}`,
771
+ );
772
+ }
773
+ if (!Array.isArray(turn.toolCalls)) {
774
+ throw new TranscriptCorruptionError(
775
+ `transcript turn toolCalls must be an array, got ${JSON.stringify(turn.toolCalls)}`,
776
+ );
777
+ }
778
+ const content = stringifyJson(turn.content.map(toContentBlock), "content");
779
+ const toolCalls = stringifyJson(turn.toolCalls.map(toToolCall), "toolCalls");
780
+ const metrics = turn.metrics === undefined ? null : stringifyJson(toMetrics(turn.metrics), "metrics");
781
+ let producedAt: number | null = null;
782
+ if (turn.producedAt !== undefined) {
783
+ if (!isNonNegInt(turn.producedAt)) {
784
+ throw new RangeError(
785
+ `transcript turn producedAt must be a non-negative safe integer (epoch millis), got ${turn.producedAt}`,
786
+ );
787
+ }
788
+ producedAt = turn.producedAt;
789
+ }
790
+ const { changes } = this.#db.run(
791
+ `INSERT INTO ${TRANSCRIPT_TURN_TABLE}
792
+ (stream, turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at, recorded_at)
793
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
794
+ ON CONFLICT(stream, turn_sequence) DO NOTHING`,
795
+ [stream, turn.sequence, turn.loopIteration, role, content, toolCalls, metrics, producedAt, at],
796
+ );
797
+ written += changes;
798
+ }
799
+ return written;
800
+ });
801
+ }
802
+
803
+ /** Read a stream's whole turn-structured transcript in `sequence` order. */
804
+ readTurns(stream: string): TranscriptTurn[] {
805
+ return this.#db
806
+ .all<DbTurnRow>(
807
+ `SELECT turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at
808
+ FROM ${TRANSCRIPT_TURN_TABLE} WHERE stream = ? ORDER BY turn_sequence`,
809
+ [stream],
810
+ )
811
+ .map(toTurn);
812
+ }
813
+
384
814
  /**
385
815
  * Apply a rolling retention window to a long-lived stream: drop every chunk with
386
816
  * `offset < before`. A subsequent {@link since} from an offset older than
@@ -411,8 +841,9 @@ export class TranscriptStore {
411
841
  /**
412
842
  * Retention sweep for completed ephemeral transcripts: drop every stream whose
413
843
  * `status = 'completed'` and whose `completed_at` is older than the retention
414
- * window, along with its chunks. Long-lived streams are never time-swept (they
415
- * are bounded by {@link truncateBefore} instead). Returns the removed stream ids.
844
+ * window, along with its chunks and structured turns. Long-lived streams are
845
+ * never time-swept (they are bounded by {@link truncateBefore} instead). Returns
846
+ * the removed stream ids.
416
847
  *
417
848
  * The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
418
849
  * sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
@@ -432,6 +863,7 @@ export class TranscriptStore {
432
863
  .map((r) => r.stream);
433
864
  for (const stream of removed) {
434
865
  this.#db.run(`DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`, [stream]);
866
+ this.#db.run(`DELETE FROM ${TRANSCRIPT_TURN_TABLE} WHERE stream = ?`, [stream]);
435
867
  this.#db.run(`DELETE FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`, [stream]);
436
868
  }
437
869
  return removed;