@north-light/crouter 0.3.205 → 0.3.207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/api/dto/messages.d.ts +26 -4
  2. package/dist/clients/attach/viewer.js +356 -356
  3. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -2
  4. package/dist/core/__tests__/context-intro.test.js +7 -8
  5. package/dist/core/__tests__/kickoff.test.js +6 -6
  6. package/dist/core/canvas/paths.d.ts +2 -2
  7. package/dist/core/canvas/paths.js +2 -2
  8. package/dist/core/runtime/bearings.js +5 -4
  9. package/dist/core/runtime/broker/frame-dispatch.js +10 -1
  10. package/dist/core/runtime/broker/inbox.d.ts +7 -1
  11. package/dist/core/runtime/broker/inbox.js +19 -3
  12. package/dist/core/runtime/broker-extension-render.d.ts +3 -0
  13. package/dist/core/runtime/broker-extension-render.js +3 -0
  14. package/dist/core/runtime/broker-protocol.d.ts +5 -0
  15. package/dist/core/runtime/interactive-deliver.d.ts +3 -2
  16. package/dist/core/runtime/interactive-deliver.js +4 -3
  17. package/dist/core/runtime/kickoff.js +4 -3
  18. package/dist/core/runtime/situational-context.d.ts +14 -7
  19. package/dist/core/runtime/situational-context.js +42 -23
  20. package/dist/core/runtime/situational-live.d.ts +2 -2
  21. package/dist/core/runtime/situational-live.js +18 -18
  22. package/dist/core/runtime/spawn.d.ts +4 -4
  23. package/dist/core/runtime/spawn.js +4 -3
  24. package/dist/daemon/api/handlers/messages.js +101 -17
  25. package/dist/daemon/api/handlers/nodes.js +2 -1
  26. package/dist/daemon/messaging/node-message.d.ts +4 -1
  27. package/dist/pi-extensions/canvas-context-intro.js +5 -5
  28. package/dist/pi-extensions/canvas-inbox-watcher.js +101 -63
  29. package/dist/shared/generated-context.d.ts +11 -0
  30. package/dist/shared/generated-context.js +29 -2
  31. package/package.json +1 -1
  32. 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
- // A body is required unless this is a fresh revive, a situational-only update,
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 plain body only (no fresh, reopen, situational_context, or output_schema)');
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
- const situational = req.situational_context !== undefined && req.situational_context.trim() !== ''
126
- ? req.situational_context.trim()
127
- : null;
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 (situational !== null)
142
- appendSituationalContext(id, situational);
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
- await deliverLive(id, req.body);
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 (situational !== null)
186
- appendSituationalContext(id, situational);
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, ...(situational !== null ? { situational: true } : {}) },
280
+ data: { body: messageBody, ...(hasSidecar ? { situational: true } : {}), ...cards },
199
281
  });
200
282
  }
201
- // Situational-only: a hidden wake marker excluded from the visible digest.
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);
@@ -5,7 +5,10 @@ export interface NodeMessageDelivery {
5
5
  revived: boolean;
6
6
  }
7
7
  export interface RuntimeMessageCard {
8
- kind: string;
8
+ /** A bare core kind only. This renders with `formatCard`, which leaves the
9
+ * body raw — a namespaced kind's body is decoded on read (D12) and would be
10
+ * corrupted. Extend the union rather than widening to `string`. */
11
+ kind: 'review-comment' | 'review-queued' | 'review-approval';
9
12
  facts: Record<string, string | number | boolean | undefined>;
10
13
  }
11
14
  /**
@@ -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 { situationalContextBlock } from '../core/runtime/situational-context.js';
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), situationalContextBlock(nodeId), seen);
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, situationalContextBlock(nodeId));
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, situationalContextBlock(nodeId))
173
- : buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(nodeId), seen);
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 situational context and visible deliveries to pi. A
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
- import { coalesceBrokerInbox, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
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 { readSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
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
- hiddenEntries: live.filter((entry) => entry.data?.['situational'] === true && !hiddenCoverage.has(entry.entry_id)),
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, forceStreaming = false) {
577
+ visibleRoute(entries) {
569
578
  if (entries.length === 0)
570
579
  return 'none';
571
- if (!forceStreaming && this.isIdle())
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
- const hiddenSend = !deferredHold && hiddenEntries.length > 0;
588
- const visibleSend = !deferredHold
589
- && visibleEntries.length > 0
590
- && (hiddenSend || this.visibleRoute(visibleEntries) !== 'critical');
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
- situationalContent(nodeId, entries) {
602
- const markerEntryId = entries.at(-1).entry_id;
603
- let text;
604
- try {
605
- text = readSituationalContext(nodeId);
606
- }
607
- catch (error) {
608
- this.raiseSituationalFatal(markerEntryId, error);
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
- if (text === null) {
611
- this.raiseSituationalFatal(markerEntryId, new Error(`missing situational context for entry ${markerEntryId}`));
630
+ for (const entry of entries) {
631
+ if (entry.data?.['situationalOnly'] === true)
632
+ cards.push(...entryCards(entry));
612
633
  }
613
- return `<situational-context>\n${text}\n</situational-context>`;
634
+ return cards;
614
635
  }
615
636
  digest(entries, stage) {
616
637
  try {
@@ -662,26 +683,45 @@ class CanvasInboxWatcher {
662
683
  this.pendingHandoffs.push(handoff);
663
684
  return 'handed-off';
664
685
  }
665
- sendHidden(nodeId, entries, content, stage) {
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
- this.pi.sendMessage({
668
- customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
669
- content: formatCard('situational', {}, content),
670
- display: true,
671
- details: { nodeId },
672
- }, { deliverAs: 'followUp', triggerTurn: true });
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
- sendVisible(entries, content, route, stage) {
708
+ /** The visible delivery and its own entry's one-shot cards share ONE failure
709
+ * unit: a throw anywhere requeues the whole entry, so a retry may repeat a
710
+ * card already handed off (context is restatable; the human's body is not,
711
+ * and it is sent last). */
712
+ sendVisible(entries, cards, content, route, stage) {
676
713
  return this.sendDelivery('visible', entries, () => {
714
+ if (route !== 'idle' && route !== 'steer' && route !== 'followUp') {
715
+ throw new Error(`visible send requested for non-send route ${route}`);
716
+ }
717
+ for (const card of cards) {
718
+ this.pi.sendMessage({ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE, content: card, display: true }, route === 'idle' ? {} : { deliverAs: route });
719
+ }
677
720
  if (route === 'idle') {
678
721
  this.pi.sendUserMessage(content);
679
722
  }
680
- else if (route === 'steer' || route === 'followUp') {
681
- this.pi.sendUserMessage(content, { deliverAs: route });
682
- }
683
723
  else {
684
- throw new Error(`visible send requested for non-send route ${route}`);
724
+ this.pi.sendUserMessage(content, { deliverAs: route });
685
725
  }
686
726
  }, stage);
687
727
  }
@@ -703,46 +743,44 @@ class CanvasInboxWatcher {
703
743
  }
704
744
  executePlan(nodeId, plan, stage) {
705
745
  const requeueEntryIds = new Set();
706
- const situationalContent = plan.hiddenEntries.length > 0
707
- ? this.situationalContent(nodeId, plan.hiddenEntries)
708
- : undefined;
746
+ // Route over the WHOLE visible set BEFORE anything is sent: a critical entry
747
+ // anywhere aborts the live turn and requeues the batch, cards included.
748
+ const route = this.visibleRoute(plan.visibleEntries);
749
+ if (route === 'critical') {
750
+ this.abortCritical();
751
+ if (!this.canMutate())
752
+ return { inactive: true, requeueEntryIds };
753
+ for (const entry of plan.visibleEntries)
754
+ requeueEntryIds.add(entry.entry_id);
755
+ for (const entry of plan.hiddenEntries)
756
+ requeueEntryIds.add(entry.entry_id);
757
+ return { inactive: false, requeueEntryIds };
758
+ }
759
+ // A mixed batch splits into several deliveries, and exactly one goes out
760
+ // per cycle: pi marks the session streaming only after awaits inside
761
+ // prompt(), so two user sends in one tick start two concurrent agent runs.
762
+ // The rest requeue and go out on later ticks, in order, behind this one —
763
+ // a requeued delivery's one-shot cards requeue with it.
709
764
  const deliveries = plan.visibleEntries.length > 0 ? this.digest(plan.visibleEntries, stage) : [];
710
- let hiddenStartedTurn = false;
765
+ const [delivery, ...deferred] = deliveries;
766
+ for (const rest of deferred) {
767
+ for (const entry of rest.entries)
768
+ requeueEntryIds.add(entry.entry_id);
769
+ }
711
770
  if (plan.hiddenEntries.length > 0) {
712
- const result = this.sendHidden(nodeId, plan.hiddenEntries, situationalContent, stage);
771
+ const cards = this.contextCards(nodeId, plan.hiddenEntries);
772
+ const result = this.sendContext(nodeId, plan.hiddenEntries, cards, route, delivery === undefined, stage);
713
773
  if (result === 'inactive')
714
774
  return { inactive: true, requeueEntryIds };
715
775
  if (result === 'failed') {
716
776
  for (const entry of plan.hiddenEntries)
717
777
  requeueEntryIds.add(entry.entry_id);
718
778
  }
719
- else {
720
- hiddenStartedTurn = true;
721
- }
722
779
  }
723
780
  if (!this.canMutate())
724
781
  return { inactive: true, requeueEntryIds };
725
- // Route over the WHOLE visible set, not just the part sent below: a critical
726
- // entry anywhere still aborts and requeues the batch before anything is sent.
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);
782
+ if (delivery !== undefined) {
783
+ const result = this.sendVisible(delivery.entries, delivery.cards, delivery.text, route, stage);
746
784
  if (result === 'inactive')
747
785
  return { inactive: true, requeueEntryIds };
748
786
  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 body = text.slice(opening[0].length, -'</runtime>'.length);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.205",
3
+ "version": "0.3.207",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.205",
3
+ "version": "0.3.207",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.205",
9
+ "version": "0.3.207",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {