@nanobpm/nano-workforce 0.171.0 → 0.171.1
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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Code Refactoring
|
|
4
|
+
|
|
5
|
+
* **agentic:** consume @nanobpm/agentic/transcript, drop the duplicated grammar ([#677](https://github.com/nanobpm/nano-workforce/issues/677)) ([8c8d3fc](https://github.com/nanobpm/nano-workforce/commit/8c8d3fc393134862557a65e2aab2dd9e16292c66)), closes [#676](https://github.com/nanobpm/nano-workforce/issues/676)
|
|
6
|
+
|
|
1
7
|
## [0.171.0](https://github.com/nanobpm/nano-workforce/compare/v0.170.1...v0.171.0) (2026-08-31)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// Drift-guard:
|
|
1
|
+
// Drift-guard: the transcript grammar is DERIVED from its one owner, never re-forked here (#676).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// nano-workforce used to carry a byte-for-byte hand-rolled copy of the transcript-event grammar
|
|
4
|
+
// (marker/version, the ONE parser, the vocabulary, the derive fold). That grammar now lives in exactly
|
|
5
|
+
// one place — `@nanobpm/agentic/transcript` (agentic 0.10.0) — and `transcript-events.ts` is a thin
|
|
6
|
+
// re-export barrel over it. This guard secures the *class* of failure ("a consumer hand-rolls the
|
|
7
|
+
// transcript grammar instead of importing it") structurally, by scanning the app-tier source: it fails
|
|
8
|
+
// if the barrel stops importing agentic, if a local module re-defines the envelope marker literal, or
|
|
9
|
+
// if a transcript consumer re-parses a stored chunk itself instead of folding through the one parser.
|
|
9
10
|
import { test } from "node:test";
|
|
10
11
|
import { readdirSync, readFileSync } from "node:fs";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
@@ -26,26 +27,36 @@ function sourceFiles(dir: string): string[] {
|
|
|
26
27
|
return out;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const BARREL_MODULE = join(AGENTIC_DIR, "transcript-events.ts");
|
|
31
|
+
const AGENTIC_TRANSCRIPT_SPECIFIER = "@nanobpm/agentic/transcript";
|
|
30
32
|
|
|
31
|
-
test("the transcript
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
test("the transcript grammar is imported from @nanobpm/agentic/transcript, never redefined locally", () => {
|
|
34
|
+
// The single source of truth is agentic; nano-workforce derives from it via a re-export barrel.
|
|
35
|
+
const barrel = readFileSync(BARREL_MODULE, "utf8");
|
|
36
|
+
assert(
|
|
37
|
+
barrel.includes(AGENTIC_TRANSCRIPT_SPECIFIER),
|
|
38
|
+
`${BARREL_MODULE} must re-export the grammar from "${AGENTIC_TRANSCRIPT_SPECIFIER}"`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("no local module re-defines the transcript-event marker literal (no second grammar)", () => {
|
|
43
|
+
// Consumers reference the marker via the imported `TRANSCRIPT_EVENT_MARKER` identifier; only a
|
|
44
|
+
// second, forked grammar would embed the marker's string literal in nano-workforce source. Match
|
|
45
|
+
// every quote form (double, single, backtick) so a re-fork can't bypass the guard by hardcoding the
|
|
46
|
+
// marker in a different literal style. Expect ZERO owners — the literal lives in agentic now.
|
|
36
47
|
const quotedMarkerForms = ['"', "'", "`"].map((q) => `${q}${TRANSCRIPT_EVENT_MARKER}${q}`);
|
|
37
48
|
const owners = sourceFiles(AGENTIC_DIR).filter((path) => {
|
|
38
49
|
const src = readFileSync(path, "utf8");
|
|
39
50
|
return quotedMarkerForms.some((literal) => src.includes(literal));
|
|
40
51
|
});
|
|
41
|
-
assertEquals(owners, [
|
|
52
|
+
assertEquals(owners, []);
|
|
42
53
|
});
|
|
43
54
|
|
|
44
|
-
test("no transcript consumer re-parses raw chunks —
|
|
45
|
-
// The cockpit + read projections must fold through the single parser, never JSON.parse a
|
|
46
|
-
// themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
|
|
47
|
-
const consumers = sourceFiles(AGENTIC_DIR).filter(
|
|
48
|
-
|
|
55
|
+
test("no transcript consumer re-parses raw chunks — the log is folded only through the one parser", () => {
|
|
56
|
+
// The cockpit + read projections must fold through the single parser (in agentic), never JSON.parse a
|
|
57
|
+
// chunk themselves. Scan the transcript-facing consumers and assert none contains a raw JSON.parse.
|
|
58
|
+
const consumers = sourceFiles(AGENTIC_DIR).filter((path) =>
|
|
59
|
+
/transcript-(read|render|view|derive|fork)\.ts$/.test(path),
|
|
49
60
|
);
|
|
50
61
|
assert(consumers.length >= 3, "expected to scan several transcript consumers");
|
|
51
62
|
for (const path of consumers) {
|
|
@@ -1,721 +1,19 @@
|
|
|
1
|
-
// nano-workforce — the transcript EVENT
|
|
1
|
+
// nano-workforce — the transcript EVENT grammar, DERIVED from its one canonical owner (issue #676).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// The transcript event-sourced session layer (ADR 0056, #251) — the marker/version, the ONE parser,
|
|
4
|
+
// the merge-extensible vocabulary, the typed event union, and the single {@link deriveView} fold — is
|
|
5
|
+
// now DEFINED once, in `@nanobpm/agentic/transcript` (published in agentic 0.10.0 via nano-ide#534).
|
|
6
|
+
// nano-workforce used to carry a byte-for-byte hand-rolled duplicate of that grammar here; two copies
|
|
7
|
+
// of one envelope grammar is exactly the DRIFT SURFACE our "Derivation Over Duplication" doctrine
|
|
8
|
+
// forbids — the day agentic's grammar evolved (a new event kind, a marker/version bump, a decoder fix)
|
|
9
|
+
// this fork would silently keep decoding by the old rules and the cockpit transcript view would
|
|
10
|
+
// diverge.
|
|
9
11
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
// THE ONE PARSER. {@link parseTranscriptEvent} is the SINGLE place a stored chunk is classified into a
|
|
19
|
-
// typed event; every consumer (cockpit, search, token accounting, export) reads the derived view, not
|
|
20
|
-
// the raw bytes. A drift-guard test (`transcript-events.drift.test.ts`) asserts the event marker — and
|
|
21
|
-
// therefore the raw→event parse — appears in exactly this module, so a second parser cannot creep in.
|
|
22
|
-
//
|
|
23
|
-
// MERGE-EXTENSIBLE. The vocabulary is a small core ({@link CORE_TRANSCRIPT_VOCAB}) authors extend in the
|
|
24
|
-
// same schema with {@link mergeTranscriptVocab} (cribbed from dsh's merge-extensible event taxonomy and
|
|
25
|
-
// the S3 `mergeVocab`), so a new event kind is an additive merge, never a fork of the parser.
|
|
26
|
-
//
|
|
27
|
-
// Pure and side-effect-free: no I/O, unit-testable on Node, and it never touches the engine or a BPMN
|
|
28
|
-
// flow (ADR 0056: app-tier only, advisory).
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Runtime-safe UTF-8 byte length. This module is imported by cockpit code that runs in the BROWSER
|
|
32
|
-
* (via `cockpit/transcript-derive.ts`), where Node's `Buffer` global is not available — a bare
|
|
33
|
-
* `Buffer.byteLength` would throw at runtime when deriving the view for a replayed transcript. Prefer
|
|
34
|
-
* `Buffer` when present (Node) and fall back to `TextEncoder` (a Web/Node standard) otherwise, so the
|
|
35
|
-
* single derive fold is portable across both hosts. This is the one canonical UTF-8 byte-length
|
|
36
|
-
* implementation the transcript plane derives from (reused by `transcript-read.ts`).
|
|
37
|
-
*/
|
|
38
|
-
let cachedTextEncoder: TextEncoder | undefined;
|
|
39
|
-
export function utf8ByteLength(text: string): number {
|
|
40
|
-
if (typeof Buffer !== "undefined") return Buffer.byteLength(text, "utf8");
|
|
41
|
-
// Cache one TextEncoder in the browser hot path (folding many stream-chunk events) to avoid
|
|
42
|
-
// allocating a new encoder — and the GC pressure it creates — on every call.
|
|
43
|
-
cachedTextEncoder ??= new TextEncoder();
|
|
44
|
-
return cachedTextEncoder.encode(text).length;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The reserved marker field that distinguishes a structured transcript-event envelope from raw
|
|
49
|
-
* terminal bytes. A stored chunk is decoded as a typed event ONLY when it is a JSON object carrying
|
|
50
|
-
* this field set to the schema version — otherwise it is retained verbatim as a raw `stream-chunk`, so
|
|
51
|
-
* a raw ANSI frame that happens to be valid JSON is never mis-classified. Namespaced to nano-workforce
|
|
52
|
-
* so it cannot collide with a producer's own payload keys.
|
|
53
|
-
*/
|
|
54
|
-
export const TRANSCRIPT_EVENT_MARKER = "nwfTranscriptEvent" as const;
|
|
55
|
-
|
|
56
|
-
/** The current transcript-event envelope schema version (the value {@link TRANSCRIPT_EVENT_MARKER} carries). */
|
|
57
|
-
export const TRANSCRIPT_EVENT_VERSION = 1 as const;
|
|
58
|
-
|
|
59
|
-
/** The core, merge-extensible transcript-event kinds (authors add more via {@link mergeTranscriptVocab}). */
|
|
60
|
-
export type TranscriptEventKind =
|
|
61
|
-
| "stream-chunk"
|
|
62
|
-
| "message"
|
|
63
|
-
| "tool-call"
|
|
64
|
-
| "tool-result"
|
|
65
|
-
| "turn"
|
|
66
|
-
| "step"
|
|
67
|
-
| "lifecycle"
|
|
68
|
-
| "permission";
|
|
69
|
-
|
|
70
|
-
/** The message roles the derived history distinguishes (assistant is authoritative for derivation). */
|
|
71
|
-
export type TranscriptRole = "assistant" | "user" | "system" | "tool";
|
|
72
|
-
|
|
73
|
-
/** Fields every typed event carries: its kind and the store offset it was decoded from. */
|
|
74
|
-
interface TranscriptEventBase {
|
|
75
|
-
readonly offset: number;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** A raw terminal chunk retained verbatim for byte-level replay fidelity (the default classification). */
|
|
79
|
-
export interface StreamChunkEvent extends TranscriptEventBase {
|
|
80
|
-
readonly kind: "stream-chunk";
|
|
81
|
-
/** The exact stored bytes — unmodified, so raw-byte replay stays faithful. */
|
|
82
|
-
readonly chunk: string;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** An assistant/user/system message — authoritative for the derived message history. */
|
|
86
|
-
export interface MessageEvent extends TranscriptEventBase {
|
|
87
|
-
readonly kind: "message";
|
|
88
|
-
readonly role: TranscriptRole;
|
|
89
|
-
readonly text: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A tool invocation the agent issued. */
|
|
93
|
-
export interface ToolCallEvent extends TranscriptEventBase {
|
|
94
|
-
readonly kind: "tool-call";
|
|
95
|
-
readonly name: string;
|
|
96
|
-
/** A stable id linking this call to its {@link ToolResultEvent}, when the producer supplies one. */
|
|
97
|
-
readonly callId?: string;
|
|
98
|
-
readonly args?: unknown;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** A tool result, paired back to its {@link ToolCallEvent} by `callId` (else the most recent open call). */
|
|
102
|
-
export interface ToolResultEvent extends TranscriptEventBase {
|
|
103
|
-
readonly kind: "tool-result";
|
|
104
|
-
readonly callId?: string;
|
|
105
|
-
readonly ok: boolean;
|
|
106
|
-
readonly content?: string;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** A turn boundary — the start of a new request/response cycle. */
|
|
110
|
-
export interface TurnEvent extends TranscriptEventBase {
|
|
111
|
-
readonly kind: "turn";
|
|
112
|
-
/** The producer's turn index, when supplied (else derived positionally). */
|
|
113
|
-
readonly index?: number;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** A step boundary within a turn (a tool loop iteration, a sub-agent hop, …). */
|
|
117
|
-
export interface StepEvent extends TranscriptEventBase {
|
|
118
|
-
readonly kind: "step";
|
|
119
|
-
readonly label?: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** A session lifecycle transition (open → completed, or an explicit exit). */
|
|
123
|
-
export interface LifecycleEvent extends TranscriptEventBase {
|
|
124
|
-
readonly kind: "lifecycle";
|
|
125
|
-
readonly phase: "open" | "completed" | "exited";
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// --- Permission (ACP `session/request_permission`) — SHARED CONTRACT (issue #559) ------------------
|
|
129
|
-
// A `permission` event models ACP's `session/request_permission`: the agent asks the operator to
|
|
130
|
-
// allow/deny a proposed action (usually a tool call), and the operator (or an auto policy) resolves it.
|
|
131
|
-
// It is decoded here (the ONE parser) and folded here (the ONE fold) into a paired {@link DerivedPermission}.
|
|
132
|
-
// These exported types are the SINGLE SOURCE OF TRUTH the sibling slices (cockpit render, escalation
|
|
133
|
-
// bridge) consume — they must import these, never reinvent a divergent permission shape. See the durable
|
|
134
|
-
// declaration in `app/contracts.ts` (`type:PermissionPolicy`, `wire:transcript.permission`).
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The role's permission policy the PRODUCER tags a request with. `"escalate"` means a human must be
|
|
138
|
-
* asked (the cockpit renders an Allow/Deny prompt, the escalation bridge raises a user task);
|
|
139
|
-
* `"yolo"` means the action is auto-allowed and never prompts a human. Cockpit + bridge branch on this.
|
|
140
|
-
*/
|
|
141
|
-
export type PermissionPolicy = "escalate" | "yolo";
|
|
142
|
-
|
|
143
|
-
/** The kind of a permission option — mirrors ACP's option kinds (allow/reject × once/always). */
|
|
144
|
-
export type PermissionOptionKind = "allow-once" | "allow-always" | "reject-once" | "reject-always";
|
|
145
|
-
|
|
146
|
-
/** Pure, canonical: does a permission option kind ALLOW (true) or REJECT (false) the proposed action?
|
|
147
|
-
* The `allow-*` vs `reject-*` prefix is the single source of truth. This lives beside
|
|
148
|
-
* {@link PermissionOptionKind} so every consumer (the cockpit render seam and the permission-escalation
|
|
149
|
-
* bridge) derives allow/deny from ONE implementation — the two paths can never disagree on what a
|
|
150
|
-
* chosen option means (no drift surface). */
|
|
151
|
-
export function optionKindAllows(kind: PermissionOptionKind): boolean {
|
|
152
|
-
return kind === "allow-once" || kind === "allow-always";
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** One offered permission option (ACP `options[]` member): a stable id, a label, and its kind. */
|
|
156
|
-
export interface PermissionOption {
|
|
157
|
-
readonly optionId: string;
|
|
158
|
-
readonly name: string;
|
|
159
|
-
readonly kind: PermissionOptionKind;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* A permission REQUEST: the agent asks the operator to allow/deny a proposed action. The `callId`
|
|
164
|
-
* pairs the eventual {@link PermissionResolutionEvent} back to this request (mirroring how
|
|
165
|
-
* `tool-call`/`tool-result` pair by `callId`).
|
|
166
|
-
*/
|
|
167
|
-
export interface PermissionRequestEvent extends TranscriptEventBase {
|
|
168
|
-
readonly kind: "permission";
|
|
169
|
-
readonly phase: "request";
|
|
170
|
-
/** Stable id pairing this request to its resolution. */
|
|
171
|
-
readonly callId: string;
|
|
172
|
-
/** The producer-tagged policy the cockpit + bridge branch on. */
|
|
173
|
-
readonly policy: PermissionPolicy;
|
|
174
|
-
/** The offered options — always at least one (the decoder rejects an empty list). */
|
|
175
|
-
readonly options: readonly PermissionOption[];
|
|
176
|
-
/** The tool the proposed action would invoke, when known. */
|
|
177
|
-
readonly toolName?: string;
|
|
178
|
-
/** A short human-readable title for the prompt. */
|
|
179
|
-
readonly title?: string;
|
|
180
|
-
/** A longer human-readable reason for the prompt. */
|
|
181
|
-
readonly reason?: string;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* A permission RESOLUTION: the operator's (or an auto policy's) decision, carrying the same `callId`,
|
|
186
|
-
* the chosen `optionId`, and whether the action was `allowed`.
|
|
187
|
-
*/
|
|
188
|
-
export interface PermissionResolutionEvent extends TranscriptEventBase {
|
|
189
|
-
readonly kind: "permission";
|
|
190
|
-
readonly phase: "resolution";
|
|
191
|
-
/** The `callId` of the {@link PermissionRequestEvent} this resolves. */
|
|
192
|
-
readonly callId: string;
|
|
193
|
-
/** The chosen option's id. */
|
|
194
|
-
readonly optionId: string;
|
|
195
|
-
/** True = allowed, false = denied. */
|
|
196
|
-
readonly allowed: boolean;
|
|
197
|
-
/** Provenance of the decision, when supplied. */
|
|
198
|
-
readonly by?: "operator" | "auto";
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** The core typed transcript-event union (merge-extensible: authors add kinds via the vocab). */
|
|
202
|
-
export type TranscriptEvent =
|
|
203
|
-
| StreamChunkEvent
|
|
204
|
-
| MessageEvent
|
|
205
|
-
| ToolCallEvent
|
|
206
|
-
| ToolResultEvent
|
|
207
|
-
| TurnEvent
|
|
208
|
-
| StepEvent
|
|
209
|
-
| LifecycleEvent
|
|
210
|
-
| PermissionRequestEvent
|
|
211
|
-
| PermissionResolutionEvent;
|
|
212
|
-
|
|
213
|
-
/** A stored chunk as the store/read path exposes it (mirrors `TranscriptChunk`). */
|
|
214
|
-
export interface StoredChunk {
|
|
215
|
-
readonly offset: number;
|
|
216
|
-
readonly chunk: string;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* A decoder for one event kind: given the parsed envelope body and the chunk offset, it returns the
|
|
221
|
-
* typed event (or `undefined` to reject a malformed envelope, which then falls back to `stream-chunk`).
|
|
222
|
-
* A vocabulary is the map kind → decoder; {@link mergeTranscriptVocab} extends it additively.
|
|
223
|
-
*/
|
|
224
|
-
export type TranscriptEventDecoder = (body: Record<string, unknown>, offset: number) => TranscriptEvent | undefined;
|
|
225
|
-
|
|
226
|
-
/** A transcript-event vocabulary: the ONE registry of kind → decoder the single parser consults. */
|
|
227
|
-
export type TranscriptVocab = Readonly<Record<string, TranscriptEventDecoder>>;
|
|
228
|
-
|
|
229
|
-
function str(body: Record<string, unknown>, key: string): string | undefined {
|
|
230
|
-
const v = body[key];
|
|
231
|
-
return typeof v === "string" ? v : undefined;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function num(body: Record<string, unknown>, key: string): number | undefined {
|
|
235
|
-
const v = body[key];
|
|
236
|
-
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const ROLES: readonly TranscriptRole[] = ["assistant", "user", "system", "tool"];
|
|
240
|
-
|
|
241
|
-
/** Narrow an arbitrary string to a known {@link TranscriptRole}, defaulting to `assistant`. */
|
|
242
|
-
function toRole(value: string | undefined): TranscriptRole {
|
|
243
|
-
return ROLES.find((role) => role === value) ?? "assistant";
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/** A structural guard: a non-null, non-array object is a plain record of unknown values. */
|
|
247
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const PERMISSION_OPTION_KINDS: readonly PermissionOptionKind[] = [
|
|
252
|
-
"allow-once",
|
|
253
|
-
"allow-always",
|
|
254
|
-
"reject-once",
|
|
255
|
-
"reject-always",
|
|
256
|
-
];
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Decode ACP's `options[]` into typed {@link PermissionOption}s, or `undefined` if the array is
|
|
260
|
-
* missing/empty or any member is malformed (so the whole request envelope is rejected → `stream-chunk`).
|
|
261
|
-
*/
|
|
262
|
-
function decodePermissionOptions(value: unknown): PermissionOption[] | undefined {
|
|
263
|
-
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
264
|
-
const options: PermissionOption[] = [];
|
|
265
|
-
for (const raw of value) {
|
|
266
|
-
if (!isRecord(raw)) return undefined;
|
|
267
|
-
const optionId = str(raw, "optionId");
|
|
268
|
-
const name = str(raw, "name");
|
|
269
|
-
const kindRaw = str(raw, "kind");
|
|
270
|
-
const kind = PERMISSION_OPTION_KINDS.find((k) => k === kindRaw);
|
|
271
|
-
if (optionId === undefined || name === undefined || kind === undefined) return undefined;
|
|
272
|
-
options.push({ optionId, name, kind });
|
|
273
|
-
}
|
|
274
|
-
return options;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* The opinionated core vocabulary — the built-in event kinds every consumer understands out of the
|
|
279
|
-
* box. Authors extend it in the SAME schema via {@link mergeTranscriptVocab}; they never fork the
|
|
280
|
-
* parser. (`stream-chunk` is not decoded here — it is the fallback the parser applies to any chunk
|
|
281
|
-
* that is not a well-formed typed envelope, so raw fidelity needs no decoder.)
|
|
282
|
-
*/
|
|
283
|
-
export const CORE_TRANSCRIPT_VOCAB: TranscriptVocab = Object.freeze({
|
|
284
|
-
message: (body, offset) => {
|
|
285
|
-
const text = str(body, "text");
|
|
286
|
-
if (text === undefined) return undefined;
|
|
287
|
-
const roleRaw = str(body, "role");
|
|
288
|
-
return { kind: "message", offset, role: toRole(roleRaw), text };
|
|
289
|
-
},
|
|
290
|
-
"tool-call": (body, offset) => {
|
|
291
|
-
const name = str(body, "name");
|
|
292
|
-
if (name === undefined) return undefined;
|
|
293
|
-
const event: ToolCallEvent = { kind: "tool-call", offset, name };
|
|
294
|
-
const callId = str(body, "callId");
|
|
295
|
-
return {
|
|
296
|
-
...event,
|
|
297
|
-
...(callId !== undefined ? { callId } : {}),
|
|
298
|
-
...("args" in body ? { args: body.args } : {}),
|
|
299
|
-
};
|
|
300
|
-
},
|
|
301
|
-
"tool-result": (body, offset) => {
|
|
302
|
-
const ok = typeof body.ok === "boolean" ? body.ok : true;
|
|
303
|
-
const event: ToolResultEvent = { kind: "tool-result", offset, ok };
|
|
304
|
-
const callId = str(body, "callId");
|
|
305
|
-
const content = str(body, "content");
|
|
306
|
-
return {
|
|
307
|
-
...event,
|
|
308
|
-
...(callId !== undefined ? { callId } : {}),
|
|
309
|
-
...(content !== undefined ? { content } : {}),
|
|
310
|
-
};
|
|
311
|
-
},
|
|
312
|
-
// ACP `plan` mapping: ACP `session/update` plan updates map onto the EXISTING `step`/`turn`
|
|
313
|
-
// vocabulary rather than a new kind — an ACP plan ENTRY becomes a `step` (its `label` is the plan
|
|
314
|
-
// entry's title; the entry ordinal is not preserved, as `StepEvent` carries only a `label`), and a
|
|
315
|
-
// plan/turn BOUNDARY becomes a `turn` (its `index` the ACP turn/plan ordinal). The decoders below
|
|
316
|
-
// already cope with an ACP-shaped `label` (`step`) / `index` (`turn`), so no new kind is needed.
|
|
317
|
-
turn: (body, offset) => {
|
|
318
|
-
const index = num(body, "index");
|
|
319
|
-
return index !== undefined ? { kind: "turn", offset, index } : { kind: "turn", offset };
|
|
320
|
-
},
|
|
321
|
-
step: (body, offset) => {
|
|
322
|
-
const label = str(body, "label");
|
|
323
|
-
return label !== undefined ? { kind: "step", offset, label } : { kind: "step", offset };
|
|
324
|
-
},
|
|
325
|
-
lifecycle: (body, offset) => {
|
|
326
|
-
const phase = str(body, "phase");
|
|
327
|
-
if (phase !== "open" && phase !== "completed" && phase !== "exited") return undefined;
|
|
328
|
-
return { kind: "lifecycle", offset, phase };
|
|
329
|
-
},
|
|
330
|
-
// A single `permission` decoder handles BOTH shapes (never a parser fork), branching on `phase`.
|
|
331
|
-
// Malformed envelopes return `undefined` and fall back to `stream-chunk`, like the other decoders.
|
|
332
|
-
permission: (body, offset) => {
|
|
333
|
-
const callId = str(body, "callId");
|
|
334
|
-
if (callId === undefined) return undefined;
|
|
335
|
-
const phase = str(body, "phase");
|
|
336
|
-
if (phase === "request") {
|
|
337
|
-
const policy = str(body, "policy");
|
|
338
|
-
if (policy !== "escalate" && policy !== "yolo") return undefined;
|
|
339
|
-
const options = decodePermissionOptions(body.options);
|
|
340
|
-
if (options === undefined) return undefined;
|
|
341
|
-
const toolName = str(body, "toolName");
|
|
342
|
-
const title = str(body, "title");
|
|
343
|
-
const reason = str(body, "reason");
|
|
344
|
-
const event: PermissionRequestEvent = { kind: "permission", phase: "request", offset, callId, policy, options };
|
|
345
|
-
return {
|
|
346
|
-
...event,
|
|
347
|
-
...(toolName !== undefined ? { toolName } : {}),
|
|
348
|
-
...(title !== undefined ? { title } : {}),
|
|
349
|
-
...(reason !== undefined ? { reason } : {}),
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
if (phase === "resolution") {
|
|
353
|
-
const optionId = str(body, "optionId");
|
|
354
|
-
if (optionId === undefined) return undefined;
|
|
355
|
-
if (typeof body.allowed !== "boolean") return undefined;
|
|
356
|
-
const by = str(body, "by");
|
|
357
|
-
// Reject a malformed `by` rather than silently dropping it: a present-but-unknown provenance is a
|
|
358
|
-
// producer bug, and swallowing it would make the typed event diverge from the on-wire JSON. This
|
|
359
|
-
// covers BOTH a present-but-non-string `by` (e.g. `by: 123`, where str() coerces to undefined) and
|
|
360
|
-
// a string that isn't a known provenance — either way the on-wire `by` is present but invalid.
|
|
361
|
-
if (body.by !== undefined && by === undefined) return undefined;
|
|
362
|
-
if (by !== undefined && by !== "operator" && by !== "auto") return undefined;
|
|
363
|
-
const event: PermissionResolutionEvent = {
|
|
364
|
-
kind: "permission",
|
|
365
|
-
phase: "resolution",
|
|
366
|
-
offset,
|
|
367
|
-
callId,
|
|
368
|
-
optionId,
|
|
369
|
-
allowed: body.allowed,
|
|
370
|
-
};
|
|
371
|
-
return { ...event, ...(by !== undefined ? { by } : {}) };
|
|
372
|
-
}
|
|
373
|
-
return undefined;
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Extend a vocabulary additively: later entries win on a key clash, so an author can either register a
|
|
379
|
-
* brand-new kind or deliberately override a core decoder. Returns a NEW frozen vocab — neither input is
|
|
380
|
-
* mutated — so the core stays canonical. (Cribbed from dsh's merge-extensible taxonomy / the S3
|
|
381
|
-
* `mergeVocab`: one schema, extended by merge, never a second parser.)
|
|
382
|
-
*/
|
|
383
|
-
export function mergeTranscriptVocab(base: TranscriptVocab, ...extensions: TranscriptVocab[]): TranscriptVocab {
|
|
384
|
-
return Object.freeze(Object.assign({}, base, ...extensions));
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* THE ONE PARSER. Classify a single stored chunk into a typed {@link TranscriptEvent}.
|
|
389
|
-
*
|
|
390
|
-
* A chunk is decoded as a structured event ONLY when it is a JSON object carrying the
|
|
391
|
-
* {@link TRANSCRIPT_EVENT_MARKER} at the current version AND a `kind` the vocab knows AND its decoder
|
|
392
|
-
* accepts the body. Anything else — raw terminal bytes, non-JSON, a JSON value without the marker, an
|
|
393
|
-
* unknown kind, a decoder rejection — is retained verbatim as a `stream-chunk`, so byte-level replay
|
|
394
|
-
* fidelity is never lost. This is the SINGLE point at which raw bytes become typed events; every view
|
|
395
|
-
* folds over the result of this function, so there is exactly one parser of the log.
|
|
396
|
-
*/
|
|
397
|
-
export function parseTranscriptEvent(
|
|
398
|
-
entry: StoredChunk,
|
|
399
|
-
vocab: TranscriptVocab = CORE_TRANSCRIPT_VOCAB,
|
|
400
|
-
): TranscriptEvent {
|
|
401
|
-
const raw: StreamChunkEvent = { kind: "stream-chunk", offset: entry.offset, chunk: entry.chunk };
|
|
402
|
-
const body = decodeEnvelope(entry.chunk);
|
|
403
|
-
if (body === undefined) return raw;
|
|
404
|
-
const kind = typeof body.kind === "string" ? body.kind : undefined;
|
|
405
|
-
if (kind === undefined) return raw;
|
|
406
|
-
const decoder = vocab[kind];
|
|
407
|
-
if (decoder === undefined) return raw;
|
|
408
|
-
return decoder(body, entry.offset) ?? raw;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Decode a chunk into a marker-tagged envelope body, or `undefined` when it is not one. Kept private
|
|
413
|
-
* so `JSON.parse` of a chunk lives in exactly one place (the drift-guard depends on this).
|
|
414
|
-
*/
|
|
415
|
-
function decodeEnvelope(chunk: string): Record<string, unknown> | undefined {
|
|
416
|
-
// Cheap reject before the parse: a valid envelope is a JSON object mentioning the marker key.
|
|
417
|
-
const trimmed = chunk.trimStart();
|
|
418
|
-
if (!trimmed.startsWith("{") || !chunk.includes(TRANSCRIPT_EVENT_MARKER)) return undefined;
|
|
419
|
-
let parsed: unknown;
|
|
420
|
-
try {
|
|
421
|
-
parsed = JSON.parse(chunk);
|
|
422
|
-
} catch {
|
|
423
|
-
return undefined;
|
|
424
|
-
}
|
|
425
|
-
if (!isRecord(parsed)) return undefined;
|
|
426
|
-
return parsed[TRANSCRIPT_EVENT_MARKER] === TRANSCRIPT_EVENT_VERSION ? parsed : undefined;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Encode a typed event into the stored-chunk wire form a structured producer appends. The inverse of
|
|
431
|
-
* {@link parseTranscriptEvent} for every non-raw kind (a `stream-chunk` is stored as its own raw bytes,
|
|
432
|
-
* so it is returned verbatim). Provided so producers and tests speak the one envelope grammar rather
|
|
433
|
-
* than hand-rolling the marker — the derivation-over-duplication rule applied to the write side too.
|
|
434
|
-
*/
|
|
435
|
-
export function encodeTranscriptEvent(event: TranscriptEvent): string {
|
|
436
|
-
if (event.kind === "stream-chunk") return event.chunk;
|
|
437
|
-
const { offset: _offset, ...rest } = event;
|
|
438
|
-
return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, ...rest });
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/** A derived tool card: a tool-call paired with its result (result absent while the call is pending). */
|
|
442
|
-
export interface DerivedTool {
|
|
443
|
-
readonly name: string;
|
|
444
|
-
readonly callId?: string;
|
|
445
|
-
readonly args?: unknown;
|
|
446
|
-
readonly offset: number;
|
|
447
|
-
readonly result?: { readonly ok: boolean; readonly content?: string; readonly offset: number };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** A derived message in the folded history. */
|
|
451
|
-
export interface DerivedMessage {
|
|
452
|
-
readonly role: TranscriptRole;
|
|
453
|
-
readonly text: string;
|
|
454
|
-
readonly offset: number;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* A derived permission: a permission REQUEST paired with its RESOLUTION by `callId` (resolution absent
|
|
459
|
-
* while the request is still pending), mirroring how {@link DerivedTool} pairs a call with its result.
|
|
460
|
-
* The cockpit and the escalation bridge read THIS — they never re-parse the log.
|
|
461
|
-
*/
|
|
462
|
-
export interface DerivedPermission {
|
|
463
|
-
readonly callId: string;
|
|
464
|
-
readonly policy: PermissionPolicy;
|
|
465
|
-
readonly options: readonly PermissionOption[];
|
|
466
|
-
readonly toolName?: string;
|
|
467
|
-
readonly title?: string;
|
|
468
|
-
readonly reason?: string;
|
|
469
|
-
readonly offset: number;
|
|
470
|
-
/** The resolution, once present (pending request → `undefined`). */
|
|
471
|
-
readonly resolved?: {
|
|
472
|
-
readonly allowed: boolean;
|
|
473
|
-
readonly optionId: string;
|
|
474
|
-
readonly by?: "operator" | "auto";
|
|
475
|
-
readonly offset: number;
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** A derived turn: the messages, tool cards and step count folded within one turn boundary. */
|
|
480
|
-
export interface DerivedTurn {
|
|
481
|
-
readonly index: number;
|
|
482
|
-
readonly startOffset: number;
|
|
483
|
-
readonly messages: readonly DerivedMessage[];
|
|
484
|
-
readonly tools: readonly DerivedTool[];
|
|
485
|
-
readonly permissions: readonly DerivedPermission[];
|
|
486
|
-
readonly steps: number;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** The single derived view every higher-level consumer reads instead of re-parsing raw bytes. */
|
|
490
|
-
export interface DerivedView {
|
|
491
|
-
/** The per-turn structure (a turn is opened implicitly before the first turn event, if any content precedes it). */
|
|
492
|
-
readonly turns: readonly DerivedTurn[];
|
|
493
|
-
/** Every message across all turns, in offset order (the flat derived history). */
|
|
494
|
-
readonly messages: readonly DerivedMessage[];
|
|
495
|
-
/** Every tool card across all turns, in offset order. */
|
|
496
|
-
readonly tools: readonly DerivedTool[];
|
|
497
|
-
/** Every permission across all turns, in offset order (each request paired to its resolution by `callId`). */
|
|
498
|
-
readonly permissions: readonly DerivedPermission[];
|
|
499
|
-
/** Total retained raw bytes (UTF-8) across `stream-chunk` events — the byte-replay fidelity accounting. */
|
|
500
|
-
readonly rawByteLength: number;
|
|
501
|
-
/** Number of retained raw chunks. */
|
|
502
|
-
readonly rawChunkCount: number;
|
|
503
|
-
/** The session lifecycle as the last lifecycle event reports it (defaults to `open`). */
|
|
504
|
-
readonly lifecycle: "open" | "completed" | "exited";
|
|
505
|
-
/** Number of typed events folded. */
|
|
506
|
-
readonly eventCount: number;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
interface MutableTurn {
|
|
510
|
-
index: number;
|
|
511
|
-
startOffset: number;
|
|
512
|
-
messages: DerivedMessage[];
|
|
513
|
-
tools: DerivedTool[];
|
|
514
|
-
permissions: DerivedPermission[];
|
|
515
|
-
steps: number;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* THE SINGLE FOLD. Derive every higher-level view from the typed event log — "the log IS the state".
|
|
520
|
-
*
|
|
521
|
-
* Folds the events (assumed in offset order — the store's append order) into per-turn structure, a flat
|
|
522
|
-
* message history, tool cards (each call paired to its result by `callId`, else the most recent open
|
|
523
|
-
* call), raw-byte accounting for replay fidelity, and the session lifecycle. It is a pure reduction of
|
|
524
|
-
* one log: the cockpit, search, token accounting and export all read THIS, so there is never a second
|
|
525
|
-
* parser of the same bytes. Content that precedes the first explicit `turn` event opens an implicit
|
|
526
|
-
* turn 0, so a producer that never emits turn boundaries still derives a coherent single-turn view.
|
|
527
|
-
*/
|
|
528
|
-
export function deriveView(events: Iterable<TranscriptEvent>): DerivedView {
|
|
529
|
-
const turns: MutableTurn[] = [];
|
|
530
|
-
const messages: DerivedMessage[] = [];
|
|
531
|
-
const tools: DerivedTool[] = [];
|
|
532
|
-
const permissions: DerivedPermission[] = [];
|
|
533
|
-
const openTools = new Map<string, DerivedTool>();
|
|
534
|
-
let anonymousTool: DerivedTool | undefined;
|
|
535
|
-
const openPermissions = new Map<string, DerivedPermission>();
|
|
536
|
-
let rawByteLength = 0;
|
|
537
|
-
let rawChunkCount = 0;
|
|
538
|
-
let lifecycle: "open" | "completed" | "exited" = "open";
|
|
539
|
-
let eventCount = 0;
|
|
540
|
-
let current: MutableTurn | undefined;
|
|
541
|
-
|
|
542
|
-
const ensureTurn = (offset: number): MutableTurn => {
|
|
543
|
-
if (current === undefined) {
|
|
544
|
-
current = { index: turns.length, startOffset: offset, messages: [], tools: [], permissions: [], steps: 0 };
|
|
545
|
-
turns.push(current);
|
|
546
|
-
}
|
|
547
|
-
return current;
|
|
548
|
-
};
|
|
549
|
-
|
|
550
|
-
for (const event of events) {
|
|
551
|
-
eventCount++;
|
|
552
|
-
switch (event.kind) {
|
|
553
|
-
case "turn": {
|
|
554
|
-
current = {
|
|
555
|
-
index: event.index ?? turns.length,
|
|
556
|
-
startOffset: event.offset,
|
|
557
|
-
messages: [],
|
|
558
|
-
tools: [],
|
|
559
|
-
permissions: [],
|
|
560
|
-
steps: 0,
|
|
561
|
-
};
|
|
562
|
-
turns.push(current);
|
|
563
|
-
break;
|
|
564
|
-
}
|
|
565
|
-
case "step": {
|
|
566
|
-
ensureTurn(event.offset).steps++;
|
|
567
|
-
break;
|
|
568
|
-
}
|
|
569
|
-
case "message": {
|
|
570
|
-
const msg: DerivedMessage = { role: event.role, text: event.text, offset: event.offset };
|
|
571
|
-
messages.push(msg);
|
|
572
|
-
ensureTurn(event.offset).messages.push(msg);
|
|
573
|
-
break;
|
|
574
|
-
}
|
|
575
|
-
case "tool-call": {
|
|
576
|
-
const tool: DerivedTool = {
|
|
577
|
-
name: event.name,
|
|
578
|
-
offset: event.offset,
|
|
579
|
-
...(event.callId !== undefined ? { callId: event.callId } : {}),
|
|
580
|
-
...(event.args !== undefined ? { args: event.args } : {}),
|
|
581
|
-
};
|
|
582
|
-
tools.push(tool);
|
|
583
|
-
ensureTurn(event.offset).tools.push(tool);
|
|
584
|
-
if (event.callId !== undefined) openTools.set(event.callId, tool);
|
|
585
|
-
else anonymousTool = tool;
|
|
586
|
-
break;
|
|
587
|
-
}
|
|
588
|
-
case "tool-result": {
|
|
589
|
-
const target = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
|
|
590
|
-
if (target !== undefined) {
|
|
591
|
-
pairResult(tools, target, event);
|
|
592
|
-
pairResultInTurns(turns, target, event);
|
|
593
|
-
if (event.callId !== undefined) openTools.delete(event.callId);
|
|
594
|
-
else anonymousTool = undefined;
|
|
595
|
-
}
|
|
596
|
-
break;
|
|
597
|
-
}
|
|
598
|
-
case "permission": {
|
|
599
|
-
// A `permission` event is one of two phases (same discriminant `kind`); branch on `phase`. A
|
|
600
|
-
// REQUEST opens a pending DerivedPermission (paired to its turn); a RESOLUTION folds back into
|
|
601
|
-
// the open request by `callId` — mirroring the tool-call/tool-result open-map pairing above.
|
|
602
|
-
if (event.phase === "request") {
|
|
603
|
-
const permission: DerivedPermission = {
|
|
604
|
-
policy: event.policy,
|
|
605
|
-
options: event.options,
|
|
606
|
-
offset: event.offset,
|
|
607
|
-
callId: event.callId,
|
|
608
|
-
...(event.toolName !== undefined ? { toolName: event.toolName } : {}),
|
|
609
|
-
...(event.title !== undefined ? { title: event.title } : {}),
|
|
610
|
-
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
611
|
-
};
|
|
612
|
-
permissions.push(permission);
|
|
613
|
-
ensureTurn(event.offset).permissions.push(permission);
|
|
614
|
-
openPermissions.set(event.callId, permission);
|
|
615
|
-
} else {
|
|
616
|
-
const target = openPermissions.get(event.callId);
|
|
617
|
-
if (target !== undefined) {
|
|
618
|
-
pairResolution(permissions, target, event);
|
|
619
|
-
pairResolutionInTurns(turns, target, event);
|
|
620
|
-
openPermissions.delete(event.callId);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
break;
|
|
624
|
-
}
|
|
625
|
-
case "lifecycle": {
|
|
626
|
-
lifecycle = event.phase;
|
|
627
|
-
break;
|
|
628
|
-
}
|
|
629
|
-
case "stream-chunk": {
|
|
630
|
-
rawByteLength += utf8ByteLength(event.chunk);
|
|
631
|
-
rawChunkCount++;
|
|
632
|
-
break;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
return {
|
|
638
|
-
turns: turns.map((t) => ({
|
|
639
|
-
index: t.index,
|
|
640
|
-
startOffset: t.startOffset,
|
|
641
|
-
messages: t.messages,
|
|
642
|
-
tools: t.tools,
|
|
643
|
-
permissions: t.permissions,
|
|
644
|
-
steps: t.steps,
|
|
645
|
-
})),
|
|
646
|
-
messages,
|
|
647
|
-
tools,
|
|
648
|
-
permissions,
|
|
649
|
-
rawByteLength,
|
|
650
|
-
rawChunkCount,
|
|
651
|
-
lifecycle,
|
|
652
|
-
eventCount,
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/** Replace a pending tool with its result in the flat list. A pending tool starts as the same object in
|
|
657
|
-
* both the flat list and its turn (pushed by reference), so {@link pairResultInTurns} locates it there by
|
|
658
|
-
* identity; each list is then replaced independently with its own resolved copy via {@link withResult}. */
|
|
659
|
-
function pairResult(list: DerivedTool[], target: DerivedTool, result: ToolResultEvent): void {
|
|
660
|
-
const idx = list.indexOf(target);
|
|
661
|
-
if (idx >= 0) list[idx] = withResult(target, result);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/** Replace a pending tool with its result inside whichever turn holds it. */
|
|
665
|
-
function pairResultInTurns(turns: MutableTurn[], target: DerivedTool, result: ToolResultEvent): void {
|
|
666
|
-
for (const turn of turns) {
|
|
667
|
-
const idx = turn.tools.indexOf(target);
|
|
668
|
-
if (idx >= 0) {
|
|
669
|
-
turn.tools[idx] = withResult(target, result);
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function withResult(tool: DerivedTool, result: ToolResultEvent): DerivedTool {
|
|
676
|
-
return {
|
|
677
|
-
...tool,
|
|
678
|
-
result: { ok: result.ok, offset: result.offset, ...(result.content !== undefined ? { content: result.content } : {}) },
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/** Replace a pending permission with its resolution in the flat list (by identity — see {@link pairResult}). */
|
|
683
|
-
function pairResolution(list: DerivedPermission[], target: DerivedPermission, resolution: PermissionResolutionEvent): void {
|
|
684
|
-
const idx = list.indexOf(target);
|
|
685
|
-
if (idx >= 0) list[idx] = withResolution(target, resolution);
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
/** Replace a pending permission with its resolution inside whichever turn holds it. */
|
|
689
|
-
function pairResolutionInTurns(turns: MutableTurn[], target: DerivedPermission, resolution: PermissionResolutionEvent): void {
|
|
690
|
-
for (const turn of turns) {
|
|
691
|
-
const idx = turn.permissions.indexOf(target);
|
|
692
|
-
if (idx >= 0) {
|
|
693
|
-
turn.permissions[idx] = withResolution(target, resolution);
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function withResolution(permission: DerivedPermission, resolution: PermissionResolutionEvent): DerivedPermission {
|
|
700
|
-
return {
|
|
701
|
-
...permission,
|
|
702
|
-
resolved: {
|
|
703
|
-
allowed: resolution.allowed,
|
|
704
|
-
optionId: resolution.optionId,
|
|
705
|
-
offset: resolution.offset,
|
|
706
|
-
...(resolution.by !== undefined ? { by: resolution.by } : {}),
|
|
707
|
-
},
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Convenience: parse a run of stored chunks into typed events through {@link parseTranscriptEvent} (the
|
|
713
|
-
* one parser) and fold them with {@link deriveView} in a single call — the entry point a consumer uses
|
|
714
|
-
* to go from stored bytes to a derived view without ever touching a second parser.
|
|
715
|
-
*/
|
|
716
|
-
export function deriveViewFromChunks(chunks: Iterable<StoredChunk>, vocab: TranscriptVocab = CORE_TRANSCRIPT_VOCAB): DerivedView {
|
|
717
|
-
function* parsed(): Generator<TranscriptEvent> {
|
|
718
|
-
for (const entry of chunks) yield parseTranscriptEvent(entry, vocab);
|
|
719
|
-
}
|
|
720
|
-
return deriveView(parsed());
|
|
721
|
-
}
|
|
12
|
+
// So this module is now a THIN RE-EXPORT BARREL over the single source of truth: every consumer
|
|
13
|
+
// (cockpit render, transcript-read, permission-bridge, token accounting, export) keeps importing the
|
|
14
|
+
// grammar from here, but the grammar itself lives in exactly one place — agentic. There is NO local
|
|
15
|
+
// definition of the marker, NO local re-parse of a stored chunk, and NO forked vocabulary; the
|
|
16
|
+
// canonical `mergeTranscriptVocab` / `CORE_TRANSCRIPT_VOCAB` are re-exported so a future nwf-specific
|
|
17
|
+
// event kind stays an additive merge, never a fork of the parser. `transcript-events.drift.test.ts`
|
|
18
|
+
// pins this structurally — it fails if a local envelope grammar or a chunk re-parser ever reappears.
|
|
19
|
+
export * from "@nanobpm/agentic/transcript";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@nanobpm/agentic": "^0.
|
|
65
|
+
"@nanobpm/agentic": "^0.10.0",
|
|
66
66
|
"@nanobpm/urban": "^0.88.1",
|
|
67
67
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
68
68
|
},
|
|
@@ -1,51 +1,50 @@
|
|
|
1
|
-
// @generated from
|
|
1
|
+
// @generated from node_modules/@nanobpm/agentic/dist/transcript/events.js by scripts/build-cockpit-browser.ts — DO NOT EDIT.
|
|
2
2
|
//
|
|
3
3
|
// Browser ESM derived (type-strip only) from the typed transcript core so pages/cockpit/mount.js
|
|
4
4
|
// renders the agentic transcript from ONE source of truth (#660). Regenerate with:
|
|
5
5
|
// node --experimental-strip-types scripts/build-cockpit-browser.ts
|
|
6
6
|
|
|
7
|
-
// nano-workforce — the transcript EVENT vocabulary + the single derive() fold (ADR 0056, #251).
|
|
8
|
-
//
|
|
9
|
-
// This is the "event-sourced session" layer over the H3 transcript store (#146/#222). The store is
|
|
10
|
-
// already append-only and offset-keyed — chunks are appended, never mutated — which is half of the
|
|
11
|
-
// dsh (DeepSeek Harness) event-sourced-session pattern. The gap it left is that chunks are opaque
|
|
12
|
-
// `TEXT`: every richer view (structured message history, tool cards, per-turn boundaries, token
|
|
13
|
-
// accounting) had to re-parse the raw frame bytes ad hoc, a DRIFT SURFACE (two parsers of the same
|
|
14
|
-
// bytes), which our "Derivation Over Duplication" doctrine forbids.
|
|
15
|
-
//
|
|
16
|
-
// This module closes that gap the way dsh does: the append-only log of TYPED events is the single
|
|
17
|
-
// source of truth, and every higher-level view is a DERIVATION of that one log via a single
|
|
18
|
-
// {@link deriveView} fold — "the log IS the state, so divergence is structurally impossible". A raw
|
|
19
|
-
// terminal chunk is retained verbatim as a `stream-chunk` event (byte-level replay fidelity is
|
|
20
|
-
// preserved); a producer that emits a structured, marker-tagged JSON envelope is decoded into the
|
|
21
|
-
// authoritative typed events (message / tool-call / tool-result / turn / step / lifecycle) the derived
|
|
22
|
-
// views fold over — mirroring dsh (raw chunks for token-replay, `assistant/message` authoritative).
|
|
23
|
-
//
|
|
24
|
-
// THE ONE PARSER. {@link parseTranscriptEvent} is the SINGLE place a stored chunk is classified into a
|
|
25
|
-
// typed event; every consumer (cockpit, search, token accounting, export) reads the derived view, not
|
|
26
|
-
// the raw bytes. A drift-guard test (`transcript-events.drift.test.ts`) asserts the event marker — and
|
|
27
|
-
// therefore the raw→event parse — appears in exactly this module, so a second parser cannot creep in.
|
|
28
|
-
//
|
|
29
|
-
// MERGE-EXTENSIBLE. The vocabulary is a small core ({@link CORE_TRANSCRIPT_VOCAB}) authors extend in the
|
|
30
|
-
// same schema with {@link mergeTranscriptVocab} (cribbed from dsh's merge-extensible event taxonomy and
|
|
31
|
-
// the S3 `mergeVocab`), so a new event kind is an additive merge, never a fork of the parser.
|
|
32
|
-
//
|
|
33
|
-
// Pure and side-effect-free: no I/O, unit-testable on Node, and it never touches the engine or a BPMN
|
|
34
|
-
// flow (ADR 0056: app-tier only, advisory).
|
|
35
7
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
8
|
+
* The transcript EVENT vocabulary + the single derive() fold (ADR 0056, #251).
|
|
9
|
+
*
|
|
10
|
+
* This is the "event-sourced session" layer over the S6 transcript store ({@link ./store.ts}). The
|
|
11
|
+
* store is already append-only and offset-keyed — chunks are appended, never mutated — which is half of
|
|
12
|
+
* the event-sourced-session pattern. The gap it left is that chunks are opaque `TEXT`: every richer
|
|
13
|
+
* view (structured message history, tool cards, per-turn boundaries, token accounting) had to re-parse
|
|
14
|
+
* the raw frame bytes ad hoc, a DRIFT SURFACE (two parsers of the same bytes), which our "Derivation
|
|
15
|
+
* Over Duplication" doctrine forbids.
|
|
16
|
+
*
|
|
17
|
+
* This module closes that gap: the append-only log of TYPED events is the single source of truth, and
|
|
18
|
+
* every higher-level view is a DERIVATION of that one log via a single {@link deriveView} fold — "the
|
|
19
|
+
* log IS the state, so divergence is structurally impossible". A raw terminal chunk is retained
|
|
20
|
+
* verbatim as a `stream-chunk` event (byte-level replay fidelity is preserved); a producer that emits a
|
|
21
|
+
* structured, marker-tagged JSON envelope is decoded into the authoritative typed events (message /
|
|
22
|
+
* tool-call / tool-result / turn / step / lifecycle) the derived views fold over.
|
|
23
|
+
*
|
|
24
|
+
* THE ONE PARSER. {@link parseTranscriptEvent} is the SINGLE place a stored chunk is classified into a
|
|
25
|
+
* typed event; every consumer (cockpit, search, token accounting, export) reads the derived view, not
|
|
26
|
+
* the raw bytes. A drift-guard test (`events.drift.test.ts`) asserts the event marker — and therefore
|
|
27
|
+
* the raw→event parse — appears in exactly this module, so a second parser cannot creep in.
|
|
28
|
+
*
|
|
29
|
+
* MERGE-EXTENSIBLE. The vocabulary is a small core ({@link CORE_TRANSCRIPT_VOCAB}) authors extend in the
|
|
30
|
+
* same schema with {@link mergeTranscriptVocab}, so a new event kind is an additive merge, never a fork
|
|
31
|
+
* of the parser. A downstream app (e.g. nano-workforce#559) registers its own `permission` kind this
|
|
32
|
+
* way without editing this package.
|
|
33
|
+
*
|
|
34
|
+
* BROWSER-SAFE. This module is imported by cockpit code that runs in the BROWSER (the cockpit derive),
|
|
35
|
+
* so it takes no hard dependency on Node's `Buffer` or any Node-only API — {@link utf8ByteLength} uses
|
|
36
|
+
* the Web/Node standard `TextEncoder`. It is pure and side-effect-free: no I/O, and it never touches the
|
|
37
|
+
* engine or a BPMN flow (ADR 0056: app-tier only, advisory).
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Runtime-safe UTF-8 byte length. The one canonical UTF-8 byte-length implementation the transcript
|
|
41
|
+
* plane derives from. Implemented with `TextEncoder` (a Web/Node standard) rather than Node's `Buffer`,
|
|
42
|
+
* so the single derive fold is portable across the browser (where `Buffer` is not available) and Node.
|
|
42
43
|
*/
|
|
43
44
|
let cachedTextEncoder;
|
|
44
45
|
export function utf8ByteLength(text) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Cache one TextEncoder in the browser hot path (folding many stream-chunk events) to avoid
|
|
48
|
-
// allocating a new encoder — and the GC pressure it creates — on every call.
|
|
46
|
+
// Cache one TextEncoder in the hot path (folding many stream-chunk events) to avoid allocating a new
|
|
47
|
+
// encoder — and the GC pressure it creates — on every call.
|
|
49
48
|
cachedTextEncoder ??= new TextEncoder();
|
|
50
49
|
return cachedTextEncoder.encode(text).length;
|
|
51
50
|
}
|
|
@@ -53,8 +52,9 @@ export function utf8ByteLength(text) {
|
|
|
53
52
|
* The reserved marker field that distinguishes a structured transcript-event envelope from raw
|
|
54
53
|
* terminal bytes. A stored chunk is decoded as a typed event ONLY when it is a JSON object carrying
|
|
55
54
|
* this field set to the schema version — otherwise it is retained verbatim as a raw `stream-chunk`, so
|
|
56
|
-
* a raw ANSI frame that happens to be valid JSON is never mis-classified. Namespaced
|
|
57
|
-
*
|
|
55
|
+
* a raw ANSI frame that happens to be valid JSON is never mis-classified. Namespaced so it cannot
|
|
56
|
+
* collide with a producer's own payload keys. This is the canonical single source of truth for the
|
|
57
|
+
* whole package family — consumers (e.g. the cockpit) import this identifier, never a private copy.
|
|
58
58
|
*/
|
|
59
59
|
export const TRANSCRIPT_EVENT_MARKER = "nwfTranscriptEvent";
|
|
60
60
|
/** The current transcript-event envelope schema version (the value {@link TRANSCRIPT_EVENT_MARKER} carries). */
|
|
@@ -117,7 +117,7 @@ function decodePermissionOptions(value) {
|
|
|
117
117
|
* parser. (`stream-chunk` is not decoded here — it is the fallback the parser applies to any chunk
|
|
118
118
|
* that is not a well-formed typed envelope, so raw fidelity needs no decoder.)
|
|
119
119
|
*/
|
|
120
|
-
export const CORE_TRANSCRIPT_VOCAB = Object.freeze({
|
|
120
|
+
export const CORE_TRANSCRIPT_VOCAB = Object.freeze(Object.assign(Object.create(null), {
|
|
121
121
|
message: (body, offset) => {
|
|
122
122
|
const text = str(body, "text");
|
|
123
123
|
if (text === undefined)
|
|
@@ -148,11 +148,6 @@ export const CORE_TRANSCRIPT_VOCAB = Object.freeze({
|
|
|
148
148
|
...(content !== undefined ? { content } : {}),
|
|
149
149
|
};
|
|
150
150
|
},
|
|
151
|
-
// ACP `plan` mapping: ACP `session/update` plan updates map onto the EXISTING `step`/`turn`
|
|
152
|
-
// vocabulary rather than a new kind — an ACP plan ENTRY becomes a `step` (its `label` is the plan
|
|
153
|
-
// entry's title; the entry ordinal is not preserved, as `StepEvent` carries only a `label`), and a
|
|
154
|
-
// plan/turn BOUNDARY becomes a `turn` (its `index` the ACP turn/plan ordinal). The decoders below
|
|
155
|
-
// already cope with an ACP-shaped `label` (`step`) / `index` (`turn`), so no new kind is needed.
|
|
156
151
|
turn: (body, offset) => {
|
|
157
152
|
const index = num(body, "index");
|
|
158
153
|
return index !== undefined ? { kind: "turn", offset, index } : { kind: "turn", offset };
|
|
@@ -184,6 +179,16 @@ export const CORE_TRANSCRIPT_VOCAB = Object.freeze({
|
|
|
184
179
|
const toolName = str(body, "toolName");
|
|
185
180
|
const title = str(body, "title");
|
|
186
181
|
const reason = str(body, "reason");
|
|
182
|
+
// Reject a present-but-non-string optional field rather than silently dropping it, mirroring the
|
|
183
|
+
// `by` treatment in the resolution path below: a present-but-invalid value is a producer bug, and
|
|
184
|
+
// swallowing it would make the typed event diverge from the on-wire JSON ("malformed → raw
|
|
185
|
+
// fallback, never a silent mis-decode").
|
|
186
|
+
if (body.toolName !== undefined && toolName === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
if (body.title !== undefined && title === undefined)
|
|
189
|
+
return undefined;
|
|
190
|
+
if (body.reason !== undefined && reason === undefined)
|
|
191
|
+
return undefined;
|
|
187
192
|
const event = { kind: "permission", phase: "request", offset, callId, policy, options };
|
|
188
193
|
return {
|
|
189
194
|
...event,
|
|
@@ -219,15 +224,28 @@ export const CORE_TRANSCRIPT_VOCAB = Object.freeze({
|
|
|
219
224
|
}
|
|
220
225
|
return undefined;
|
|
221
226
|
},
|
|
222
|
-
});
|
|
227
|
+
}));
|
|
228
|
+
/** The core event kinds the parser decodes from an envelope (every kind except the raw `stream-chunk`
|
|
229
|
+
* fallback). Kept as a runtime list so the drift-guard can assert the single fold handles them all. */
|
|
230
|
+
export const CORE_TRANSCRIPT_EVENT_KINDS = Object.freeze([
|
|
231
|
+
"message",
|
|
232
|
+
"tool-call",
|
|
233
|
+
"tool-result",
|
|
234
|
+
"turn",
|
|
235
|
+
"step",
|
|
236
|
+
"lifecycle",
|
|
237
|
+
"permission",
|
|
238
|
+
]);
|
|
223
239
|
/**
|
|
224
240
|
* Extend a vocabulary additively: later entries win on a key clash, so an author can either register a
|
|
225
|
-
* brand-new kind or deliberately override a core decoder. Returns a NEW frozen vocab —
|
|
226
|
-
* mutated — so the core stays canonical
|
|
227
|
-
*
|
|
241
|
+
* brand-new kind or deliberately override a core decoder. Returns a NEW frozen, NULL-PROTOTYPE vocab —
|
|
242
|
+
* neither input is mutated — so the core stays canonical AND `kind in vocab` / `Object.keys(vocab)`
|
|
243
|
+
* only ever see own decoders (an inherited "toString"/"constructor" key can never masquerade as one).
|
|
244
|
+
* This is the EXTENSION POINT a downstream app uses to add its own kind (e.g. a synthetic `annotation`
|
|
245
|
+
* kind) without editing this package: one schema, extended by merge, never a second parser.
|
|
228
246
|
*/
|
|
229
247
|
export function mergeTranscriptVocab(base, ...extensions) {
|
|
230
|
-
return Object.freeze(Object.assign(
|
|
248
|
+
return Object.freeze(Object.assign(Object.create(null), base, ...extensions));
|
|
231
249
|
}
|
|
232
250
|
/**
|
|
233
251
|
* THE ONE PARSER. Classify a single stored chunk into a typed {@link TranscriptEvent}.
|
|
@@ -247,8 +265,12 @@ export function parseTranscriptEvent(entry, vocab = CORE_TRANSCRIPT_VOCAB) {
|
|
|
247
265
|
const kind = typeof body.kind === "string" ? body.kind : undefined;
|
|
248
266
|
if (kind === undefined)
|
|
249
267
|
return raw;
|
|
250
|
-
|
|
251
|
-
|
|
268
|
+
// Own-property + typeof-function guard: `kind` is untrusted, so a bare `vocab[kind]` would resolve
|
|
269
|
+
// inherited members like "constructor"/"toString"/"__proto__" up the prototype chain to a
|
|
270
|
+
// non-decoder function and call it — a crashable (DoS) / invariant-breaking path. Only an OWN
|
|
271
|
+
// decoder function is ever invoked; everything else falls back to the raw stream-chunk.
|
|
272
|
+
const decoder = Object.prototype.hasOwnProperty.call(vocab, kind) ? vocab[kind] : undefined;
|
|
273
|
+
if (typeof decoder !== "function")
|
|
252
274
|
return raw;
|
|
253
275
|
return decoder(body, entry.offset) ?? raw;
|
|
254
276
|
}
|
|
@@ -291,8 +313,10 @@ export function encodeTranscriptEvent(event) {
|
|
|
291
313
|
* message history, tool cards (each call paired to its result by `callId`, else the most recent open
|
|
292
314
|
* call), raw-byte accounting for replay fidelity, and the session lifecycle. It is a pure reduction of
|
|
293
315
|
* one log: the cockpit, search, token accounting and export all read THIS, so there is never a second
|
|
294
|
-
* parser of the same bytes.
|
|
295
|
-
* turn 0, so a producer that never emits turn boundaries still
|
|
316
|
+
* parser of the same bytes. Typed content — a message, tool-call or step — that precedes the first
|
|
317
|
+
* explicit `turn` event opens an implicit turn 0, so a producer that never emits turn boundaries still
|
|
318
|
+
* derives a coherent single-turn view. Raw `stream-chunk` events alone open no turn (they only feed the
|
|
319
|
+
* byte-replay accounting), so a log of only chunks derives zero turns.
|
|
296
320
|
*/
|
|
297
321
|
export function deriveView(events) {
|
|
298
322
|
const turns = [];
|
|
@@ -318,14 +342,7 @@ export function deriveView(events) {
|
|
|
318
342
|
eventCount++;
|
|
319
343
|
switch (event.kind) {
|
|
320
344
|
case "turn": {
|
|
321
|
-
current = {
|
|
322
|
-
index: event.index ?? turns.length,
|
|
323
|
-
startOffset: event.offset,
|
|
324
|
-
messages: [],
|
|
325
|
-
tools: [],
|
|
326
|
-
permissions: [],
|
|
327
|
-
steps: 0,
|
|
328
|
-
};
|
|
345
|
+
current = { index: event.index ?? turns.length, startOffset: event.offset, messages: [], tools: [], permissions: [], steps: 0 };
|
|
329
346
|
turns.push(current);
|
|
330
347
|
break;
|
|
331
348
|
}
|
|
@@ -346,19 +363,22 @@ export function deriveView(events) {
|
|
|
346
363
|
...(event.callId !== undefined ? { callId: event.callId } : {}),
|
|
347
364
|
...(event.args !== undefined ? { args: event.args } : {}),
|
|
348
365
|
};
|
|
349
|
-
tools.push(tool);
|
|
350
|
-
ensureTurn(event.offset)
|
|
366
|
+
const toolsIndex = tools.push(tool) - 1;
|
|
367
|
+
const turn = ensureTurn(event.offset);
|
|
368
|
+
const turnToolIndex = turn.tools.push(tool) - 1;
|
|
369
|
+
const pending = { tool, toolsIndex, turn, turnToolIndex };
|
|
351
370
|
if (event.callId !== undefined)
|
|
352
|
-
openTools.set(event.callId,
|
|
371
|
+
openTools.set(event.callId, pending);
|
|
353
372
|
else
|
|
354
|
-
anonymousTool =
|
|
373
|
+
anonymousTool = pending;
|
|
355
374
|
break;
|
|
356
375
|
}
|
|
357
376
|
case "tool-result": {
|
|
358
|
-
const
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
377
|
+
const pending = event.callId !== undefined ? openTools.get(event.callId) : anonymousTool;
|
|
378
|
+
if (pending !== undefined) {
|
|
379
|
+
const resolved = withResult(pending.tool, event);
|
|
380
|
+
tools[pending.toolsIndex] = resolved;
|
|
381
|
+
pending.turn.tools[pending.turnToolIndex] = resolved;
|
|
362
382
|
if (event.callId !== undefined)
|
|
363
383
|
openTools.delete(event.callId);
|
|
364
384
|
else
|
|
@@ -369,7 +389,7 @@ export function deriveView(events) {
|
|
|
369
389
|
case "permission": {
|
|
370
390
|
// A `permission` event is one of two phases (same discriminant `kind`); branch on `phase`. A
|
|
371
391
|
// REQUEST opens a pending DerivedPermission (paired to its turn); a RESOLUTION folds back into
|
|
372
|
-
// the open request by `callId` — mirroring the tool-call/tool-result
|
|
392
|
+
// the open request by `callId` in O(1) — mirroring the tool-call/tool-result pending-map pairing.
|
|
373
393
|
if (event.phase === "request") {
|
|
374
394
|
const permission = {
|
|
375
395
|
policy: event.policy,
|
|
@@ -380,15 +400,17 @@ export function deriveView(events) {
|
|
|
380
400
|
...(event.title !== undefined ? { title: event.title } : {}),
|
|
381
401
|
...(event.reason !== undefined ? { reason: event.reason } : {}),
|
|
382
402
|
};
|
|
383
|
-
permissions.push(permission);
|
|
384
|
-
ensureTurn(event.offset)
|
|
385
|
-
|
|
403
|
+
const permissionsIndex = permissions.push(permission) - 1;
|
|
404
|
+
const turn = ensureTurn(event.offset);
|
|
405
|
+
const turnPermissionIndex = turn.permissions.push(permission) - 1;
|
|
406
|
+
openPermissions.set(event.callId, { permission, permissionsIndex, turn, turnPermissionIndex });
|
|
386
407
|
}
|
|
387
408
|
else {
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
409
|
+
const pending = openPermissions.get(event.callId);
|
|
410
|
+
if (pending !== undefined) {
|
|
411
|
+
const resolved = withResolution(pending.permission, event);
|
|
412
|
+
permissions[pending.permissionsIndex] = resolved;
|
|
413
|
+
pending.turn.permissions[pending.turnPermissionIndex] = resolved;
|
|
392
414
|
openPermissions.delete(event.callId);
|
|
393
415
|
}
|
|
394
416
|
}
|
|
@@ -406,14 +428,7 @@ export function deriveView(events) {
|
|
|
406
428
|
}
|
|
407
429
|
}
|
|
408
430
|
return {
|
|
409
|
-
turns: turns.map((t) => ({
|
|
410
|
-
index: t.index,
|
|
411
|
-
startOffset: t.startOffset,
|
|
412
|
-
messages: t.messages,
|
|
413
|
-
tools: t.tools,
|
|
414
|
-
permissions: t.permissions,
|
|
415
|
-
steps: t.steps,
|
|
416
|
-
})),
|
|
431
|
+
turns: turns.map((t) => ({ index: t.index, startOffset: t.startOffset, messages: t.messages, tools: t.tools, permissions: t.permissions, steps: t.steps })),
|
|
417
432
|
messages,
|
|
418
433
|
tools,
|
|
419
434
|
permissions,
|
|
@@ -423,46 +438,14 @@ export function deriveView(events) {
|
|
|
423
438
|
eventCount,
|
|
424
439
|
};
|
|
425
440
|
}
|
|
426
|
-
/** Replace a pending tool with its result
|
|
427
|
-
* both the flat list and its turn (pushed by reference), so {@link pairResultInTurns} locates it there by
|
|
428
|
-
* identity; each list is then replaced independently with its own resolved copy via {@link withResult}. */
|
|
429
|
-
function pairResult(list, target, result) {
|
|
430
|
-
const idx = list.indexOf(target);
|
|
431
|
-
if (idx >= 0)
|
|
432
|
-
list[idx] = withResult(target, result);
|
|
433
|
-
}
|
|
434
|
-
/** Replace a pending tool with its result inside whichever turn holds it. */
|
|
435
|
-
function pairResultInTurns(turns, target, result) {
|
|
436
|
-
for (const turn of turns) {
|
|
437
|
-
const idx = turn.tools.indexOf(target);
|
|
438
|
-
if (idx >= 0) {
|
|
439
|
-
turn.tools[idx] = withResult(target, result);
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
441
|
+
/** Replace a pending tool with its result: a new resolved card that carries the result payload. */
|
|
444
442
|
function withResult(tool, result) {
|
|
445
443
|
return {
|
|
446
444
|
...tool,
|
|
447
445
|
result: { ok: result.ok, offset: result.offset, ...(result.content !== undefined ? { content: result.content } : {}) },
|
|
448
446
|
};
|
|
449
447
|
}
|
|
450
|
-
/** Replace a pending permission with its resolution
|
|
451
|
-
function pairResolution(list, target, resolution) {
|
|
452
|
-
const idx = list.indexOf(target);
|
|
453
|
-
if (idx >= 0)
|
|
454
|
-
list[idx] = withResolution(target, resolution);
|
|
455
|
-
}
|
|
456
|
-
/** Replace a pending permission with its resolution inside whichever turn holds it. */
|
|
457
|
-
function pairResolutionInTurns(turns, target, resolution) {
|
|
458
|
-
for (const turn of turns) {
|
|
459
|
-
const idx = turn.permissions.indexOf(target);
|
|
460
|
-
if (idx >= 0) {
|
|
461
|
-
turn.permissions[idx] = withResolution(target, resolution);
|
|
462
|
-
return;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
}
|
|
448
|
+
/** Replace a pending permission with its resolution: a new card carrying the operator's decision. */
|
|
466
449
|
function withResolution(permission, resolution) {
|
|
467
450
|
return {
|
|
468
451
|
...permission,
|
|
@@ -30,12 +30,18 @@ interface BundleModule {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// The transcript RENDER path the browser needs is a two-module graph, both pure + DOM-agnostic:
|
|
33
|
-
// transcript-events
|
|
34
|
-
// transcript-derive.ts
|
|
33
|
+
// transcript-events — the ONE parser + derive() fold (no runtime imports).
|
|
34
|
+
// transcript-derive.ts — renderDerivedTranscript(): message turns, tool/diff cards, permission prompts.
|
|
35
35
|
// Their only non-type imports are between each other; every type-only import (DocumentLike, ElementLike,
|
|
36
36
|
// TranscriptDataReport) is `import type` and is erased on transpile, so the emitted JS is import-clean.
|
|
37
|
+
//
|
|
38
|
+
// The grammar module is now DEFINED once, in `@nanobpm/agentic/transcript` (issue #676) — nano-workforce
|
|
39
|
+
// no longer forks it. So the browser bundle for the parser+fold is DERIVED from agentic's own published,
|
|
40
|
+
// self-contained ESM (`dist/transcript/events.js` has no runtime imports), the SAME source of truth the
|
|
41
|
+
// Node core re-exports via the `transcript-events.ts` barrel. This keeps "one grammar, no drift surface"
|
|
42
|
+
// true across BOTH hosts: the cockpit renders from agentic's grammar, never a hand-rolled second copy.
|
|
37
43
|
const MODULES: readonly BundleModule[] = [
|
|
38
|
-
{ src: "
|
|
44
|
+
{ src: "node_modules/@nanobpm/agentic/dist/transcript/events.js", out: "pages/cockpit/generated/transcript-events.js" },
|
|
39
45
|
{
|
|
40
46
|
src: "app/agentic/cockpit/transcript-derive.ts",
|
|
41
47
|
out: "pages/cockpit/generated/transcript-derive.js",
|