@north-light/crouter 0.3.205 → 0.3.206
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/dist/api/dto/messages.d.ts +22 -1
- package/dist/clients/attach/viewer.js +293 -293
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -2
- package/dist/core/__tests__/context-intro.test.js +7 -8
- package/dist/core/__tests__/kickoff.test.js +6 -6
- package/dist/core/runtime/bearings.js +5 -4
- package/dist/core/runtime/broker/frame-dispatch.js +10 -1
- package/dist/core/runtime/broker/inbox.d.ts +7 -1
- package/dist/core/runtime/broker/inbox.js +19 -3
- package/dist/core/runtime/broker-extension-render.d.ts +3 -0
- package/dist/core/runtime/broker-extension-render.js +3 -0
- package/dist/core/runtime/broker-protocol.d.ts +5 -0
- package/dist/core/runtime/interactive-deliver.d.ts +3 -2
- package/dist/core/runtime/interactive-deliver.js +4 -3
- package/dist/core/runtime/kickoff.js +4 -3
- package/dist/core/runtime/situational-context.d.ts +14 -5
- package/dist/core/runtime/situational-context.js +39 -21
- package/dist/core/runtime/situational-live.d.ts +2 -2
- package/dist/core/runtime/situational-live.js +17 -17
- package/dist/core/runtime/spawn.js +4 -3
- package/dist/daemon/api/handlers/messages.js +101 -17
- package/dist/daemon/api/handlers/nodes.js +2 -1
- package/dist/pi-extensions/canvas-context-intro.js +5 -5
- package/dist/pi-extensions/canvas-inbox-watcher.js +99 -63
- package/dist/shared/generated-context.d.ts +11 -0
- package/dist/shared/generated-context.js +29 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -9,7 +9,8 @@ import { cancelPendingHumanMessages } from '../../../core/feed/inbox.js';
|
|
|
9
9
|
import { withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
|
|
10
10
|
import { assertNotFinalized, assertFinalizedForReopen, commitReopen } from '../../../core/runtime/reopen.js';
|
|
11
11
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
12
|
-
import { appendSituationalContext } from '../../../core/runtime/situational-context.js';
|
|
12
|
+
import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
13
|
+
import { formatDataCard } from '../../../shared/generated-context.js';
|
|
13
14
|
import { hasNoNaturalCycle } from '../../../core/runtime/revive-all.js';
|
|
14
15
|
import { readGoal } from '../../../core/runtime/kickoff.js';
|
|
15
16
|
import { readRoadmap } from '../../../core/runtime/roadmap.js';
|
|
@@ -20,6 +21,53 @@ import { ApiError } from '../../../api/index.js';
|
|
|
20
21
|
// POST /v1/nodes/{id}/messages
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
const TIERS = ['critical', 'urgent', 'normal', 'deferred'];
|
|
24
|
+
/** Validate + narrow one caller-supplied runtime card. A bare kind is crouter's
|
|
25
|
+
* own closed vocabulary, so a caller must namespace: no product can shadow a
|
|
26
|
+
* core kind, and the parser's decode rule keys on exactly this property. */
|
|
27
|
+
function parseCardRequest(value, field) {
|
|
28
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
29
|
+
throw usage(`${field} must be an object { kind, facts?, body? }`);
|
|
30
|
+
}
|
|
31
|
+
const raw = value;
|
|
32
|
+
const kind = raw['kind'];
|
|
33
|
+
if (typeof kind !== 'string' || kind.trim() === '')
|
|
34
|
+
throw usage(`${field}.kind is required`);
|
|
35
|
+
if (!kind.includes(':'))
|
|
36
|
+
throw usage(`${field}.kind must be namespaced (contain ':'): ${kind}`);
|
|
37
|
+
const card = { kind };
|
|
38
|
+
const facts = raw['facts'];
|
|
39
|
+
if (facts !== undefined) {
|
|
40
|
+
if (typeof facts !== 'object' || facts === null || Array.isArray(facts)) {
|
|
41
|
+
throw usage(`${field}.facts must be an object of string or number values`);
|
|
42
|
+
}
|
|
43
|
+
const values = {};
|
|
44
|
+
for (const [name, fact] of Object.entries(facts)) {
|
|
45
|
+
if (typeof fact !== 'string' && typeof fact !== 'number') {
|
|
46
|
+
throw usage(`${field}.facts.${name} must be a string or number`);
|
|
47
|
+
}
|
|
48
|
+
values[name] = fact;
|
|
49
|
+
}
|
|
50
|
+
card.facts = values;
|
|
51
|
+
}
|
|
52
|
+
const body = raw['body'];
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
if (typeof body !== 'string')
|
|
55
|
+
throw usage(`${field}.body must be a string`);
|
|
56
|
+
card.body = body;
|
|
57
|
+
}
|
|
58
|
+
return card;
|
|
59
|
+
}
|
|
60
|
+
/** Render a validated card. `formatDataCard` escapes the body exactly once —
|
|
61
|
+
* this is the ONLY escape on the path — and rejects a fact name that cannot be
|
|
62
|
+
* an XML attribute, which is caller input and so a 400, not a 500. */
|
|
63
|
+
function renderCard(card, field) {
|
|
64
|
+
try {
|
|
65
|
+
return formatDataCard(card.kind, card.facts ?? {}, card.body ?? '');
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw usage(`${field}: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
23
71
|
/** Validate + narrow the send-message body (spec §6.2). */
|
|
24
72
|
function parseSendBody(body) {
|
|
25
73
|
if (typeof body !== 'object' || body === null) {
|
|
@@ -34,10 +82,29 @@ function parseSendBody(body) {
|
|
|
34
82
|
const hasBody = typeof text === 'string' && text.trim() !== '';
|
|
35
83
|
const hasSituational = typeof situational === 'string' && situational.trim() !== '';
|
|
36
84
|
const hasSchema = typeof outputSchema === 'string' && outputSchema.trim() !== '';
|
|
37
|
-
|
|
85
|
+
const situationalCardRaw = b['situational_card'];
|
|
86
|
+
const contextCardsRaw = b['context_cards'];
|
|
87
|
+
const situationalCard = situationalCardRaw === undefined
|
|
88
|
+
? undefined
|
|
89
|
+
: parseCardRequest(situationalCardRaw, 'situational_card');
|
|
90
|
+
if (contextCardsRaw !== undefined && !Array.isArray(contextCardsRaw)) {
|
|
91
|
+
throw usage('context_cards must be an array of { kind, facts?, body? }');
|
|
92
|
+
}
|
|
93
|
+
const contextCards = contextCardsRaw?.map((card, index) => parseCardRequest(card, `context_cards[${index}]`));
|
|
94
|
+
// The sidecar is singular, so the card form and the prose form are one slot.
|
|
95
|
+
if (situationalCard !== undefined && typeof situational === 'string' && situational !== '') {
|
|
96
|
+
throw usage('situational_card and situational_context are mutually exclusive');
|
|
97
|
+
}
|
|
98
|
+
const hasCards = situationalCard !== undefined || (contextCards !== undefined && contextCards.length > 0);
|
|
99
|
+
// A body is required unless this is a fresh revive, a context-only update,
|
|
38
100
|
// or a one-off output-schema request.
|
|
39
|
-
if (!fresh && !hasBody && !hasSituational && !hasSchema) {
|
|
40
|
-
throw usage('a message requires a non-empty body (or fresh: true, situational_context, or output_schema)');
|
|
101
|
+
if (!fresh && !hasBody && !hasSituational && !hasSchema && !hasCards) {
|
|
102
|
+
throw usage('a message requires a non-empty body (or fresh: true, situational_context, a runtime card, or output_schema)');
|
|
103
|
+
}
|
|
104
|
+
// A fresh revive appends no inbox entry, so a one-shot card would have no
|
|
105
|
+
// carrier. The sidecar card survives the revive through the bearings.
|
|
106
|
+
if (fresh && contextCards !== undefined && contextCards.length > 0) {
|
|
107
|
+
throw usage('context_cards cannot ride a fresh revive (no inbox entry carries them)');
|
|
41
108
|
}
|
|
42
109
|
if (tierRaw !== undefined && !TIERS.includes(tierRaw)) {
|
|
43
110
|
throw usage(`invalid tier: ${String(tierRaw)} (expected critical|urgent|normal|deferred)`);
|
|
@@ -51,6 +118,10 @@ function parseSendBody(body) {
|
|
|
51
118
|
out.reopen = true;
|
|
52
119
|
if (typeof situational === 'string')
|
|
53
120
|
out.situational_context = situational;
|
|
121
|
+
if (situationalCard !== undefined)
|
|
122
|
+
out.situational_card = situationalCard;
|
|
123
|
+
if (contextCards !== undefined)
|
|
124
|
+
out.context_cards = contextCards;
|
|
54
125
|
if (typeof outputSchema === 'string')
|
|
55
126
|
out.output_schema = outputSchema;
|
|
56
127
|
if (typeof b['from'] === 'string')
|
|
@@ -62,8 +133,11 @@ function parseSendBody(body) {
|
|
|
62
133
|
if (delivery === 'interactive') {
|
|
63
134
|
// Interactive delivery is a live-conversation send: a plain immediate body,
|
|
64
135
|
// nothing that only makes sense on the durable path.
|
|
136
|
+
// Runtime cards ARE accepted here: a card-bearing send is an ordinary human
|
|
137
|
+
// send that happens to carry context, and the live deliver frame places the
|
|
138
|
+
// cards ahead of the body in one turn (D11).
|
|
65
139
|
if (fresh || out.reopen === true || out.situational_context !== undefined || out.output_schema !== undefined) {
|
|
66
|
-
throw usage('interactive delivery supports a
|
|
140
|
+
throw usage('interactive delivery supports a body and runtime cards only (no fresh, reopen, situational_context, or output_schema)');
|
|
67
141
|
}
|
|
68
142
|
if (!hasBody)
|
|
69
143
|
throw usage('interactive delivery requires a non-empty body');
|
|
@@ -122,9 +196,14 @@ async function handleMessage(ctx) {
|
|
|
122
196
|
const tier = (req.tier ?? 'normal');
|
|
123
197
|
const from = req.from ?? null;
|
|
124
198
|
const hasBody = req.body.trim() !== '';
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
199
|
+
// One sidecar slot: the caller's rendered card, or prose rendered as one.
|
|
200
|
+
const sidecar = req.situational_card !== undefined
|
|
201
|
+
? renderCard(req.situational_card, 'situational_card')
|
|
202
|
+
: req.situational_context !== undefined
|
|
203
|
+
? formatSituationalProse(req.situational_context.trim())
|
|
204
|
+
: '';
|
|
205
|
+
const hasSidecar = sidecar !== '';
|
|
206
|
+
const oneShotCards = (req.context_cards ?? []).map((card, index) => renderCard(card, `context_cards[${index}]`));
|
|
128
207
|
const schema = req.output_schema !== undefined && req.output_schema.trim() !== ''
|
|
129
208
|
? parseSchema(req.output_schema)
|
|
130
209
|
: null;
|
|
@@ -138,8 +217,8 @@ async function handleMessage(ctx) {
|
|
|
138
217
|
if (!req.reopen)
|
|
139
218
|
assertNotFinalized(id);
|
|
140
219
|
assertRecoverableForFresh(id);
|
|
141
|
-
if (
|
|
142
|
-
appendSituationalContext(id,
|
|
220
|
+
if (hasSidecar)
|
|
221
|
+
appendSituationalContext(id, sidecar);
|
|
143
222
|
if (req.reopen)
|
|
144
223
|
commitReopen(id, expectedFinalReport);
|
|
145
224
|
const result = reviveNode(id, { resume: false });
|
|
@@ -158,7 +237,9 @@ async function handleMessage(ctx) {
|
|
|
158
237
|
// straight there (append + revive, watcher delivers post-boot). ---
|
|
159
238
|
if (req.delivery === 'interactive' && isBrokerLive(meta)) {
|
|
160
239
|
try {
|
|
161
|
-
|
|
240
|
+
if (hasSidecar)
|
|
241
|
+
appendSituationalContext(id, sidecar);
|
|
242
|
+
await deliverLive(id, req.body, [...(hasSidecar ? [sidecar] : []), ...oneShotCards]);
|
|
162
243
|
const body = {
|
|
163
244
|
node_id: id,
|
|
164
245
|
delivered: true,
|
|
@@ -182,10 +263,11 @@ async function handleMessage(ctx) {
|
|
|
182
263
|
assertNotFinalized(id);
|
|
183
264
|
if (schema !== null)
|
|
184
265
|
writeOutputSchema(id, 'oneoff', schema);
|
|
185
|
-
if (
|
|
186
|
-
appendSituationalContext(id,
|
|
266
|
+
if (hasSidecar)
|
|
267
|
+
appendSituationalContext(id, sidecar);
|
|
187
268
|
if (req.reopen)
|
|
188
269
|
commitReopen(id, expectedFinalReport);
|
|
270
|
+
const cards = oneShotCards.length > 0 ? { cards: oneShotCards } : {};
|
|
189
271
|
if (hasBody || schema !== null) {
|
|
190
272
|
const messageBody = hasBody
|
|
191
273
|
? req.body
|
|
@@ -195,16 +277,18 @@ async function handleMessage(ctx) {
|
|
|
195
277
|
tier,
|
|
196
278
|
kind: 'message',
|
|
197
279
|
label: label(messageBody),
|
|
198
|
-
data: { body: messageBody, ...(
|
|
280
|
+
data: { body: messageBody, ...(hasSidecar ? { situational: true } : {}), ...cards },
|
|
199
281
|
});
|
|
200
282
|
}
|
|
201
|
-
//
|
|
283
|
+
// Context-only: a hidden wake marker excluded from the visible digest. Its
|
|
284
|
+
// one-shot cards have no visible delivery to ride, so the watcher's context
|
|
285
|
+
// channel carries them.
|
|
202
286
|
return appendInbox(id, {
|
|
203
287
|
from,
|
|
204
288
|
tier,
|
|
205
289
|
kind: 'message',
|
|
206
|
-
label: '(ambient context updated)',
|
|
207
|
-
data: { situational: true, situationalOnly: true },
|
|
290
|
+
label: hasSidecar ? '(ambient context updated)' : '(context delivered)',
|
|
291
|
+
data: { ...(hasSidecar ? { situational: true } : {}), situationalOnly: true, ...cards },
|
|
208
292
|
});
|
|
209
293
|
};
|
|
210
294
|
// Deferred guard: a done/canceled/finalized target has no natural
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readdirSync } from 'node:fs';
|
|
10
10
|
import { resolve } from 'node:path';
|
|
11
11
|
import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
|
|
12
|
+
import { formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
12
13
|
import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
|
|
13
14
|
import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
14
15
|
import { subtreeIds } from '../../../core/canvas/nav-model.js';
|
|
@@ -543,7 +544,7 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
543
544
|
let meta = requireMeta(id);
|
|
544
545
|
const patch = ctx.body ?? {};
|
|
545
546
|
if (patch.situational_context !== undefined) {
|
|
546
|
-
appendSituationalContext(id, patch.situational_context);
|
|
547
|
+
appendSituationalContext(id, formatSituationalProse(patch.situational_context));
|
|
547
548
|
}
|
|
548
549
|
if (patch.model !== undefined) {
|
|
549
550
|
const pinned = isProviderPinnedModelToken(patch.model);
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
import { CONTEXT_INTRO_CUSTOM_TYPE, formatCard, parseCard } from '../shared/generated-context.js';
|
|
30
30
|
import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
31
31
|
import { buildContextBearingsFromState, buildForkBearingsFromState, buildProjectContextBlockForBroker } from '../core/runtime/broker-extension-render.js';
|
|
32
|
-
import {
|
|
32
|
+
import { situationalContextEnvelope } from '../core/runtime/situational-context.js';
|
|
33
33
|
import { saveInjectedDocs, sharedInjectedDocs } from '../core/substrate/injected-store.js';
|
|
34
34
|
import { truncateToWidth } from './truncate.js';
|
|
35
35
|
/** The `customType` stamped on the injected session message. Used both to write
|
|
@@ -44,13 +44,13 @@ export { CONTEXT_INTRO_CUSTOM_TYPE };
|
|
|
44
44
|
* crouter context. Exported for testing. */
|
|
45
45
|
export async function buildContextIntro(nodeId, seen) {
|
|
46
46
|
const state = await brokerExtensionState(nodeId);
|
|
47
|
-
return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd),
|
|
47
|
+
return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextEnvelope(nodeId) ?? '', seen);
|
|
48
48
|
}
|
|
49
49
|
/** Build the compact node-scoped update for a fork whose copied branch already
|
|
50
50
|
* carries a full crouter baseline. Exported for testing. */
|
|
51
51
|
export async function buildForkContextIntro(nodeId) {
|
|
52
52
|
const state = await brokerExtensionState(nodeId);
|
|
53
|
-
return buildForkBearingsFromState(state,
|
|
53
|
+
return buildForkBearingsFromState(state, situationalContextEnvelope(nodeId) ?? '');
|
|
54
54
|
}
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
// Collapsed-by-default rendering
|
|
@@ -169,8 +169,8 @@ export function registerCanvasContextIntro(pi) {
|
|
|
169
169
|
// docs and needs no set.
|
|
170
170
|
const seen = inherited && forked ? undefined : sharedInjectedDocs(nodeId);
|
|
171
171
|
const content = inherited && forked
|
|
172
|
-
? buildForkBearingsFromState(state,
|
|
173
|
-
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd),
|
|
172
|
+
? buildForkBearingsFromState(state, situationalContextEnvelope(nodeId) ?? '')
|
|
173
|
+
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextEnvelope(nodeId) ?? '', seen);
|
|
174
174
|
pi.sendMessage({
|
|
175
175
|
customType: CONTEXT_INTRO_CUSTOM_TYPE,
|
|
176
176
|
content: formatCard('bearings', {}, content),
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
// canvas-inbox-watcher.ts — in-process delivery of a canvas node's active inbox.
|
|
2
2
|
//
|
|
3
3
|
// The watcher polls the append-only inbox in physical order, coalesces each quiet
|
|
4
|
-
// burst, and hands hidden
|
|
4
|
+
// burst, and hands hidden context cards and visible deliveries to pi. A
|
|
5
5
|
// burst mixing a person's words with node reports splits into several visible
|
|
6
6
|
// deliveries, one per cycle. A send return is only a handoff. Exact cursor persistence at agent_settled is the
|
|
7
7
|
// durable boundary; an uncommitted process exit is recovered by rereading from
|
|
8
8
|
// the unchanged cursor.
|
|
9
|
-
|
|
9
|
+
//
|
|
10
|
+
// Context sends never trigger a turn: the visible human delivery is the sole
|
|
11
|
+
// trigger, so the agent cannot run on context alone before hearing the person.
|
|
12
|
+
// A context-only batch keeps its wake by triggering on its LAST card while idle,
|
|
13
|
+
// and rides the running turn while streaming.
|
|
14
|
+
import { coalesceBrokerInbox, entryCards, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
|
|
10
15
|
import { advanceBrokerInboxCursor, brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
11
16
|
import { emitEvent } from '../core/events/emit.js';
|
|
12
17
|
import { errorClassFromError } from '../core/events/errors.js';
|
|
13
18
|
import { operationIdContext } from '../core/events/operation-id.js';
|
|
14
|
-
import {
|
|
15
|
-
import { formatCard } from '../shared/generated-context.js';
|
|
19
|
+
import { situationalContextEnvelope, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
|
|
16
20
|
const DEFAULT_TICK_MS = 800;
|
|
17
21
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
18
22
|
const MAX_PENDING_HANDOFFS = 64;
|
|
@@ -561,14 +565,19 @@ class CanvasInboxWatcher {
|
|
|
561
565
|
const hiddenCoverage = this.coveredEntryIds('hidden');
|
|
562
566
|
const visibleCoverage = this.coveredEntryIds('visible');
|
|
563
567
|
return {
|
|
564
|
-
|
|
568
|
+
// The context channel carries a batch's sidecar card and the one-shot
|
|
569
|
+
// cards of entries that get no visible delivery of their own. A
|
|
570
|
+
// `situationalOnly` entry is in NO other channel, so its coverage and
|
|
571
|
+
// commit depend on being routed here even when it carries no sidecar.
|
|
572
|
+
hiddenEntries: live.filter((entry) => (entry.data?.['situational'] === true || entry.data?.['situationalOnly'] === true)
|
|
573
|
+
&& !hiddenCoverage.has(entry.entry_id)),
|
|
565
574
|
visibleEntries: live.filter((entry) => entry.data?.['situationalOnly'] !== true && !visibleCoverage.has(entry.entry_id)),
|
|
566
575
|
};
|
|
567
576
|
}
|
|
568
|
-
visibleRoute(entries
|
|
577
|
+
visibleRoute(entries) {
|
|
569
578
|
if (entries.length === 0)
|
|
570
579
|
return 'none';
|
|
571
|
-
if (
|
|
580
|
+
if (this.isIdle())
|
|
572
581
|
return 'idle';
|
|
573
582
|
if (entries.some((entry) => entry.tier === 'critical'))
|
|
574
583
|
return 'critical';
|
|
@@ -584,10 +593,11 @@ class CanvasInboxWatcher {
|
|
|
584
593
|
const deferredHold = this.isIdle()
|
|
585
594
|
&& routedEntries.length > 0
|
|
586
595
|
&& routedEntries.every((entry) => entry.tier === 'deferred');
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
596
|
+
// A critical entry aborts and requeues the whole batch before anything is
|
|
597
|
+
// sent, so neither channel counts against handoff capacity.
|
|
598
|
+
const critical = this.visibleRoute(visibleEntries) === 'critical';
|
|
599
|
+
const hiddenSend = !deferredHold && !critical && hiddenEntries.length > 0;
|
|
600
|
+
const visibleSend = !deferredHold && !critical && visibleEntries.length > 0;
|
|
591
601
|
return {
|
|
592
602
|
hiddenEntries,
|
|
593
603
|
visibleEntries,
|
|
@@ -598,19 +608,30 @@ class CanvasInboxWatcher {
|
|
|
598
608
|
ordinaryPlan() {
|
|
599
609
|
return this.buildPlan(this.buffer);
|
|
600
610
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
611
|
+
/** The context channel's ordered cards for one batch: the sidecar envelope
|
|
612
|
+
* once (a batch containing any situational-marked entry), then the one-shot
|
|
613
|
+
* cards of entries that have no visible delivery to ride. */
|
|
614
|
+
contextCards(nodeId, entries) {
|
|
615
|
+
const cards = [];
|
|
616
|
+
if (entries.some((entry) => entry.data?.['situational'] === true)) {
|
|
617
|
+
const markerEntryId = entries.at(-1).entry_id;
|
|
618
|
+
let envelope;
|
|
619
|
+
try {
|
|
620
|
+
envelope = situationalContextEnvelope(nodeId);
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
this.raiseSituationalFatal(markerEntryId, error);
|
|
624
|
+
}
|
|
625
|
+
if (envelope === null) {
|
|
626
|
+
this.raiseSituationalFatal(markerEntryId, new Error(`missing situational context for entry ${markerEntryId}`));
|
|
627
|
+
}
|
|
628
|
+
cards.push(envelope);
|
|
609
629
|
}
|
|
610
|
-
|
|
611
|
-
|
|
630
|
+
for (const entry of entries) {
|
|
631
|
+
if (entry.data?.['situationalOnly'] === true)
|
|
632
|
+
cards.push(...entryCards(entry));
|
|
612
633
|
}
|
|
613
|
-
return
|
|
634
|
+
return cards;
|
|
614
635
|
}
|
|
615
636
|
digest(entries, stage) {
|
|
616
637
|
try {
|
|
@@ -662,26 +683,43 @@ class CanvasInboxWatcher {
|
|
|
662
683
|
this.pendingHandoffs.push(handoff);
|
|
663
684
|
return 'handed-off';
|
|
664
685
|
}
|
|
665
|
-
|
|
686
|
+
/** Send the batch's context cards. `route` is the route the visible delivery
|
|
687
|
+
* of this same cycle takes, so a streaming card queues behind nothing and
|
|
688
|
+
* ahead of the body. `triggersTurn` is set only when this cycle has no
|
|
689
|
+
* visible send at all — then the LAST card carries the wake. */
|
|
690
|
+
sendContext(nodeId, entries, cards, route, triggersTurn, stage) {
|
|
666
691
|
return this.sendDelivery('hidden', entries, () => {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
692
|
+
const streamingRoute = route === 'steer' || route === 'followUp'
|
|
693
|
+
? route
|
|
694
|
+
: this.isIdle() ? undefined : 'followUp';
|
|
695
|
+
cards.forEach((card, index) => {
|
|
696
|
+
const wake = triggersTurn && index === cards.length - 1;
|
|
697
|
+
this.pi.sendMessage({
|
|
698
|
+
customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
|
|
699
|
+
content: card,
|
|
700
|
+
display: true,
|
|
701
|
+
details: { nodeId },
|
|
702
|
+
}, streamingRoute !== undefined
|
|
703
|
+
? { deliverAs: streamingRoute }
|
|
704
|
+
: wake ? { deliverAs: 'followUp', triggerTurn: true } : {});
|
|
705
|
+
});
|
|
673
706
|
}, stage);
|
|
674
707
|
}
|
|
675
|
-
|
|
708
|
+
/** The visible delivery and its own entry's one-shot cards in ONE send, so a
|
|
709
|
+
* failed body never leaves its cards delivered and requeued for a resend. */
|
|
710
|
+
sendVisible(entries, cards, content, route, stage) {
|
|
676
711
|
return this.sendDelivery('visible', entries, () => {
|
|
712
|
+
if (route !== 'idle' && route !== 'steer' && route !== 'followUp') {
|
|
713
|
+
throw new Error(`visible send requested for non-send route ${route}`);
|
|
714
|
+
}
|
|
715
|
+
for (const card of cards) {
|
|
716
|
+
this.pi.sendMessage({ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE, content: card, display: true }, route === 'idle' ? {} : { deliverAs: route });
|
|
717
|
+
}
|
|
677
718
|
if (route === 'idle') {
|
|
678
719
|
this.pi.sendUserMessage(content);
|
|
679
720
|
}
|
|
680
|
-
else if (route === 'steer' || route === 'followUp') {
|
|
681
|
-
this.pi.sendUserMessage(content, { deliverAs: route });
|
|
682
|
-
}
|
|
683
721
|
else {
|
|
684
|
-
|
|
722
|
+
this.pi.sendUserMessage(content, { deliverAs: route });
|
|
685
723
|
}
|
|
686
724
|
}, stage);
|
|
687
725
|
}
|
|
@@ -703,46 +741,44 @@ class CanvasInboxWatcher {
|
|
|
703
741
|
}
|
|
704
742
|
executePlan(nodeId, plan, stage) {
|
|
705
743
|
const requeueEntryIds = new Set();
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
744
|
+
// Route over the WHOLE visible set BEFORE anything is sent: a critical entry
|
|
745
|
+
// anywhere aborts the live turn and requeues the batch, cards included.
|
|
746
|
+
const route = this.visibleRoute(plan.visibleEntries);
|
|
747
|
+
if (route === 'critical') {
|
|
748
|
+
this.abortCritical();
|
|
749
|
+
if (!this.canMutate())
|
|
750
|
+
return { inactive: true, requeueEntryIds };
|
|
751
|
+
for (const entry of plan.visibleEntries)
|
|
752
|
+
requeueEntryIds.add(entry.entry_id);
|
|
753
|
+
for (const entry of plan.hiddenEntries)
|
|
754
|
+
requeueEntryIds.add(entry.entry_id);
|
|
755
|
+
return { inactive: false, requeueEntryIds };
|
|
756
|
+
}
|
|
757
|
+
// A mixed batch splits into several deliveries, and exactly one goes out
|
|
758
|
+
// per cycle: pi marks the session streaming only after awaits inside
|
|
759
|
+
// prompt(), so two user sends in one tick start two concurrent agent runs.
|
|
760
|
+
// The rest requeue and go out on later ticks, in order, behind this one —
|
|
761
|
+
// a requeued delivery's one-shot cards requeue with it.
|
|
709
762
|
const deliveries = plan.visibleEntries.length > 0 ? this.digest(plan.visibleEntries, stage) : [];
|
|
710
|
-
|
|
763
|
+
const [delivery, ...deferred] = deliveries;
|
|
764
|
+
for (const rest of deferred) {
|
|
765
|
+
for (const entry of rest.entries)
|
|
766
|
+
requeueEntryIds.add(entry.entry_id);
|
|
767
|
+
}
|
|
711
768
|
if (plan.hiddenEntries.length > 0) {
|
|
712
|
-
const
|
|
769
|
+
const cards = this.contextCards(nodeId, plan.hiddenEntries);
|
|
770
|
+
const result = this.sendContext(nodeId, plan.hiddenEntries, cards, route, delivery === undefined, stage);
|
|
713
771
|
if (result === 'inactive')
|
|
714
772
|
return { inactive: true, requeueEntryIds };
|
|
715
773
|
if (result === 'failed') {
|
|
716
774
|
for (const entry of plan.hiddenEntries)
|
|
717
775
|
requeueEntryIds.add(entry.entry_id);
|
|
718
776
|
}
|
|
719
|
-
else {
|
|
720
|
-
hiddenStartedTurn = true;
|
|
721
|
-
}
|
|
722
777
|
}
|
|
723
778
|
if (!this.canMutate())
|
|
724
779
|
return { inactive: true, requeueEntryIds };
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
const visibleRoute = this.visibleRoute(plan.visibleEntries, hiddenStartedTurn);
|
|
728
|
-
if (visibleRoute === 'critical') {
|
|
729
|
-
this.abortCritical();
|
|
730
|
-
if (!this.canMutate())
|
|
731
|
-
return { inactive: true, requeueEntryIds };
|
|
732
|
-
for (const entry of plan.visibleEntries)
|
|
733
|
-
requeueEntryIds.add(entry.entry_id);
|
|
734
|
-
}
|
|
735
|
-
else if (visibleRoute === 'idle' || visibleRoute === 'steer' || visibleRoute === 'followUp') {
|
|
736
|
-
// A mixed batch splits into several deliveries, and exactly one goes out
|
|
737
|
-
// per cycle: pi marks the session streaming only after awaits inside
|
|
738
|
-
// prompt(), so two user sends in one tick start two concurrent agent runs.
|
|
739
|
-
// The rest requeue and go out on later ticks, in order, behind this one.
|
|
740
|
-
const [delivery, ...deferred] = deliveries;
|
|
741
|
-
for (const rest of deferred) {
|
|
742
|
-
for (const entry of rest.entries)
|
|
743
|
-
requeueEntryIds.add(entry.entry_id);
|
|
744
|
-
}
|
|
745
|
-
const result = this.sendVisible(delivery.entries, delivery.text, visibleRoute, stage);
|
|
780
|
+
if (delivery !== undefined) {
|
|
781
|
+
const result = this.sendVisible(delivery.entries, delivery.cards, delivery.text, route, stage);
|
|
746
782
|
if (result === 'inactive')
|
|
747
783
|
return { inactive: true, requeueEntryIds };
|
|
748
784
|
if (result === 'failed') {
|
|
@@ -71,10 +71,21 @@ export interface CardKindDefinition {
|
|
|
71
71
|
}
|
|
72
72
|
/** The closed crouter vocabulary, plus custom-role aliases for the same cards. */
|
|
73
73
|
export declare const KIND_TABLE: Readonly<Record<string, CardKindDefinition>>;
|
|
74
|
+
/** Contribute presentation for namespaced kinds this process will render.
|
|
75
|
+
* Idempotent and last-write-wins, so a re-imported module cannot fail a boot.
|
|
76
|
+
* A process that never registers is not degraded: an unregistered kind still
|
|
77
|
+
* parses and only falls back to the generic label and summary. */
|
|
78
|
+
export declare function registerCardKinds(kinds: Readonly<Record<string, CardKindDefinition>>): void;
|
|
74
79
|
/** Plain text from a generated message's string or text-block content. */
|
|
75
80
|
export declare function generatedContextText(message: GeneratedContextMessageLike): string;
|
|
76
81
|
/** Wrap a user-role runtime message in its whole-message card envelope. */
|
|
77
82
|
export declare function formatCard(kind: string, facts: Record<string, string | number | boolean | undefined>, body: string): string;
|
|
83
|
+
/** Wrap a card whose body is DATA, not markup — the only escape on the API
|
|
84
|
+
* path, so a caller can neither emit a malformed card nor smuggle markup into
|
|
85
|
+
* one. `formatCard` keeps its raw body for the trusted crouter producers that
|
|
86
|
+
* deliberately nest markup (bearings blocks, the inbox `<from>`/`<entry>`
|
|
87
|
+
* grammar `parseInboxBody` depends on). */
|
|
88
|
+
export declare function formatDataCard(kind: string, facts: Record<string, string | number | boolean | undefined>, body: string): string;
|
|
78
89
|
/** Parse a runtime envelope, with pre-envelope customType compatibility. */
|
|
79
90
|
export declare function parseCard(message: GeneratedContextMessageLike): GeneratedCard | null;
|
|
80
91
|
/** Format a complete inbox card from report bodies resolved by the caller. */
|
|
@@ -114,6 +114,21 @@ const GENERIC_CARD = {
|
|
|
114
114
|
summary: () => 'runtime context',
|
|
115
115
|
expandable: true,
|
|
116
116
|
};
|
|
117
|
+
/** Kinds contributed by a product or plugin, in whatever process renders them. */
|
|
118
|
+
const KIND_REGISTRY = {};
|
|
119
|
+
/** Contribute presentation for namespaced kinds this process will render.
|
|
120
|
+
* Idempotent and last-write-wins, so a re-imported module cannot fail a boot.
|
|
121
|
+
* A process that never registers is not degraded: an unregistered kind still
|
|
122
|
+
* parses and only falls back to the generic label and summary. */
|
|
123
|
+
export function registerCardKinds(kinds) {
|
|
124
|
+
for (const [kind, definition] of Object.entries(kinds)) {
|
|
125
|
+
// Bare kinds are crouter core's closed vocabulary. A product claiming one
|
|
126
|
+
// is a programming error at boot, not a runtime condition to tolerate.
|
|
127
|
+
if (!kind.includes(':'))
|
|
128
|
+
throw new Error(`Runtime card kind must be namespaced (contain ':'): ${kind}`);
|
|
129
|
+
KIND_REGISTRY[kind] = definition;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
117
132
|
const XML_ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_:.-]*$/;
|
|
118
133
|
const ATTRIBUTE_RE = /\s+([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/gy;
|
|
119
134
|
function escapeXmlAttribute(value) {
|
|
@@ -187,6 +202,14 @@ export function formatCard(kind, facts, body) {
|
|
|
187
202
|
throw new Error('Runtime card facts cannot replace kind');
|
|
188
203
|
return `<runtime${formatAttributes({ kind, ...facts })}>${body}</runtime>`;
|
|
189
204
|
}
|
|
205
|
+
/** Wrap a card whose body is DATA, not markup — the only escape on the API
|
|
206
|
+
* path, so a caller can neither emit a malformed card nor smuggle markup into
|
|
207
|
+
* one. `formatCard` keeps its raw body for the trusted crouter producers that
|
|
208
|
+
* deliberately nest markup (bearings blocks, the inbox `<from>`/`<entry>`
|
|
209
|
+
* grammar `parseInboxBody` depends on). */
|
|
210
|
+
export function formatDataCard(kind, facts, body) {
|
|
211
|
+
return formatCard(kind, facts, escapeXmlText(body));
|
|
212
|
+
}
|
|
190
213
|
function parseInboxBody(body) {
|
|
191
214
|
const senders = [];
|
|
192
215
|
const fromRe = /<from\b([^>]*)>([\s\S]*?)<\/from>/g;
|
|
@@ -225,7 +248,7 @@ function parseInboxBody(body) {
|
|
|
225
248
|
return senders;
|
|
226
249
|
}
|
|
227
250
|
function cardFor(kind, facts, body, senders) {
|
|
228
|
-
const definition = KIND_TABLE[kind] ?? GENERIC_CARD;
|
|
251
|
+
const definition = KIND_TABLE[kind] ?? KIND_REGISTRY[kind] ?? GENERIC_CARD;
|
|
229
252
|
return {
|
|
230
253
|
kind,
|
|
231
254
|
facts,
|
|
@@ -244,9 +267,13 @@ function parseEnvelope(text) {
|
|
|
244
267
|
const attributes = parseAttributes(opening[1] ?? '');
|
|
245
268
|
if (attributes?.kind === undefined)
|
|
246
269
|
return null;
|
|
247
|
-
const
|
|
270
|
+
const raw = text.slice(opening[0].length, -'</runtime>'.length);
|
|
248
271
|
const { kind, ...factValues } = attributes;
|
|
249
272
|
const facts = Object.freeze(factValues);
|
|
273
|
+
// A namespaced kind can only have been produced through the API path, whose
|
|
274
|
+
// renderer is `formatDataCard`, so its body is escaped by construction and
|
|
275
|
+
// decodes here. A bare kind's body is raw markup written by a core producer.
|
|
276
|
+
const body = kind.includes(':') ? unescapeXmlAttribute(raw) : raw;
|
|
250
277
|
return cardFor(kind, facts, body, kind === 'inbox' ? parseInboxBody(body) : []);
|
|
251
278
|
}
|
|
252
279
|
/** Parse a runtime envelope, with pre-envelope customType compatibility. */
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.206",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.206",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|