@nanobpm/agentic 0.1.0 → 0.4.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 (117) hide show
  1. package/README.md +1 -0
  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/package.json +17 -1
  59. package/src/demand/model.test.ts +82 -4
  60. package/src/demand/model.ts +30 -9
  61. package/src/demand/taskdef.test.ts +51 -6
  62. package/src/demand/taskdef.ts +31 -2
  63. package/src/index.ts +1 -0
  64. package/src/protocol/conformance/frames.ts +32 -4
  65. package/src/protocol/index.ts +4 -0
  66. package/src/protocol/payloads.test.ts +31 -1
  67. package/src/protocol/payloads.ts +110 -7
  68. package/src/session/acp/client.test.ts +222 -0
  69. package/src/session/acp/client.ts +356 -0
  70. package/src/session/acp/fake-agent.ts +71 -0
  71. package/src/session/acp/index.ts +68 -0
  72. package/src/session/acp/integration.test.ts +37 -0
  73. package/src/session/acp/jsonrpc.test.ts +75 -0
  74. package/src/session/acp/jsonrpc.ts +171 -0
  75. package/src/session/acp/normalize.test.ts +150 -0
  76. package/src/session/acp/normalize.ts +204 -0
  77. package/src/session/acp/protocol.ts +178 -0
  78. package/src/session/acp/spawn.test.ts +45 -0
  79. package/src/session/acp/spawn.ts +91 -0
  80. package/src/session/acp/transport.test.ts +82 -0
  81. package/src/session/acp/transport.ts +155 -0
  82. package/src/session/adapter.ts +159 -0
  83. package/src/session/backend.test.ts +198 -0
  84. package/src/session/backend.ts +128 -0
  85. package/src/session/events.test.ts +168 -0
  86. package/src/session/events.ts +347 -0
  87. package/src/session/index.ts +67 -0
  88. package/src/session/log.test.ts +215 -0
  89. package/src/session/log.ts +525 -0
  90. package/src/session/normalizer/backend-integration.test.ts +103 -0
  91. package/src/session/normalizer/claude.test.ts +68 -0
  92. package/src/session/normalizer/claude.ts +136 -0
  93. package/src/session/normalizer/copilot.test.ts +59 -0
  94. package/src/session/normalizer/copilot.ts +133 -0
  95. package/src/session/normalizer/deepseek.ts +80 -0
  96. package/src/session/normalizer/index.ts +61 -0
  97. package/src/session/normalizer/kimi.ts +82 -0
  98. package/src/session/normalizer/link.test.ts +24 -0
  99. package/src/session/normalizer/link.ts +81 -0
  100. package/src/session/normalizer/pi.ts +75 -0
  101. package/src/session/normalizer/probe.test.ts +49 -0
  102. package/src/session/normalizer/qwen.test.ts +20 -0
  103. package/src/session/normalizer/qwen.ts +77 -0
  104. package/src/session/normalizer/record.test.ts +68 -0
  105. package/src/session/normalizer/record.ts +88 -0
  106. package/src/session/normalizer/resume.test.ts +25 -0
  107. package/src/session/normalizer/types.ts +152 -0
  108. package/src/session/normalizer/vectors.test.ts +180 -0
  109. package/src/session/schema.test.ts +84 -0
  110. package/src/session/schema.ts +78 -0
  111. package/src/session/test-db.ts +56 -0
  112. package/dist/blackboard/test-db.d.ts +0 -5
  113. package/dist/blackboard/test-db.js +0 -42
  114. package/dist/presence/test-db.d.ts +0 -5
  115. package/dist/presence/test-db.js +0 -42
  116. package/dist/transcript/test-db.d.ts +0 -5
  117. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,351 @@
1
+ /**
2
+ * The authoritative session-log store — ADR 0062, slice 1.
3
+ *
4
+ * This **promotes** the advisory relay substrate (ADR 0056 §12: the bounded
5
+ * replay ring's resume-from-offset, plus generation/incarnation fencing) into an
6
+ * *authoritative* per-activation log. Two things change on promotion:
7
+ *
8
+ * - **Unbounded, durable retention.** The relay ring is a bounded in-memory
9
+ * resume window that evicts; the authoritative log retains every event of an
10
+ * activation (lifecycle-bounded like the S6 transcript, swept only when the
11
+ * activation is retired), so `restore` can always replay from offset 0.
12
+ * - **Durable fencing.** The relay {@link IncarnationFence} (which this module
13
+ * reuses verbatim for the in-memory backend) lives in memory; the SQLite
14
+ * backend persists the same high-water mark in the activation row's
15
+ * `incarnation` column, so a stale writer is fenced even across a restart.
16
+ *
17
+ * Transport is never re-implemented — this is a storage layer over the app
18
+ * DataLayer (or memory), exactly as the transcript store is. Nothing here rides
19
+ * the Camunda-8 engine (ADR 0056 boundary preserved).
20
+ */
21
+ import { IncarnationFence } from "../relay/incarnation.js";
22
+ import { activationKeyString, StaleIncarnationError, } from "./adapter.js";
23
+ import { parseSessionEvent } from "./events.js";
24
+ import { SESSION_CHECKPOINT_TABLE, SESSION_EVENT_TABLE, SESSION_LOG_TABLE, SESSION_SCHEMA_SQL, } from "./schema.js";
25
+ function isNonNegInt(value) {
26
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
27
+ }
28
+ function assertOffset(offset) {
29
+ if (!isNonNegInt(offset)) {
30
+ throw new RangeError(`session log offset must be a non-negative safe integer, got ${offset}`);
31
+ }
32
+ }
33
+ /**
34
+ * Validate a `replay(from, to)` window: `from` is a normal offset, and when a
35
+ * bounded `to` is given it must itself be a valid offset that is not below
36
+ * `from` — so an out-of-range or inverted bound fails fast instead of silently
37
+ * yielding a surprising `Array.slice`/SQL range.
38
+ */
39
+ function assertReplayBounds(from, to) {
40
+ assertOffset(from);
41
+ if (to === undefined)
42
+ return;
43
+ if (!isNonNegInt(to)) {
44
+ throw new RangeError(`session log replay 'to' must be a non-negative safe integer, got ${to}`);
45
+ }
46
+ if (to < from) {
47
+ throw new RangeError(`session log replay 'to' (${to}) must be >= 'from' (${from})`);
48
+ }
49
+ }
50
+ /**
51
+ * A checkpoint's own {@link SessionCheckpoint.incarnation} must equal the fence
52
+ * token it is written under — otherwise the row would be fenced with one
53
+ * incarnation but stamped with another, producing inconsistent durable data.
54
+ */
55
+ function assertCheckpointIncarnation(incarnation, checkpoint) {
56
+ if (checkpoint.incarnation !== incarnation) {
57
+ throw new RangeError(`checkpoint ${checkpoint.id} incarnation (${checkpoint.incarnation}) must equal the write lease incarnation (${incarnation})`);
58
+ }
59
+ }
60
+ /**
61
+ * The in-memory reference backend (the stub slices 2–5 code against and the tests
62
+ * exercise). Reuses the relay {@link IncarnationFence} verbatim and keeps each
63
+ * activation's full event array — the authoritative, non-evicting analogue of the
64
+ * relay ring's resume window.
65
+ */
66
+ export class InMemorySessionLog {
67
+ #fence = new IncarnationFence();
68
+ #events = new Map();
69
+ #checkpoints = new Map();
70
+ #eventsFor(key) {
71
+ const s = activationKeyString(key);
72
+ let arr = this.#events.get(s);
73
+ if (arr === undefined) {
74
+ arr = [];
75
+ this.#events.set(s, arr);
76
+ }
77
+ return arr;
78
+ }
79
+ #checkpointsFor(key) {
80
+ const s = activationKeyString(key);
81
+ let arr = this.#checkpoints.get(s);
82
+ if (arr === undefined) {
83
+ arr = [];
84
+ this.#checkpoints.set(s, arr);
85
+ }
86
+ return arr;
87
+ }
88
+ #admit(key, incarnation) {
89
+ const s = activationKeyString(key);
90
+ if (!this.#fence.admit(s, incarnation)) {
91
+ throw new StaleIncarnationError(key, incarnation, this.#fence.current(s) ?? incarnation);
92
+ }
93
+ }
94
+ lease(key, incarnation) {
95
+ this.#admit(key, incarnation);
96
+ }
97
+ currentIncarnation(key) {
98
+ return this.#fence.current(activationKeyString(key));
99
+ }
100
+ nextOffset(key) {
101
+ return this.#eventsFor(key).length;
102
+ }
103
+ append(key, incarnation, offset, event) {
104
+ assertOffset(offset);
105
+ this.#admit(key, incarnation);
106
+ const arr = this.#eventsFor(key);
107
+ if (offset > arr.length) {
108
+ throw new RangeError(`session log gap: append at offset ${offset} but next offset is ${arr.length}`);
109
+ }
110
+ if (offset < arr.length) {
111
+ // Resuming: drop the now-superseded uncommitted tail before re-keying, and
112
+ // prune any checkpoint pinned above the resume boundary — it now points
113
+ // past the rewritten head and would mis-seed a later restore.
114
+ arr.length = offset;
115
+ this.#pruneCheckpointsAbove(key, offset);
116
+ }
117
+ const appended = { ...event, offset, incarnation };
118
+ arr.push(appended);
119
+ return appended;
120
+ }
121
+ /** Drop checkpoints whose offset sits above `offset` (past a rewritten head). */
122
+ #pruneCheckpointsAbove(key, offset) {
123
+ const arr = this.#checkpointsFor(key);
124
+ for (let i = arr.length - 1; i >= 0; i--) {
125
+ if (arr[i].offset > offset)
126
+ arr.splice(i, 1);
127
+ }
128
+ }
129
+ putCheckpoint(key, incarnation, checkpoint) {
130
+ this.#admit(key, incarnation);
131
+ assertCheckpointIncarnation(incarnation, checkpoint);
132
+ const arr = this.#checkpointsFor(key);
133
+ // First-wins on checkpoint.id — mirrors the durable backend's
134
+ // ON CONFLICT(checkpoint_id) DO NOTHING so a retry never duplicates.
135
+ if (arr.some((cp) => cp.id === checkpoint.id))
136
+ return checkpoint;
137
+ arr.push(checkpoint);
138
+ return checkpoint;
139
+ }
140
+ latestCheckpoint(key) {
141
+ let best;
142
+ for (const cp of this.#checkpointsFor(key)) {
143
+ // Highest offset wins; a later insert at the same offset supersedes.
144
+ if (best === undefined || cp.offset >= best.offset)
145
+ best = cp;
146
+ }
147
+ return best;
148
+ }
149
+ getCheckpoint(key, id) {
150
+ return this.#checkpointsFor(key).find((cp) => cp.id === id);
151
+ }
152
+ replay(key, from, to) {
153
+ assertReplayBounds(from, to);
154
+ const arr = this.#eventsFor(key);
155
+ const end = to === undefined ? arr.length : to;
156
+ return arr.slice(from, end);
157
+ }
158
+ }
159
+ /** The default clock: `Date.now()`. */
160
+ export const systemClock = { now: () => Date.now() };
161
+ function isRecord(value) {
162
+ return typeof value === "object" && value !== null && !Array.isArray(value);
163
+ }
164
+ function isEffectLedger(value) {
165
+ if (!Array.isArray(value))
166
+ return false;
167
+ return value.every((entry) => isRecord(entry) && typeof entry.id === "string" && typeof entry.kind === "string");
168
+ }
169
+ /**
170
+ * The durable authoritative log over the app DataLayer/SQLite. The fence
171
+ * high-water lives in the activation row's `incarnation` column, so fencing
172
+ * survives a process restart — the durable counterpart of the in-memory
173
+ * {@link IncarnationFence}.
174
+ */
175
+ export class SqliteSessionLog {
176
+ #db;
177
+ #clock;
178
+ constructor(db, options = {}) {
179
+ this.#db = db;
180
+ this.#clock = options.clock ?? systemClock;
181
+ }
182
+ /** Apply the canonical DDL (idempotent). Identical to the boot migration (drift-guarded). */
183
+ ensureSchema() {
184
+ this.#db.exec(SESSION_SCHEMA_SQL);
185
+ }
186
+ #logRow(key) {
187
+ return this.#db.all(`SELECT incarnation, next_offset FROM ${SESSION_LOG_TABLE} WHERE process_instance_key = ? AND element_id = ?`, [key.processInstanceKey, key.elementId])[0];
188
+ }
189
+ #admit(key, incarnation) {
190
+ if (!isNonNegInt(incarnation)) {
191
+ throw new RangeError(`incarnation must be a non-negative safe integer, got ${incarnation}`);
192
+ }
193
+ // Insert the activation row if it is missing, or advance its fence high-water when
194
+ // this lease is newer — as one atomic UPSERT. A plain ON CONFLICT DO NOTHING would
195
+ // let a concurrent first-lease race slip through: if another writer created the row
196
+ // between a pre-read and this INSERT, DO NOTHING would neither advance the fence nor
197
+ // reject a stale lease. Re-read and assert afterwards (insert-then-get, mirroring the
198
+ // transcript store) so a lease below the stored high-water is always fenced out.
199
+ this.#db.run(`INSERT INTO ${SESSION_LOG_TABLE} (process_instance_key, element_id, incarnation, created_at, next_offset)
200
+ VALUES (?, ?, ?, ?, 0)
201
+ ON CONFLICT(process_instance_key, element_id)
202
+ DO UPDATE SET incarnation = excluded.incarnation
203
+ WHERE excluded.incarnation > ${SESSION_LOG_TABLE}.incarnation`, [key.processInstanceKey, key.elementId, incarnation, new Date(this.#clock.now()).toISOString()]);
204
+ const row = this.#logRow(key);
205
+ if (row === undefined) {
206
+ throw new Error(`session log row vanished immediately after admit: ${activationKeyString(key)}`);
207
+ }
208
+ if (incarnation < row.incarnation) {
209
+ throw new StaleIncarnationError(key, incarnation, row.incarnation);
210
+ }
211
+ }
212
+ lease(key, incarnation) {
213
+ this.#admit(key, incarnation);
214
+ }
215
+ currentIncarnation(key) {
216
+ return this.#logRow(key)?.incarnation;
217
+ }
218
+ nextOffset(key) {
219
+ return this.#logRow(key)?.next_offset ?? 0;
220
+ }
221
+ append(key, incarnation, offset, event) {
222
+ assertOffset(offset);
223
+ this.#admit(key, incarnation);
224
+ const next = this.nextOffset(key);
225
+ if (offset > next) {
226
+ throw new RangeError(`session log gap: append at offset ${offset} but next offset is ${next}`);
227
+ }
228
+ const appended = { ...event, offset, incarnation };
229
+ return this.#atomic(() => {
230
+ if (offset < next) {
231
+ // Resuming: drop the now-superseded uncommitted tail before re-keying.
232
+ this.#db.run(`DELETE FROM ${SESSION_EVENT_TABLE} WHERE process_instance_key = ? AND element_id = ? AND event_offset >= ?`, [key.processInstanceKey, key.elementId, offset]);
233
+ // Prune checkpoints pinned above the resume boundary: they now point
234
+ // past the rewritten head and would mis-seed a later restore (a gap
235
+ // RangeError on the next emit).
236
+ this.#db.run(`DELETE FROM ${SESSION_CHECKPOINT_TABLE} WHERE process_instance_key = ? AND element_id = ? AND checkpoint_offset > ?`, [key.processInstanceKey, key.elementId, offset]);
237
+ }
238
+ this.#db.run(`INSERT INTO ${SESSION_EVENT_TABLE}
239
+ (process_instance_key, element_id, event_offset, incarnation, event_id, parent_id, event_type, payload, appended_at)
240
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
241
+ key.processInstanceKey,
242
+ key.elementId,
243
+ offset,
244
+ incarnation,
245
+ event.id,
246
+ event.parentId,
247
+ event.type,
248
+ JSON.stringify(appended),
249
+ new Date(this.#clock.now()).toISOString(),
250
+ ]);
251
+ this.#advanceWindow(key, offset);
252
+ return appended;
253
+ });
254
+ }
255
+ putCheckpoint(key, incarnation, checkpoint) {
256
+ this.#admit(key, incarnation);
257
+ assertCheckpointIncarnation(incarnation, checkpoint);
258
+ this.#db.run(`INSERT INTO ${SESSION_CHECKPOINT_TABLE}
259
+ (process_instance_key, element_id, checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at)
260
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
261
+ ON CONFLICT(process_instance_key, element_id, checkpoint_id) DO NOTHING`, [
262
+ key.processInstanceKey,
263
+ key.elementId,
264
+ checkpoint.id,
265
+ checkpoint.offset,
266
+ incarnation,
267
+ checkpoint.commitSha,
268
+ JSON.stringify(checkpoint.effectLedger),
269
+ checkpoint.at,
270
+ ]);
271
+ return checkpoint;
272
+ }
273
+ latestCheckpoint(key) {
274
+ const row = this.#db.all(`SELECT checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at
275
+ FROM ${SESSION_CHECKPOINT_TABLE}
276
+ WHERE process_instance_key = ? AND element_id = ?
277
+ ORDER BY checkpoint_offset DESC, rowid DESC LIMIT 1`, [key.processInstanceKey, key.elementId])[0];
278
+ return row === undefined ? undefined : this.#toCheckpoint(row);
279
+ }
280
+ getCheckpoint(key, id) {
281
+ const row = this.#db.all(`SELECT checkpoint_id, checkpoint_offset, incarnation, commit_sha, effect_ledger, created_at
282
+ FROM ${SESSION_CHECKPOINT_TABLE}
283
+ WHERE process_instance_key = ? AND element_id = ? AND checkpoint_id = ?`, [key.processInstanceKey, key.elementId, id])[0];
284
+ return row === undefined ? undefined : this.#toCheckpoint(row);
285
+ }
286
+ #toCheckpoint(row) {
287
+ const ledgerRaw = JSON.parse(row.effect_ledger);
288
+ if (!isEffectLedger(ledgerRaw)) {
289
+ throw new SessionLogCorruptionError(`checkpoint ${row.checkpoint_id} effect_ledger is not a valid EffectLedger`);
290
+ }
291
+ return {
292
+ id: row.checkpoint_id,
293
+ offset: row.checkpoint_offset,
294
+ commitSha: row.commit_sha,
295
+ effectLedger: ledgerRaw,
296
+ incarnation: row.incarnation,
297
+ at: row.created_at,
298
+ };
299
+ }
300
+ replay(key, from, to) {
301
+ assertReplayBounds(from, to);
302
+ const upper = to === undefined ? Number.MAX_SAFE_INTEGER : to;
303
+ return this.#db
304
+ .all(`SELECT event_offset, incarnation, payload FROM ${SESSION_EVENT_TABLE}
305
+ WHERE process_instance_key = ? AND element_id = ? AND event_offset >= ? AND event_offset < ?
306
+ ORDER BY event_offset`, [key.processInstanceKey, key.elementId, from, upper])
307
+ .map((row) => {
308
+ const parsed = JSON.parse(row.payload);
309
+ const event = parseSessionEvent(parsed);
310
+ return { ...event, offset: row.event_offset, incarnation: row.incarnation };
311
+ });
312
+ }
313
+ /**
314
+ * Advance an activation's retained offset window (`first_offset`/`next_offset`,
315
+ * keyed by ActivationKey) to include the offset just appended. `append` always
316
+ * truncates the tail (`event_offset >= offset`) before inserting at `offset`, so
317
+ * the freshly stored offset is necessarily the new maximum (`next_offset =
318
+ * offset + 1`) and the minimum can only move down. Updating incrementally from
319
+ * the appended offset therefore keeps the window exact in O(1) — a full MIN/MAX
320
+ * scan of the event table on every append would be O(n) and make appends O(n²)
321
+ * as the log grows.
322
+ */
323
+ #advanceWindow(key, offset) {
324
+ this.#db.run(`UPDATE ${SESSION_LOG_TABLE}
325
+ SET first_offset = MIN(COALESCE(first_offset, ?), ?), next_offset = ?
326
+ WHERE process_instance_key = ? AND element_id = ?`, [offset, offset, offset + 1, key.processInstanceKey, key.elementId]);
327
+ }
328
+ #atomic(body) {
329
+ this.#db.exec("SAVEPOINT nano_session_atomic");
330
+ try {
331
+ const result = body();
332
+ this.#db.exec("RELEASE SAVEPOINT nano_session_atomic");
333
+ return result;
334
+ }
335
+ catch (err) {
336
+ this.#db.exec("ROLLBACK TO SAVEPOINT nano_session_atomic");
337
+ this.#db.exec("RELEASE SAVEPOINT nano_session_atomic");
338
+ throw err;
339
+ }
340
+ }
341
+ }
342
+ /**
343
+ * Raised when a row read back from the durable session log holds a value outside
344
+ * its domain (e.g. a corrupt effect-ledger JSON). Fail fast rather than coercing.
345
+ */
346
+ export class SessionLogCorruptionError extends Error {
347
+ constructor(message) {
348
+ super(message);
349
+ this.name = "SessionLogCorruptionError";
350
+ }
351
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Claude Code normalizer — ADR 0062 slice 3, reference dialect B.
3
+ *
4
+ * Driven with `claude -p --output-format stream-json` (paired with
5
+ * `--input-format stream-json` to feed it), Claude Code streams one JSON object
6
+ * per line. A turn arrives as an `assistant` frame whose `message.content` is an
7
+ * array of typed parts — `text`, `thinking` (with an encrypted `signature`),
8
+ * `tool_use` — and tool outputs come back as a `user` frame carrying
9
+ * `tool_result` parts. Restore is the native `--resume <id>` (`-c` continues the
10
+ * latest, `--from-pr` seeds from a PR — both resume-by-latest, not by-id, so the
11
+ * id-restore shim is `--resume`). Streaming + resume-by-id → `durable-resume`.
12
+ *
13
+ * ## Resume-critical fidelity: `thinking.signature`
14
+ *
15
+ * Claude's `thinking` parts carry a `signature`: the provider's opaque, encrypted
16
+ * reasoning-continuation token that must be replayed verbatim to continue
17
+ * extended thinking across a resume (ADR 0062 §5). We map it to the canonical
18
+ * `ReasoningEvent.providerContinuation`, so the native transcript remains the
19
+ * authoritative restore path for Claude just as it does for Copilot.
20
+ */
21
+ import { type HarnessNormalizer } from "./types.ts";
22
+ /** The Claude Code fallback normalizer. Streaming + resume-by-id → durable. */
23
+ export declare const claudeNormalizer: HarnessNormalizer;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Claude Code normalizer — ADR 0062 slice 3, reference dialect B.
3
+ *
4
+ * Driven with `claude -p --output-format stream-json` (paired with
5
+ * `--input-format stream-json` to feed it), Claude Code streams one JSON object
6
+ * per line. A turn arrives as an `assistant` frame whose `message.content` is an
7
+ * array of typed parts — `text`, `thinking` (with an encrypted `signature`),
8
+ * `tool_use` — and tool outputs come back as a `user` frame carrying
9
+ * `tool_result` parts. Restore is the native `--resume <id>` (`-c` continues the
10
+ * latest, `--from-pr` seeds from a PR — both resume-by-latest, not by-id, so the
11
+ * id-restore shim is `--resume`). Streaming + resume-by-id → `durable-resume`.
12
+ *
13
+ * ## Resume-critical fidelity: `thinking.signature`
14
+ *
15
+ * Claude's `thinking` parts carry a `signature`: the provider's opaque, encrypted
16
+ * reasoning-continuation token that must be replayed verbatim to continue
17
+ * extended thinking across a resume (ADR 0062 §5). We map it to the canonical
18
+ * `ReasoningEvent.providerContinuation`, so the native transcript remains the
19
+ * authoritative restore path for Claude just as it does for Copilot.
20
+ */
21
+ import { NormalizerDialectError } from "./types.js";
22
+ import { asArray, asRecord, isRecord, optNumber, optString, reqString } from "./record.js";
23
+ const HARNESS = "claude-code";
24
+ function messageContent(obj) {
25
+ const message = obj.message;
26
+ if (!isRecord(message)) {
27
+ throw new NormalizerDialectError(HARNESS, `${String(obj.type)} frame must carry a "message" object`);
28
+ }
29
+ const content = message.content;
30
+ // Claude also permits a bare-string message content for a plain text turn.
31
+ if (typeof content === "string")
32
+ return [{ type: "text", text: content }];
33
+ return asArray(HARNESS, content, "message.content");
34
+ }
35
+ function usageDrafts(usage, model) {
36
+ if (!isRecord(usage))
37
+ return [];
38
+ return [
39
+ {
40
+ type: "usage",
41
+ inputTokens: optNumber(HARNESS, usage, "input_tokens") ?? 0,
42
+ outputTokens: optNumber(HARNESS, usage, "output_tokens") ?? 0,
43
+ ...(model !== undefined ? { model } : {}),
44
+ },
45
+ ];
46
+ }
47
+ function toDrafts(record) {
48
+ const obj = asRecord(HARNESS, record);
49
+ switch (obj.type) {
50
+ case "system":
51
+ // The `init` system frame is session metadata (tools, model, cwd), not a
52
+ // conversational system turn — it carries no canonical text.
53
+ return [];
54
+ case "assistant": {
55
+ const message = isRecord(obj.message) ? obj.message : {};
56
+ const model = optString(HARNESS, message, "model");
57
+ const drafts = [];
58
+ for (const part of messageContent(obj)) {
59
+ if (!isRecord(part))
60
+ continue;
61
+ switch (part.type) {
62
+ case "text": {
63
+ const text = optString(HARNESS, part, "text");
64
+ if (text !== undefined && text.length > 0)
65
+ drafts.push({ type: "assistant", text });
66
+ break;
67
+ }
68
+ case "thinking": {
69
+ const text = optString(HARNESS, part, "thinking");
70
+ const signature = optString(HARNESS, part, "signature");
71
+ drafts.push({
72
+ type: "reasoning",
73
+ ...(text !== undefined ? { text } : {}),
74
+ ...(signature !== undefined ? { providerContinuation: signature } : {}),
75
+ });
76
+ break;
77
+ }
78
+ case "tool_use": {
79
+ const callId = reqString(HARNESS, part, "id");
80
+ drafts.push({
81
+ type: "tool-call",
82
+ id: `call:${callId}`,
83
+ callId,
84
+ name: reqString(HARNESS, part, "name"),
85
+ args: part.input,
86
+ });
87
+ break;
88
+ }
89
+ default:
90
+ break;
91
+ }
92
+ }
93
+ // A turn's usage rides on its assistant message.
94
+ drafts.push(...usageDrafts(message.usage, model));
95
+ return drafts;
96
+ }
97
+ case "user": {
98
+ const drafts = [];
99
+ for (const part of messageContent(obj)) {
100
+ if (!isRecord(part))
101
+ continue;
102
+ if (part.type === "tool_result") {
103
+ const callId = reqString(HARNESS, part, "tool_use_id");
104
+ drafts.push({
105
+ type: "tool-result",
106
+ id: `result:${callId}`,
107
+ callId,
108
+ ok: part.is_error !== true,
109
+ result: part.content,
110
+ });
111
+ }
112
+ else if (part.type === "text") {
113
+ const text = optString(HARNESS, part, "text");
114
+ if (text !== undefined && text.length > 0)
115
+ drafts.push({ type: "user", text });
116
+ }
117
+ }
118
+ return drafts;
119
+ }
120
+ case "result":
121
+ // Terminal accounting frame: fold its top-level usage in whenever present.
122
+ // Like every other frame's usage (and every other normalizer), this maps to
123
+ // its own canonical `usage` event — assistant frames emit their own
124
+ // per-message usage and the linker never dedupes; consumers aggregate.
125
+ return usageDrafts(obj.usage, optString(HARNESS, obj, "model"));
126
+ default:
127
+ return [];
128
+ }
129
+ }
130
+ /** The Claude Code fallback normalizer. Streaming + resume-by-id → durable. */
131
+ export const claudeNormalizer = {
132
+ harness: HARNESS,
133
+ capabilities: { streaming: true, resumeById: true },
134
+ toDrafts,
135
+ resume(sessionId) {
136
+ return { transport: "cli", sessionId, args: ["--resume", sessionId] };
137
+ },
138
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@github/copilot` normalizer — ADR 0062 slice 3, reference dialect A.
3
+ *
4
+ * Copilot runs in-process through `copilot-sdk`: the mind source is its own
5
+ * `SessionEvent` stream (`session.on(...)`, or the `events.jsonl` transcript it
6
+ * writes), and restore is the SDK's `resumeSession(id)` (the `sessionFsProvider`
7
+ * seam is the same call under a different persistence root, so the resume shim is
8
+ * identical). Copilot both streams and resumes-by-id → it advertises
9
+ * `durable-resume`.
10
+ *
11
+ * ## Resume-critical fidelity: `reasoningOpaque`
12
+ *
13
+ * ADR 0062 §5 lets a native adapter *prefer the native transcript for restore*
14
+ * when it carries more than the ACP models. Copilot's reasoning events carry a
15
+ * `reasoningOpaque` blob — the provider reasoning-continuation handle that must
16
+ * be replayed verbatim to continue the model's reasoning across a resume. The
17
+ * canonical `ReasoningEvent` has a home for exactly this (`providerContinuation`),
18
+ * so we map it straight through, untouched. Dropping it (as a lossy ACP
19
+ * projection might) would silently break reasoning continuation on resume — so
20
+ * this dialect is the authoritative ingestion path for Copilot.
21
+ */
22
+ import type { HarnessNormalizer } from "./types.ts";
23
+ /**
24
+ * The `@github/copilot` fallback normalizer. `streaming`/`resumeById` are both
25
+ * true, so {@link capabilityProbe} derives `durable-resume: true`.
26
+ */
27
+ export declare const copilotNormalizer: HarnessNormalizer;
@@ -0,0 +1,105 @@
1
+ import { asRecord, contentText, optNumber, optString, reqString } from "./record.js";
2
+ const HARNESS = "@github/copilot";
3
+ /**
4
+ * Map one `copilot-sdk` `SessionEvent` to canonical drafts. Copilot's event
5
+ * `type`s are close to ours but not identical (`assistant_message` vs
6
+ * `assistant`, `reasoningOpaque` vs `providerContinuation`, `turn_started` vs
7
+ * `turn-start`); this is exactly the per-dialect translation the slice exists to
8
+ * own.
9
+ */
10
+ function toDrafts(record) {
11
+ const obj = asRecord(HARNESS, record);
12
+ switch (obj.type) {
13
+ case "system":
14
+ case "system_message":
15
+ return [{ type: "system", text: reqString(HARNESS, obj, "text") }];
16
+ case "user":
17
+ case "user_message":
18
+ return [{ type: "user", text: reqString(HARNESS, obj, "text") }];
19
+ case "assistant":
20
+ case "assistant_message": {
21
+ const text = contentText(obj.text ?? obj.content);
22
+ return text === undefined ? [] : [{ type: "assistant", text }];
23
+ }
24
+ case "reasoning": {
25
+ const text = optString(HARNESS, obj, "text");
26
+ // Copilot names the continuation blob `reasoningOpaque`; accept the
27
+ // canonical spelling too so a pre-normalized feed round-trips.
28
+ const providerContinuation = optString(HARNESS, obj, "reasoningOpaque") ?? optString(HARNESS, obj, "providerContinuation");
29
+ return [
30
+ {
31
+ type: "reasoning",
32
+ ...(text !== undefined ? { text } : {}),
33
+ ...(providerContinuation !== undefined ? { providerContinuation } : {}),
34
+ },
35
+ ];
36
+ }
37
+ case "tool_call": {
38
+ const callId = reqString(HARNESS, obj, "id");
39
+ return [
40
+ {
41
+ type: "tool-call",
42
+ id: `call:${callId}`,
43
+ callId,
44
+ name: reqString(HARNESS, obj, "name"),
45
+ args: obj.arguments ?? obj.args,
46
+ },
47
+ ];
48
+ }
49
+ case "tool_result": {
50
+ const callId = reqString(HARNESS, obj, "id");
51
+ // Copilot marks failure with an `isError` flag; the payload lives in
52
+ // `output` either way.
53
+ const ok = obj.isError === true ? false : obj.error == null;
54
+ return [
55
+ {
56
+ type: "tool-result",
57
+ id: `result:${callId}`,
58
+ callId,
59
+ ok,
60
+ result: obj.output ?? obj.result ?? obj.error,
61
+ },
62
+ ];
63
+ }
64
+ case "turn_started":
65
+ return [{ type: "turn-start", turn: turnIndex(obj) }];
66
+ case "turn_completed":
67
+ case "turn_ended":
68
+ return [{ type: "turn-end", turn: turnIndex(obj) }];
69
+ case "usage": {
70
+ const model = optString(HARNESS, obj, "model");
71
+ return [
72
+ {
73
+ type: "usage",
74
+ inputTokens: usageCount(obj, "inputTokens", "input_tokens"),
75
+ outputTokens: usageCount(obj, "outputTokens", "output_tokens"),
76
+ ...(model !== undefined ? { model } : {}),
77
+ },
78
+ ];
79
+ }
80
+ default:
81
+ // Transport frames Copilot emits that carry no session-log meaning
82
+ // (heartbeats, session-ready acks) normalize to nothing.
83
+ return [];
84
+ }
85
+ }
86
+ function turnIndex(obj) {
87
+ return optNumber(HARNESS, obj, "turn") ?? 0;
88
+ }
89
+ function usageCount(obj, camel, snake) {
90
+ return optNumber(HARNESS, obj, camel) ?? optNumber(HARNESS, obj, snake) ?? 0;
91
+ }
92
+ /**
93
+ * The `@github/copilot` fallback normalizer. `streaming`/`resumeById` are both
94
+ * true, so {@link capabilityProbe} derives `durable-resume: true`.
95
+ */
96
+ export const copilotNormalizer = {
97
+ harness: HARNESS,
98
+ capabilities: { streaming: true, resumeById: true },
99
+ toDrafts,
100
+ resume(sessionId) {
101
+ // In-process SDK restore: `resumeSession(id)` (the sessionFsProvider seam is
102
+ // the same call under a different persistence root).
103
+ return { transport: "sdk", sessionId, call: "resumeSession", args: [sessionId] };
104
+ },
105
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * DeepSeek Harness normalizer — ADR 0062 slice 3.
3
+ *
4
+ * The DeepSeek Harness exposes a live `SessionEvent` feed rather than a spawned
5
+ * `stream-json` transport: its frames are already event-shaped (`kind`-tagged),
6
+ * so the dialect is a thin renaming onto the canonical union. Restore is the
7
+ * harness's in-process `seed`/`restore` pair, so the resume shim is the SDK
8
+ * `restore(id)` call. Streaming + resume-by-id → `durable-resume`.
9
+ */
10
+ import type { HarnessNormalizer } from "./types.ts";
11
+ export declare const deepseekNormalizer: HarnessNormalizer;