@nanobpm/agentic 0.1.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.
Files changed (128) hide show
  1. package/README.md +2 -1
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/dist/transcript/index.d.ts +2 -2
  59. package/dist/transcript/index.js +1 -1
  60. package/dist/transcript/schema.d.ts +23 -1
  61. package/dist/transcript/schema.js +34 -1
  62. package/dist/transcript/store.d.ts +93 -5
  63. package/dist/transcript/store.js +287 -6
  64. package/package.json +17 -1
  65. package/src/demand/model.test.ts +82 -4
  66. package/src/demand/model.ts +30 -9
  67. package/src/demand/taskdef.test.ts +51 -6
  68. package/src/demand/taskdef.ts +31 -2
  69. package/src/index.ts +1 -0
  70. package/src/protocol/conformance/frames.ts +32 -4
  71. package/src/protocol/index.ts +4 -0
  72. package/src/protocol/payloads.test.ts +31 -1
  73. package/src/protocol/payloads.ts +110 -7
  74. package/src/session/acp/client.test.ts +222 -0
  75. package/src/session/acp/client.ts +356 -0
  76. package/src/session/acp/fake-agent.ts +71 -0
  77. package/src/session/acp/index.ts +68 -0
  78. package/src/session/acp/integration.test.ts +37 -0
  79. package/src/session/acp/jsonrpc.test.ts +75 -0
  80. package/src/session/acp/jsonrpc.ts +171 -0
  81. package/src/session/acp/normalize.test.ts +150 -0
  82. package/src/session/acp/normalize.ts +204 -0
  83. package/src/session/acp/protocol.ts +178 -0
  84. package/src/session/acp/spawn.test.ts +45 -0
  85. package/src/session/acp/spawn.ts +91 -0
  86. package/src/session/acp/transport.test.ts +82 -0
  87. package/src/session/acp/transport.ts +155 -0
  88. package/src/session/adapter.ts +159 -0
  89. package/src/session/backend.test.ts +198 -0
  90. package/src/session/backend.ts +128 -0
  91. package/src/session/events.test.ts +168 -0
  92. package/src/session/events.ts +347 -0
  93. package/src/session/index.ts +67 -0
  94. package/src/session/log.test.ts +215 -0
  95. package/src/session/log.ts +525 -0
  96. package/src/session/normalizer/backend-integration.test.ts +103 -0
  97. package/src/session/normalizer/claude.test.ts +68 -0
  98. package/src/session/normalizer/claude.ts +136 -0
  99. package/src/session/normalizer/copilot.test.ts +59 -0
  100. package/src/session/normalizer/copilot.ts +133 -0
  101. package/src/session/normalizer/deepseek.ts +80 -0
  102. package/src/session/normalizer/index.ts +61 -0
  103. package/src/session/normalizer/kimi.ts +82 -0
  104. package/src/session/normalizer/link.test.ts +24 -0
  105. package/src/session/normalizer/link.ts +81 -0
  106. package/src/session/normalizer/pi.ts +75 -0
  107. package/src/session/normalizer/probe.test.ts +49 -0
  108. package/src/session/normalizer/qwen.test.ts +20 -0
  109. package/src/session/normalizer/qwen.ts +77 -0
  110. package/src/session/normalizer/record.test.ts +68 -0
  111. package/src/session/normalizer/record.ts +88 -0
  112. package/src/session/normalizer/resume.test.ts +25 -0
  113. package/src/session/normalizer/types.ts +152 -0
  114. package/src/session/normalizer/vectors.test.ts +180 -0
  115. package/src/session/schema.test.ts +84 -0
  116. package/src/session/schema.ts +78 -0
  117. package/src/session/test-db.ts +56 -0
  118. package/src/transcript/index.ts +8 -0
  119. package/src/transcript/schema.test.ts +31 -4
  120. package/src/transcript/schema.ts +36 -1
  121. package/src/transcript/store.ts +438 -6
  122. package/src/transcript/turns.test.ts +334 -0
  123. package/dist/blackboard/test-db.d.ts +0 -5
  124. package/dist/blackboard/test-db.js +0 -42
  125. package/dist/presence/test-db.d.ts +0 -5
  126. package/dist/presence/test-db.js +0 -42
  127. package/dist/transcript/test-db.d.ts +0 -5
  128. package/dist/transcript/test-db.js +0 -41
@@ -22,9 +22,17 @@
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.js";
25
+ import { TRANSCRIPT_CHUNK_TABLE, TRANSCRIPT_SCHEMA_SQL, TRANSCRIPT_STREAM_TABLE, TRANSCRIPT_TURN_SCHEMA_SQL, TRANSCRIPT_TURN_TABLE, } from "./schema.js";
26
26
  /** The default clock: `Date.now()`. */
27
27
  export const systemClock = { now: () => Date.now() };
28
+ const TURN_ROLES = [
29
+ "USER",
30
+ "ASSISTANT",
31
+ "TOOL_RESULT",
32
+ "CONFIGURATION",
33
+ "UNSPECIFIED",
34
+ ];
35
+ const CONTENT_TYPES = ["TEXT", "DOCUMENT", "OBJECT", "UNSPECIFIED"];
28
36
  const DEFAULT_EPHEMERAL_RETENTION_MS = 86_400_000;
29
37
  function isNonNegInt(value) {
30
38
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -63,6 +71,208 @@ function toStream(row) {
63
71
  out.firstOffset = row.first_offset;
64
72
  return out;
65
73
  }
74
+ /**
75
+ * A plain JSON-shaped object: an ordinary or null-prototype object, never an array or a
76
+ * class instance (e.g. `Date`, `Map`). Rejecting exotic instances at the boundary stops a
77
+ * value that would serialise via `toJSON()` into a non-object (making the stored turn
78
+ * unreadable) from ever being persisted.
79
+ */
80
+ function isPlainObject(value) {
81
+ if (typeof value !== "object" || value === null || Array.isArray(value))
82
+ return false;
83
+ const proto = Object.getPrototypeOf(value);
84
+ return proto === Object.prototype || proto === null;
85
+ }
86
+ function toTurnRole(value) {
87
+ const match = TURN_ROLES.find((role) => role === value);
88
+ if (match !== undefined)
89
+ return match;
90
+ throw new TranscriptCorruptionError(`invalid transcript turn role: ${JSON.stringify(value)}`);
91
+ }
92
+ function toContentType(value) {
93
+ const match = CONTENT_TYPES.find((type) => type === value);
94
+ if (match !== undefined)
95
+ return match;
96
+ throw new TranscriptCorruptionError(`invalid transcript content type: ${JSON.stringify(value)}`);
97
+ }
98
+ /**
99
+ * The payload key each content type carries: TEXT→`text`, DOCUMENT→`documentReference`,
100
+ * OBJECT→`object`; UNSPECIFIED carries none. Drives the per-`contentType` payload
101
+ * invariant enforced by {@link toContentBlock}.
102
+ */
103
+ const CONTENT_PAYLOAD_KEYS = ["text", "documentReference", "object"];
104
+ const CONTENT_TYPE_PAYLOAD = {
105
+ TEXT: "text",
106
+ DOCUMENT: "documentReference",
107
+ OBJECT: "object",
108
+ UNSPECIFIED: null,
109
+ };
110
+ /**
111
+ * Validate a value into a canonical content block, enforcing the per-`contentType`
112
+ * payload invariant: a block carries exactly the one payload its type mandates
113
+ * (TEXT→text, DOCUMENT→documentReference, OBJECT→object) — never a missing, mismatched
114
+ * or multiple payload — and UNSPECIFIED carries none. This is the single validator both
115
+ * the write path (record) and the read path (deserialise) run through, so the two can
116
+ * never drift and a malformed block can neither persist nor round-trip. It emits only
117
+ * that one payload key, so serialise/deserialise round-trips exactly.
118
+ */
119
+ function toContentBlock(value) {
120
+ if (!isPlainObject(value)) {
121
+ throw new TranscriptCorruptionError(`transcript content block must be an object, got ${JSON.stringify(value)}`);
122
+ }
123
+ const contentType = toContentType(value.contentType);
124
+ const present = CONTENT_PAYLOAD_KEYS.filter((key) => key in value && value[key] !== undefined);
125
+ const expected = CONTENT_TYPE_PAYLOAD[contentType];
126
+ if (expected === null) {
127
+ if (present.length > 0) {
128
+ throw new TranscriptCorruptionError(`${contentType} content block must carry no payload, got ${JSON.stringify(present)}`);
129
+ }
130
+ return { contentType };
131
+ }
132
+ if (present.length !== 1 || present[0] !== expected) {
133
+ throw new TranscriptCorruptionError(`${contentType} content block must carry exactly its ${expected} payload, got ${JSON.stringify(present)}`);
134
+ }
135
+ if (expected === "text") {
136
+ const text = value.text;
137
+ if (typeof text !== "string") {
138
+ throw new TranscriptCorruptionError(`content block text must be a string, got ${JSON.stringify(text)}`);
139
+ }
140
+ return { contentType, text };
141
+ }
142
+ if (expected === "documentReference") {
143
+ const documentReference = value.documentReference;
144
+ if (typeof documentReference !== "string") {
145
+ throw new TranscriptCorruptionError(`content block documentReference must be a string, got ${JSON.stringify(documentReference)}`);
146
+ }
147
+ return { contentType, documentReference };
148
+ }
149
+ return { contentType, object: value.object };
150
+ }
151
+ /**
152
+ * Validate a value into a canonical tool call — the single validator both the write and
153
+ * read paths run through. Mirrors Camunda's `AgentHistoryEmbeddedToolCallValue`.
154
+ */
155
+ function toToolCall(value) {
156
+ if (!isPlainObject(value)) {
157
+ throw new TranscriptCorruptionError(`transcript tool call must be an object, got ${JSON.stringify(value)}`);
158
+ }
159
+ const { toolCallId, toolName, elementId } = value;
160
+ if (typeof toolCallId !== "string") {
161
+ throw new TranscriptCorruptionError(`tool call toolCallId must be a string, got ${JSON.stringify(toolCallId)}`);
162
+ }
163
+ if (typeof toolName !== "string") {
164
+ throw new TranscriptCorruptionError(`tool call toolName must be a string, got ${JSON.stringify(toolName)}`);
165
+ }
166
+ const args = value.arguments;
167
+ if (!isPlainObject(args)) {
168
+ throw new TranscriptCorruptionError(`tool call arguments must be an object, got ${JSON.stringify(args)}`);
169
+ }
170
+ const out = {
171
+ toolCallId,
172
+ toolName,
173
+ arguments: args,
174
+ };
175
+ if (elementId !== undefined) {
176
+ if (typeof elementId !== "string") {
177
+ throw new TranscriptCorruptionError(`tool call elementId must be a string, got ${JSON.stringify(elementId)}`);
178
+ }
179
+ out.elementId = elementId;
180
+ }
181
+ return out;
182
+ }
183
+ function readMetric(source, key) {
184
+ const value = source[key];
185
+ if (!isNonNegInt(value)) {
186
+ throw new TranscriptCorruptionError(`transcript metric "${key}" must be a non-negative safe integer, got ${JSON.stringify(value)}`);
187
+ }
188
+ return value;
189
+ }
190
+ /**
191
+ * Validate a value into canonical per-turn metrics — the single validator both the write
192
+ * and read paths run through. Every field must be a non-negative safe integer: Camunda's
193
+ * `AgentHistoryMetricsValue` token counts and `durationMs` are integer-valued and cannot be
194
+ * negative, so an out-of-domain value is treated as corruption rather than round-tripped.
195
+ */
196
+ function toMetrics(value) {
197
+ if (!isPlainObject(value)) {
198
+ throw new TranscriptCorruptionError(`transcript turn metrics must be an object, got ${JSON.stringify(value)}`);
199
+ }
200
+ return {
201
+ inputTokens: readMetric(value, "inputTokens"),
202
+ outputTokens: readMetric(value, "outputTokens"),
203
+ reasoningTokenCount: readMetric(value, "reasoningTokenCount"),
204
+ cacheCreationTokenCount: readMetric(value, "cacheCreationTokenCount"),
205
+ cacheReadTokenCount: readMetric(value, "cacheReadTokenCount"),
206
+ durationMs: readMetric(value, "durationMs"),
207
+ };
208
+ }
209
+ /**
210
+ * Parse a JSON column, re-raising a malformed-JSON `SyntaxError` as a
211
+ * {@link TranscriptCorruptionError} so every read-back failure surfaces through the store's
212
+ * single corruption signal rather than a raw parse error.
213
+ */
214
+ function parseJsonColumn(json, label) {
215
+ try {
216
+ return JSON.parse(json);
217
+ }
218
+ catch (err) {
219
+ throw new TranscriptCorruptionError(`transcript turn ${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
220
+ }
221
+ }
222
+ /** Parse a JSON array column, failing with a corruption error on invalid JSON or non-arrays. */
223
+ function parseJsonArray(json, label) {
224
+ const parsed = parseJsonColumn(json, label);
225
+ if (!Array.isArray(parsed)) {
226
+ throw new TranscriptCorruptionError(`transcript turn ${label} must be a JSON array, got ${JSON.stringify(parsed)}`);
227
+ }
228
+ return parsed;
229
+ }
230
+ /**
231
+ * Serialise a validated turn payload for storage, re-raising a non-serialisable value
232
+ * (e.g. a `bigint`, which makes `JSON.stringify` throw a `TypeError`) as a
233
+ * {@link TranscriptCorruptionError} so the untyped write boundary only ever surfaces the
234
+ * store's own error taxonomy.
235
+ */
236
+ function stringifyJson(value, label) {
237
+ try {
238
+ return JSON.stringify(value);
239
+ }
240
+ catch (err) {
241
+ throw new TranscriptCorruptionError(`transcript turn ${label} is not JSON-serialisable: ${err instanceof Error ? err.message : String(err)}`);
242
+ }
243
+ }
244
+ /**
245
+ * Reconstruct a structured turn from its DB row, validating every field so a corrupted
246
+ * or hand-edited row fails fast with a {@link TranscriptCorruptionError} rather than
247
+ * silently propagating an out-of-domain value. `turn_sequence`, `loop_iteration` and
248
+ * `produced_at` are held to the same non-negative-safe-integer domain the write path
249
+ * enforces.
250
+ */
251
+ function toTurn(row) {
252
+ if (!isRecordableOffset(row.turn_sequence)) {
253
+ throw new TranscriptCorruptionError(`transcript turn sequence must be a non-negative safe integer, got ${JSON.stringify(row.turn_sequence)}`);
254
+ }
255
+ if (!isNonNegInt(row.loop_iteration)) {
256
+ throw new TranscriptCorruptionError(`transcript turn loopIteration must be a non-negative safe integer, got ${JSON.stringify(row.loop_iteration)}`);
257
+ }
258
+ const out = {
259
+ sequence: row.turn_sequence,
260
+ loopIteration: row.loop_iteration,
261
+ role: toTurnRole(row.role),
262
+ content: parseJsonArray(row.content, "content").map(toContentBlock),
263
+ toolCalls: parseJsonArray(row.tool_calls, "toolCalls").map(toToolCall),
264
+ };
265
+ if (row.metrics !== null) {
266
+ out.metrics = toMetrics(parseJsonColumn(row.metrics, "metrics"));
267
+ }
268
+ if (row.produced_at !== null) {
269
+ if (!isNonNegInt(row.produced_at)) {
270
+ throw new TranscriptCorruptionError(`transcript turn producedAt must be a non-negative safe integer, got ${JSON.stringify(row.produced_at)}`);
271
+ }
272
+ out.producedAt = row.produced_at;
273
+ }
274
+ return out;
275
+ }
66
276
  /**
67
277
  * Raised when a transcript row read back from storage holds a value outside its
68
278
  * domain (e.g. an unknown `lifecycle`/`status`), signalling schema corruption or a
@@ -106,12 +316,15 @@ export class TranscriptStore {
106
316
  }
107
317
  /**
108
318
  * Apply the canonical transcript DDL (idempotent). Callers that let the app
109
- * DataLayer migration runner apply `db/migrations/002_agentic_transcript.sql`
110
- * do not need this — but it is provided so the store is usable against a bare
111
- * source too. The DDL is identical to the migration (drift-guarded).
319
+ * DataLayer migration runner apply the transcript migrations
320
+ * (`db/migrations/002_agentic_transcript.sql` for the chunk stream and
321
+ * `db/migrations/008_agentic_transcript_turns.sql` for the turn-structured
322
+ * view) do not need this — but it is provided so the store is usable against a
323
+ * bare source too. The DDL is identical to the migrations (drift-guarded).
112
324
  */
113
325
  ensureSchema() {
114
326
  this.#db.exec(TRANSCRIPT_SCHEMA_SQL);
327
+ this.#db.exec(TRANSCRIPT_TURN_SCHEMA_SQL);
115
328
  }
116
329
  /**
117
330
  * Open (or fetch) a stream's transcript with the given lifecycle. Idempotent:
@@ -228,6 +441,72 @@ export class TranscriptStore {
228
441
  .all(`SELECT chunk_offset, chunk FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ? ORDER BY chunk_offset`, [stream])
229
442
  .map((r) => ({ offset: r.chunk_offset, chunk: r.chunk }));
230
443
  }
444
+ /**
445
+ * Record structured turns into a stream's additive turn-structured view — the
446
+ * Camunda `AgentHistoryRecordValue` parity layer (issue #475). Each turn is
447
+ * keyed `(stream, sequence)` so re-recording an already-stored sequence (a
448
+ * retry, a re-emit, an overlapping reattach) is a no-op — never a duplicate,
449
+ * exactly the idempotency the chunk stream gets from `(stream, offset)`.
450
+ *
451
+ * This is purely additive: it never reads or writes the raw chunk stream or the
452
+ * stream's offset window, so it cannot regress any existing chunk reader. It
453
+ * auto-opens the stream (default `long-lived`) so the turns hang off a stream
454
+ * row; a lifecycle mismatch throws a {@link TranscriptLifecycleError} before
455
+ * writing anything (lifecycle is first-wins). The batch is atomic: an invalid
456
+ * turn (or any failed write) partway through rolls the whole call back — it
457
+ * records every turn or none. Returns the number of newly-persisted turns.
458
+ */
459
+ recordTurns(stream, turns, lifecycle = "long-lived") {
460
+ const meta = this.open(stream, lifecycle);
461
+ if (meta.lifecycle !== lifecycle) {
462
+ throw new TranscriptLifecycleError(stream, `refusing to record turns into "${stream}" with lifecycle=${lifecycle}; the stream is ${meta.lifecycle} (lifecycle is first-wins)`);
463
+ }
464
+ const at = new Date(this.#clock.now()).toISOString();
465
+ return this.#atomic(() => {
466
+ let written = 0;
467
+ for (const turn of turns) {
468
+ if (!isPlainObject(turn)) {
469
+ throw new TranscriptCorruptionError(`transcript turn must be an object, got ${JSON.stringify(turn)}`);
470
+ }
471
+ if (!isRecordableOffset(turn.sequence)) {
472
+ throw new RangeError(`transcript turn sequence must be a non-negative safe integer below Number.MAX_SAFE_INTEGER, got ${turn.sequence}`);
473
+ }
474
+ if (!isNonNegInt(turn.loopIteration)) {
475
+ throw new RangeError(`transcript turn loopIteration must be a non-negative safe integer, got ${turn.loopIteration}`);
476
+ }
477
+ const role = toTurnRole(turn.role);
478
+ if (!Array.isArray(turn.content)) {
479
+ throw new TranscriptCorruptionError(`transcript turn content must be an array, got ${JSON.stringify(turn.content)}`);
480
+ }
481
+ if (!Array.isArray(turn.toolCalls)) {
482
+ throw new TranscriptCorruptionError(`transcript turn toolCalls must be an array, got ${JSON.stringify(turn.toolCalls)}`);
483
+ }
484
+ const content = stringifyJson(turn.content.map(toContentBlock), "content");
485
+ const toolCalls = stringifyJson(turn.toolCalls.map(toToolCall), "toolCalls");
486
+ const metrics = turn.metrics === undefined ? null : stringifyJson(toMetrics(turn.metrics), "metrics");
487
+ let producedAt = null;
488
+ if (turn.producedAt !== undefined) {
489
+ if (!isNonNegInt(turn.producedAt)) {
490
+ throw new RangeError(`transcript turn producedAt must be a non-negative safe integer (epoch millis), got ${turn.producedAt}`);
491
+ }
492
+ producedAt = turn.producedAt;
493
+ }
494
+ const { changes } = this.#db.run(`INSERT INTO ${TRANSCRIPT_TURN_TABLE}
495
+ (stream, turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at, recorded_at)
496
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
497
+ ON CONFLICT(stream, turn_sequence) DO NOTHING`, [stream, turn.sequence, turn.loopIteration, role, content, toolCalls, metrics, producedAt, at]);
498
+ written += changes;
499
+ }
500
+ return written;
501
+ });
502
+ }
503
+ /** Read a stream's whole turn-structured transcript in `sequence` order. */
504
+ readTurns(stream) {
505
+ return this.#db
506
+ .all(`SELECT turn_sequence, loop_iteration, role, content, tool_calls, metrics, produced_at
507
+ FROM ${TRANSCRIPT_TURN_TABLE} WHERE stream = ? ORDER BY turn_sequence`, [stream])
508
+ .map(toTurn);
509
+ }
231
510
  /**
232
511
  * Apply a rolling retention window to a long-lived stream: drop every chunk with
233
512
  * `offset < before`. A subsequent {@link since} from an offset older than
@@ -253,8 +532,9 @@ export class TranscriptStore {
253
532
  /**
254
533
  * Retention sweep for completed ephemeral transcripts: drop every stream whose
255
534
  * `status = 'completed'` and whose `completed_at` is older than the retention
256
- * window, along with its chunks. Long-lived streams are never time-swept (they
257
- * are bounded by {@link truncateBefore} instead). Returns the removed stream ids.
535
+ * window, along with its chunks and structured turns. Long-lived streams are
536
+ * never time-swept (they are bounded by {@link truncateBefore} instead). Returns
537
+ * the removed stream ids.
258
538
  *
259
539
  * The selection and all deletes run inside a single SAVEPOINT (#atomic) so the
260
540
  * sweep is all-or-nothing: if any delete throws mid-sweep the whole batch rolls
@@ -271,6 +551,7 @@ export class TranscriptStore {
271
551
  .map((r) => r.stream);
272
552
  for (const stream of removed) {
273
553
  this.#db.run(`DELETE FROM ${TRANSCRIPT_CHUNK_TABLE} WHERE stream = ?`, [stream]);
554
+ this.#db.run(`DELETE FROM ${TRANSCRIPT_TURN_TABLE} WHERE stream = ?`, [stream]);
274
555
  this.#db.run(`DELETE FROM ${TRANSCRIPT_STREAM_TABLE} WHERE stream = ?`, [stream]);
275
556
  }
276
557
  return removed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/agentic",
3
- "version": "0.1.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Nano agentic protocol (ADR 0056): one app-tier channel carrying agent presence/registry, demand×supply, a shared blackboard and live terminal relay — with the wire contract, channel/hub, family modules and the operator cockpit, as subpath exports.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "transcript",
16
16
  "blackboard",
17
17
  "cockpit",
18
+ "session",
18
19
  "conformance"
19
20
  ],
20
21
  "publishConfig": {
@@ -63,6 +64,21 @@
63
64
  "default": "./dist/relay/index.js"
64
65
  },
65
66
  "./source/relay": "./src/relay/index.ts",
67
+ "./session": {
68
+ "types": "./dist/session/index.d.ts",
69
+ "default": "./dist/session/index.js"
70
+ },
71
+ "./source/session": "./src/session/index.ts",
72
+ "./session/acp": {
73
+ "types": "./dist/session/acp/index.d.ts",
74
+ "default": "./dist/session/acp/index.js"
75
+ },
76
+ "./source/session/acp": "./src/session/acp/index.ts",
77
+ "./session/normalizer": {
78
+ "types": "./dist/session/normalizer/index.d.ts",
79
+ "default": "./dist/session/normalizer/index.js"
80
+ },
81
+ "./source/session/normalizer": "./src/session/normalizer/index.ts",
66
82
  "./transcript": {
67
83
  "types": "./dist/transcript/index.d.ts",
68
84
  "default": "./dist/transcript/index.js"
@@ -25,8 +25,8 @@ const AMBER_VOCAB: VocabDocument = {
25
25
  };
26
26
  const amberResolver = new VocabResolver(AMBER_VOCAB);
27
27
 
28
- function leaf(taskType: string, process = "p", elementId = "e"): TaskDefinitionLeaf {
29
- return { taskType, process, elementId };
28
+ function leaf(taskType: string, process = "p", elementId = "e", agentic = true): TaskDefinitionLeaf {
29
+ return { taskType, process, elementId, agentic };
30
30
  }
31
31
 
32
32
  function worker(instance: string, cognition: string, family: string, weight?: number): RegisteredWorker {
@@ -155,9 +155,9 @@ test("distinct demand: a token demanded by many elements is one entry", () => {
155
155
  assert.equal(qa.tokens[0].supply, 1);
156
156
  });
157
157
 
158
- test("non-routing-token task types are surfaced but excluded from accounting", () => {
158
+ test("non-agentic (prompt-less) task types are surfaced but excluded from accounting", () => {
159
159
  const report = computeDemandSupply({
160
- taskDefinitions: [leaf("planning.planner"), leaf("legacy:job#bad#token")],
160
+ taskDefinitions: [leaf("planning.planner"), leaf("legacy:job#bad#token", "p", "e", false)],
161
161
  workers: [worker("w", "planning", "gpt")],
162
162
  resolver,
163
163
  });
@@ -169,6 +169,84 @@ test("non-routing-token task types are surfaced but excluded from accounting", (
169
169
  assert.deepEqual(report.missing, []);
170
170
  });
171
171
 
172
+ test("a prompt-less pr.* leaf is nonAgentic (not falsely reported missing)", () => {
173
+ // `pr.retro-record` parses as a routing token, but with no prompt link it is a
174
+ // deterministic in-process worker — it must NOT show as missing agentic demand.
175
+ const report = computeDemandSupply({
176
+ taskDefinitions: [leaf("pr.retro-record", "p", "e", false)],
177
+ workers: [],
178
+ resolver,
179
+ });
180
+ assert.deepEqual(report.nonAgentic, ["pr.retro-record"]);
181
+ assert.deepEqual(report.networks, []);
182
+ assert.deepEqual(report.missing, []);
183
+ assert.equal(report.status, "green");
184
+ });
185
+
186
+ test("a prompt-bearing colon-form fleet type is agentic demand, not nonAgentic", () => {
187
+ // `senior:retro` / `senior:rebase` do not parse as routing tokens, but carry a
188
+ // prompt link → they must appear as agentic demand (bucketed), never dropped.
189
+ const report = computeDemandSupply({
190
+ taskDefinitions: [leaf("senior:retro"), leaf("senior:rebase")],
191
+ workers: [],
192
+ resolver,
193
+ });
194
+ assert.deepEqual(report.nonAgentic, []);
195
+ assert.deepEqual(
196
+ report.networks.map((n) => n.network),
197
+ ["senior"],
198
+ );
199
+ const senior = report.networks[0];
200
+ assert.deepEqual(
201
+ senior.tokens.map((t) => t.token),
202
+ ["senior:rebase", "senior:retro"],
203
+ );
204
+ assert.deepEqual(senior.missing, ["senior:rebase", "senior:retro"]);
205
+ });
206
+
207
+ // Defect-class guard: classification must follow the prompt signal, NOT the
208
+ // token string. This table pairs adversarial type strings (colon-form, dot-form,
209
+ // bare, malformed) with a hasPrompt flag and asserts agentic-ness tracks the
210
+ // flag alone — locking the class so a future naming convention can't re-invert
211
+ // the buckets.
212
+ const CLASSIFICATION_CASES: ReadonlyArray<{ type: string; hasPrompt: boolean }> = [
213
+ { type: "senior:retro", hasPrompt: true },
214
+ { type: "senior:retro", hasPrompt: false },
215
+ { type: "planning.planner", hasPrompt: true },
216
+ { type: "planning.planner", hasPrompt: false },
217
+ { type: "pr.retro-record", hasPrompt: true },
218
+ { type: "pr.retro-record", hasPrompt: false },
219
+ { type: "decide", hasPrompt: true },
220
+ { type: "decide", hasPrompt: false },
221
+ { type: "legacy:job#bad#token", hasPrompt: true },
222
+ { type: "legacy:job#bad#token", hasPrompt: false },
223
+ { type: "UPPER::weird!!", hasPrompt: true },
224
+ { type: "UPPER::weird!!", hasPrompt: false },
225
+ { type: "", hasPrompt: true },
226
+ ];
227
+
228
+ for (const { type, hasPrompt } of CLASSIFICATION_CASES) {
229
+ test(`classification follows the prompt signal, not the token: ${JSON.stringify(type)} hasPrompt=${hasPrompt}`, () => {
230
+ const report = computeDemandSupply({
231
+ taskDefinitions: [{ taskType: type, process: "p", elementId: "e", agentic: hasPrompt }],
232
+ workers: [],
233
+ resolver,
234
+ });
235
+ if (hasPrompt) {
236
+ // Prompt-bearing → agentic demand: bucketed, never dumped in nonAgentic,
237
+ // and admitted without throwing regardless of how pathological the string.
238
+ assert.deepEqual(report.nonAgentic, []);
239
+ assert.equal(report.networks.length, 1);
240
+ assert.equal(report.networks[0].tokens[0].token, type);
241
+ } else {
242
+ // Prompt-less → nonAgentic, whatever the string parses to.
243
+ assert.deepEqual(report.nonAgentic, [type]);
244
+ assert.deepEqual(report.networks, []);
245
+ assert.deepEqual(report.missing, []);
246
+ }
247
+ });
248
+ }
249
+
172
250
  test("the report is deterministic: lists sorted regardless of input order", () => {
173
251
  const a = computeDemandSupply({
174
252
  taskDefinitions: [leaf("qa.tester"), leaf("ci.runner"), leaf("planning.planner")],
@@ -72,10 +72,13 @@ export interface DemandSupplyReport {
72
72
  /** The overall SLO state: worst of the missing-agent signal and diversity. */
73
73
  readonly status: SloStatus;
74
74
  /**
75
- * Deployed `taskDefinition` types that are NOT valid routing tokens — ordinary
76
- * (non-agentic) C8 jobs the engine also runs. Surfaced (not silently dropped)
77
- * so an operator can spot a mistyped agentic token, but excluded from the
78
- * agentic demand×supply accounting.
75
+ * Deployed `taskDefinition` types that carry NO `linkName="prompt"` linked
76
+ * resource — ordinary (non-agentic) in-process C8 jobs the engine also runs
77
+ * (e.g. the deterministic `pr.*` workers). Classification is by the prompt
78
+ * signal, not the token string, so a prompt-less type is `nonAgentic` even when
79
+ * it happens to parse as a routing token. Surfaced (not silently dropped) so an
80
+ * operator can spot a mis-modelled task, but excluded from the agentic
81
+ * demand×supply accounting.
79
82
  */
80
83
  readonly nonAgentic: readonly string[];
81
84
  }
@@ -90,14 +93,23 @@ export interface DemandSupplyInput {
90
93
  readonly resolver: VocabResolver;
91
94
  }
92
95
 
93
- /** A demanded token's network-prefix bucket, or `undefined` if not a routing token. */
94
- function bucketOf(token: string): string | undefined {
96
+ /**
97
+ * A demanded token's bucket. For a valid routing token this is its network
98
+ * prefix (or a bare token's own role). For an arbitrary agentic type that is not
99
+ * a routing token (colon-form `senior:retro`, etc.) this is best-effort: the
100
+ * segment before the first `:` when present, else the whole type — a synthetic
101
+ * bucket so the leaf still surfaces as demand rather than being dropped. This is
102
+ * only ever called for prompt-bearing (agentic) leaves; non-agentic leaves are
103
+ * classified out before bucketing.
104
+ */
105
+ function bucketOf(token: string): string {
95
106
  try {
96
107
  const parsed = parseToken(token);
97
108
  // A bare (network-less) token like `decide` buckets under its own role name.
98
109
  return parsed.network ?? parsed.role;
99
110
  } catch {
100
- return undefined;
111
+ const colon = token.indexOf(":");
112
+ return colon > 0 ? token.slice(0, colon) : token;
101
113
  }
102
114
  }
103
115
 
@@ -127,15 +139,24 @@ export function computeDemandSupply(input: DemandSupplyInput): DemandSupplyRepor
127
139
  }
128
140
 
129
141
  const demandTokens = distinctTaskTypes(input.taskDefinitions);
142
+ // Agentic-ness is the prompt signal, OR-folded across every leaf of a type: a
143
+ // type is agentic demand iff at least one deployed leaf carries a
144
+ // `linkName="prompt"` linked resource. This is independent of the type string,
145
+ // so colon-form fleet types (`senior:retro`) are admitted and prompt-less
146
+ // deterministic `pr.*` tasks are excluded — regardless of token grammar.
147
+ const agenticByToken = new Map<string, boolean>();
148
+ for (const leaf of input.taskDefinitions) {
149
+ agenticByToken.set(leaf.taskType, (agenticByToken.get(leaf.taskType) ?? false) || leaf.agentic);
150
+ }
130
151
  const byNetwork = new Map<string, Map<string, TokenDemand>>();
131
152
  const nonAgentic: string[] = [];
132
153
 
133
154
  for (const token of demandTokens) {
134
- const network = bucketOf(token);
135
- if (network === undefined) {
155
+ if (!(agenticByToken.get(token) ?? false)) {
136
156
  nonAgentic.push(token);
137
157
  continue;
138
158
  }
159
+ const network = bucketOf(token);
139
160
  const instances = sortedUnique(supplyByToken.get(token) ?? []);
140
161
  const demand: TokenDemand = {
141
162
  token,
@@ -27,8 +27,8 @@ const MODEL = `<?xml version="1.0" encoding="UTF-8"?>
27
27
  test("scans taskDefinition leaves with process and element provenance", () => {
28
28
  const leaves = scanTaskDefinitions(MODEL);
29
29
  assert.deepEqual(leaves, [
30
- { taskType: "planning.planner", process: "plan-fanout", elementId: "plan" },
31
- { taskType: "qa.tester", process: "plan-fanout", elementId: "test" },
30
+ { taskType: "planning.planner", process: "plan-fanout", elementId: "plan", agentic: false },
31
+ { taskType: "qa.tester", process: "plan-fanout", elementId: "test", agentic: false },
32
32
  ]);
33
33
  });
34
34
 
@@ -69,17 +69,62 @@ test("tolerates whitespace around attribute equals signs (id / type / process id
69
69
  </bpmn:definitions>`;
70
70
  const leaves = scanTaskDefinitions(xml);
71
71
  assert.deepEqual(leaves, [
72
- { taskType: "ci.runner", process: "spaced-proc", elementId: "t" },
72
+ { taskType: "ci.runner", process: "spaced-proc", elementId: "t", agentic: false },
73
73
  ]);
74
74
  });
75
75
 
76
76
  test("distinctTaskTypes de-duplicates in first-occurrence order", () => {
77
77
  assert.deepEqual(
78
78
  distinctTaskTypes([
79
- { taskType: "b", process: "p", elementId: "1" },
80
- { taskType: "a", process: "p", elementId: "2" },
81
- { taskType: "b", process: "p", elementId: "3" },
79
+ { taskType: "b", process: "p", elementId: "1", agentic: false },
80
+ { taskType: "a", process: "p", elementId: "2", agentic: false },
81
+ { taskType: "b", process: "p", elementId: "3", agentic: false },
82
82
  ]),
83
83
  ["b", "a"],
84
84
  );
85
85
  });
86
+
87
+ test("marks a leaf agentic when it carries a linkName=\"prompt\" linked resource", () => {
88
+ const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
89
+ <bpmn:process id="nwf-retro">
90
+ <bpmn:serviceTask id="retro">
91
+ <bpmn:extensionElements>
92
+ <zeebe:taskDefinition type="senior:retro" />
93
+ <zeebe:linkedResources>
94
+ <zeebe:linkedResource resourceId="retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
95
+ </zeebe:linkedResources>
96
+ </bpmn:extensionElements>
97
+ </bpmn:serviceTask>
98
+ <bpmn:serviceTask id="gather">
99
+ <bpmn:extensionElements>
100
+ <zeebe:taskDefinition type="pr.retro-gather" />
101
+ </bpmn:extensionElements>
102
+ </bpmn:serviceTask>
103
+ </bpmn:process>
104
+ </bpmn:definitions>`;
105
+ const leaves = scanTaskDefinitions(xml);
106
+ assert.deepEqual(leaves, [
107
+ { taskType: "senior:retro", process: "nwf-retro", elementId: "retro", agentic: true },
108
+ { taskType: "pr.retro-gather", process: "nwf-retro", elementId: "gather", agentic: false },
109
+ ]);
110
+ });
111
+
112
+ test("prompt detection tolerates attribute ordering (linkName before resourceId)", () => {
113
+ const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
114
+ <bpmn:serviceTask id="t">
115
+ <zeebe:taskDefinition type="senior:feature" />
116
+ <zeebe:linkedResource linkName="prompt" resourceId="feature.md" />
117
+ </bpmn:serviceTask>
118
+ </bpmn:definitions>`;
119
+ assert.equal(scanTaskDefinitions(xml)[0].agentic, true);
120
+ });
121
+
122
+ test("a non-prompt linked resource (other linkName) does not mark a leaf agentic", () => {
123
+ const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
124
+ <bpmn:serviceTask id="t">
125
+ <zeebe:taskDefinition type="senior:feature" />
126
+ <zeebe:linkedResource resourceId="schema.md" linkName="schema" />
127
+ </bpmn:serviceTask>
128
+ </bpmn:definitions>`;
129
+ assert.equal(scanTaskDefinitions(xml)[0].agentic, false);
130
+ });