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.
- package/acp-transcript-producer.mjs +131 -0
- package/c8ctl-plugin.js +23 -11
- package/package.json +11 -10
|
@@ -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
|
|
5459
|
-
// transcript-chunk wire form via the shared
|
|
5460
|
-
// `
|
|
5461
|
-
//
|
|
5462
|
-
// `
|
|
5463
|
-
//
|
|
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
|
|
5467
|
-
//
|
|
5468
|
-
// never diverge on the wire again. `null` (and any
|
|
5469
|
-
//
|
|
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
|
|
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.
|
|
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.
|
|
71
|
-
"@nanobpm/urban-agent-client": "^0.1.
|
|
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.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
81
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "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
|
}
|