c8ctl-plugin-nano 1.57.1 → 1.58.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.
@@ -0,0 +1,131 @@
1
+ // Producer-side ACP → transcript-chunk mapping that PRESERVES message boundaries
2
+ // (jwulf/c8ctl-plugin-nano#206), built on top of the published shared contract
3
+ // from nanobpm/nano-ide#566 (@nanobpm/agentic >= 0.14.0).
4
+ //
5
+ // The problem: streaming ACP output arrives as arbitrary `agent_message_chunk`
6
+ // deltas whose transport boundaries are NOT message boundaries. The canonical
7
+ // bridge `acpUpdateToTranscriptChunk` (nanobpm/nano-ide#534) folds an ACP
8
+ // `session/update` into the exact transcript-chunk bytes the cockpit decodes, but
9
+ // it drops the ACP `messageId` that the shared classifier already extracts — so a
10
+ // consumer folding those chunks through the shared ordered-display derivation
11
+ // (`deriveDisplay`, #566) cannot tell a continuing delta of ONE message from the
12
+ // first delta of a NEW same-speaker message. Two distinct assistant messages
13
+ // emitted back-to-back would wrongly coalesce into one block; a single message
14
+ // split across chunks reconstructs correctly either way.
15
+ //
16
+ // This module carries the AVAILABLE producer semantics — message identity
17
+ // (`messageId`), role/channel and delta/snapshot mode — into the canonical
18
+ // additive `MessageEvent` fields the shared contract added in #566, using the
19
+ // SHARED classifier (`classifyUpdate`) and the SHARED canonical encoder
20
+ // (`encodeTranscriptEvent`). It does NOT hand-roll a parallel wire grammar,
21
+ // grouping implementation or heuristic sentence splitter: the marker, version,
22
+ // kinds and additive fields all come from the package.
23
+ //
24
+ // Fidelity contract (the documented legacy fallback):
25
+ // - ACP `agent_message_chunk` / `agent_thought_chunk` / `user_message_chunk`
26
+ // text is an incremental DELTA (never a cumulative snapshot for the supported
27
+ // ACP providers), so a message event is tagged `mode: "delta"` — a cumulative
28
+ // snapshot is NEVER emitted as an additive delta (the #566 "never append a
29
+ // snapshot as a delta" rule).
30
+ // - Where the provider exposes a `messageId`, it is carried so the display fold
31
+ // groups a message's fragments and separates two distinct same-speaker
32
+ // messages even when their transport chunks are adjacent.
33
+ // - Where the provider omits `messageId` (a documented ACP fidelity gap), NO
34
+ // identity is fabricated and NO boundary is inferred from delays/punctuation:
35
+ // the chunk is emitted through the canonical bridge UNCHANGED (byte-identical
36
+ // to the pre-#206 wire), and the display fold's adjacent-same-speaker
37
+ // coalescing is the legacy fallback.
38
+ // - Tool-call / tool-result / permission / ignored updates are delegated to the
39
+ // canonical bridge untouched, so tool and permission events stay correctly
40
+ // ordered and paired and raw replay is unchanged.
41
+
42
+ import { sessionAcp as defaultSessionAcp, transcript as defaultTranscript } from './agentic.mjs';
43
+
44
+ // Map the shared classifier's message role to a canonical `TranscriptRole`. ACP's
45
+ // `reasoning` (an `agent_thought_chunk`) has no distinct transcript role, so — like
46
+ // the canonical bridge `acpUpdateToTranscriptChunk` — it folds to `assistant`,
47
+ // which `deriveDisplay` renders as a message block rather than dropping to raw
48
+ // bytes. This mirrors the package bridge exactly so the producer never diverges
49
+ // from the shared role mapping.
50
+ function transcriptRole(acpRole) {
51
+ return acpRole === 'user' ? 'user' : 'assistant';
52
+ }
53
+
54
+ // A non-empty string, else null. `messageId` is optional on the ACP update and the
55
+ // shared classifier already normalises it to `string | null`.
56
+ function nonBlankId(value) {
57
+ return typeof value === 'string' && value !== '' ? value : null;
58
+ }
59
+
60
+ /**
61
+ * Map one raw ACP `session/update` `update` object to the canonical transcript-chunk
62
+ * bytes a producer appends, carrying the available message identity / role / delta
63
+ * semantics into the shared additive `MessageEvent` contract (#566). Returns `null`
64
+ * for an update with no canonical meaning (an `ignored` classification), exactly like
65
+ * the underlying bridge, so a caller skips it.
66
+ *
67
+ * Pure and total: any classifier or encoder throw degrades to the canonical bridge,
68
+ * and a bridge throw is itself caught (yielding `null`), so the producer hot path
69
+ * never crashes on one malformed update.
70
+ *
71
+ * @param {unknown} update The raw ACP `session/update` `params.update` object.
72
+ * @param {object} [deps]
73
+ * @param {object} [deps.sessionAcp] The shared ACP surface (`classifyUpdate` +
74
+ * `acpUpdateToTranscriptChunk`); defaults to the package bridge.
75
+ * @param {object} [deps.transcript] The shared transcript surface
76
+ * (`encodeTranscriptEvent`); defaults to the package transcript module.
77
+ * @returns {string | null} The canonical transcript-chunk bytes, or `null`.
78
+ */
79
+ export function acpUpdateToDisplayChunk(update, deps = {}) {
80
+ const sessionAcp = deps.sessionAcp || defaultSessionAcp;
81
+ const transcript = deps.transcript || defaultTranscript;
82
+
83
+ const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
84
+ const encode = typeof transcript?.encodeTranscriptEvent === 'function' ? transcript.encodeTranscriptEvent : null;
85
+ const bridge = typeof sessionAcp?.acpUpdateToTranscriptChunk === 'function' ? sessionAcp.acpUpdateToTranscriptChunk : null;
86
+
87
+ // Fallback to the canonical bridge output for this update. Never throws.
88
+ const viaBridge = () => {
89
+ if (!bridge) return null;
90
+ try { return bridge(update); }
91
+ catch { return null; }
92
+ };
93
+
94
+ // Without the shared classifier + encoder we cannot enrich the message event, so
95
+ // the byte-identical canonical bridge output is the only correct behaviour.
96
+ if (!classify || !encode) return viaBridge();
97
+
98
+ let classified;
99
+ try { classified = classify(update); }
100
+ catch { return viaBridge(); }
101
+
102
+ // Only message chunks carry identity/boundary semantics worth enriching. Every
103
+ // other classification (tool-call, tool-result, ignored) is delegated to the
104
+ // canonical bridge UNCHANGED — tool/permission ordering and raw replay untouched.
105
+ if (!classified || classified.kind !== 'message') return viaBridge();
106
+
107
+ const messageId = nonBlankId(classified.messageId);
108
+
109
+ // No provider-supplied identity → do NOT fabricate one or infer a boundary.
110
+ // Emit through the canonical bridge unchanged (byte-identical to the pre-#206
111
+ // wire) and let the display fold's adjacent-same-speaker coalescing be the
112
+ // documented legacy fallback.
113
+ if (messageId === null) return viaBridge();
114
+
115
+ // Carry the available semantics into the additive `MessageEvent` fields: the
116
+ // producer identity (`messageId`) so the fold groups this message's fragments and
117
+ // separates distinct same-speaker messages, and `mode: "delta"` because ACP
118
+ // message chunks are incremental deltas — never a cumulative snapshot. No `offset`
119
+ // is supplied here; the real store offset is assigned on append (matching every
120
+ // other `encodeTranscriptEvent` call site).
121
+ const event = {
122
+ kind: 'message',
123
+ role: transcriptRole(classified.role),
124
+ text: classified.text,
125
+ messageId,
126
+ mode: 'delta',
127
+ };
128
+
129
+ try { return encode(event); }
130
+ catch { return viaBridge(); }
131
+ }
package/c8ctl-plugin.js CHANGED
@@ -73,6 +73,13 @@ import { createLogRing, resolveLogMaxBytes } from './supervisor-log-ring.mjs';
73
73
  // raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
74
74
  // replacing the plugin's former hand-rolled `nwfTranscriptEvent` envelope grammar.
75
75
  import { sessionAcp as agenticSessionAcp } from './agentic.mjs';
76
+ // Producer-side message-boundary preservation (jwulf/c8ctl-plugin-nano#206). Wraps
77
+ // the canonical bridge to carry the ACP `messageId` / role / delta semantics into
78
+ // the shared additive `MessageEvent` contract (nanobpm/nano-ide#566), so a consumer
79
+ // folding these chunks through `deriveDisplay` reconstructs transport-fragmented
80
+ // deltas into coherent blocks and keeps distinct same-speaker messages apart —
81
+ // falling back to byte-identical bridge output when the provider omits identity.
82
+ import { acpUpdateToDisplayChunk } from './acp-transcript-producer.mjs';
76
83
  // Engine-native AgentInstance / AgentHistory durable-transcript producer (issue
77
84
  // #194): mints an AgentInstance for an `external` agent job and appends each ACP
78
85
  // turn to the engine's append-only AgentHistory via the host SDK client.
@@ -5455,20 +5462,25 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
5455
5462
  }
5456
5463
  };
5457
5464
 
5458
- // #110 / nanobpm/nano-ide#534: map an ACP session/update to the CANONICAL
5459
- // transcript-chunk wire form via the shared `@nanobpm/agentic` bridge —
5460
- // `classifyUpdate` composed with `encodeTranscriptEvent` behind the single
5461
- // `acpUpdateToTranscriptChunk` helper. It returns the exact
5462
- // `{ nwfTranscriptEvent: 1, kind, }` bytes the cockpit's `parseTranscriptEvent`
5463
- // decodes and `deriveView` folds into messages / tool cards, or `null` for an
5465
+ // #110 / nanobpm/nano-ide#534 / jwulf/c8ctl-plugin-nano#206: map an ACP
5466
+ // session/update to the CANONICAL transcript-chunk wire form via the shared
5467
+ // `@nanobpm/agentic` seams. `acpUpdateToDisplayChunk` (this plugin's producer)
5468
+ // wraps the canonical bridge (`classifyUpdate` composed with
5469
+ // `encodeTranscriptEvent`) and additionally carries the ACP `messageId` / role /
5470
+ // delta semantics into the shared additive `MessageEvent` contract (#566) so a
5471
+ // consumer folding these chunks through `deriveDisplay` reconstructs
5472
+ // transport-fragmented deltas into coherent blocks and keeps distinct
5473
+ // same-speaker messages apart — degrading to byte-identical bridge output when
5474
+ // the provider omits identity. It returns the exact `{ nwfTranscriptEvent: 1,
5475
+ // kind, … }` bytes the cockpit's `parseTranscriptEvent` decodes, or `null` for an
5464
5476
  // update with no canonical meaning (an `ignored` classification: a plan, an
5465
5477
  // intermediate tool_call_update, a non-text chunk, or a malformed update). No
5466
- // envelope grammar or vocab is hand-rolled here anymore — the marker, version,
5467
- // kinds and fields all come from the package, so a producer and a consumer can
5468
- // never diverge on the wire again. `null` (and any bridge throw) falls through
5469
- // to the minimal human-text path below, so nothing is ever dropped.
5478
+ // envelope grammar or vocab is hand-rolled here — the marker, version, kinds and
5479
+ // additive fields all come from the package, so a producer and a consumer can
5480
+ // never diverge on the wire again. `null` (and any throw) falls through to the
5481
+ // minimal human-text path below, so nothing is ever dropped.
5470
5482
  const encodeTranscriptChunk = (update) => {
5471
- try { return agenticSessionAcp.acpUpdateToTranscriptChunk(update); }
5483
+ try { return acpUpdateToDisplayChunk(update, { sessionAcp: agenticSessionAcp }); }
5472
5484
  catch { return null; }
5473
5485
  };
5474
5486
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.57.1",
3
+ "version": "1.58.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "c8ctl-plugin.js",
24
24
  "agent-instance.mjs",
25
+ "acp-transcript-producer.mjs",
25
26
  "platforms.mjs",
26
27
  "agentic.mjs",
27
28
  "agentic-loader-hook.mjs",
@@ -67,17 +68,17 @@
67
68
  "typescript": "^5.9.3"
68
69
  },
69
70
  "dependencies": {
70
- "@nanobpm/agentic": "^0.13.0",
71
- "@nanobpm/urban-agent-client": "^0.1.13"
71
+ "@nanobpm/agentic": "^0.14.0",
72
+ "@nanobpm/urban-agent-client": "^0.1.14"
72
73
  },
73
74
  "optionalDependencies": {
74
75
  "node-pty": "^1.0.0",
75
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.57.1",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.57.1",
77
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.57.1",
78
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.57.1",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.57.1",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.57.1",
81
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.57.1"
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.58.0",
77
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.58.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.58.0",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.58.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.58.0",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.58.0",
82
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.58.0"
82
83
  }
83
84
  }