@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
@@ -272,7 +272,7 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
272
272
  await waitFor(() => pi.sentMessages.length >= 1);
273
273
  assert.equal(pi.injected.length, 0, 'a situational-only entry never triggers a visible digest');
274
274
  assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
275
- assert.match(pi.sentMessages[0].content, /<situational-context>/);
275
+ assert.match(pi.sentMessages[0].content, /<runtime kind="situational">/);
276
276
  assert.match(pi.sentMessages[0].content, /the user is mid-checkout on the store applet/);
277
277
  // Pins the seam the attach viewer keys its collapsed-rendering dispatch off
278
278
  // (src/clients/attach/render/chat-view.ts): a regression here (e.g. someone routing
@@ -299,7 +299,7 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
299
299
  assert.match(pi.injected[0].content, /the visible message body/);
300
300
  assert.doesNotMatch(pi.injected[0].content, /hidden ambient checkout state/, 'ambient text never rides the visible digest');
301
301
  assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
302
- assert.match(pi.sentMessages[0].content, /<situational-context>/);
302
+ assert.match(pi.sentMessages[0].content, /<runtime kind="situational">/);
303
303
  assert.match(pi.sentMessages[0].content, /the hidden ambient checkout state/);
304
304
  });
305
305
  test('a situational-only entry survives a transient sendMessage failure — retried, not dropped', async () => {
@@ -14,7 +14,7 @@ import { join } from 'node:path';
14
14
  import { closeDb } from '../canvas/db.js';
15
15
  import { apiSocketPath } from '../canvas/paths.js';
16
16
  import { spawnNode } from '../runtime/nodes.js';
17
- import { appendSituationalContext, situationalContextBlock } from '../runtime/situational-context.js';
17
+ import { appendSituationalContext, formatSituationalProse, situationalContextEnvelope, } from '../runtime/situational-context.js';
18
18
  import registerCanvasContextIntro, { renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
19
19
  import { buildContextBearings as buildContextIntro } from '../runtime/bearings.js';
20
20
  import { createApiServer } from '../../daemon/api/server.js';
@@ -48,15 +48,14 @@ after(() => {
48
48
  delete process.env['CRTR_HOME'];
49
49
  delete process.env['CRTR_NODE_ID'];
50
50
  });
51
- test('situational context rides the bearings as a sibling <situational-context> block; empty when unset', () => {
51
+ test('the stored situational card rides the bearings body; absent when unset', () => {
52
52
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
53
- assert.equal(situationalContextBlock(meta.node_id), '', 'no block until something is set');
54
- assert.doesNotMatch(buildContextIntro(meta.node_id), /<situational-context>/, 'bearings carry no block when unset');
55
- appendSituationalContext(meta.node_id, 'user is looking at the billing applet');
53
+ assert.equal(situationalContextEnvelope(meta.node_id), null, 'no card until something is set');
54
+ assert.doesNotMatch(buildContextIntro(meta.node_id), /kind="situational"/, 'bearings carry no card when unset');
55
+ appendSituationalContext(meta.node_id, formatSituationalProse('user is looking at the billing applet'));
56
56
  const block = buildContextIntro(meta.node_id);
57
- assert.match(block, /<situational-context>/, 'block present once situational context is set');
58
- assert.match(block, /user is looking at the billing applet/, 'block carries the ambient text');
59
- assert.ok(!/<situational-context>[\s\S]*<\/crtr-bearings>/.test(block), 'the block is a sibling of <crtr-bearings>, not nested inside it');
57
+ assert.match(block, /<runtime kind="situational">/, 'card present once situational context is set');
58
+ assert.match(block, /user is looking at the billing applet/, 'card carries the ambient text');
60
59
  });
61
60
  function makeFakePi() {
62
61
  return {
@@ -112,18 +112,18 @@ test('a fresh revive kickoff never carries situational context — it rides the
112
112
  // fresh/cycling revive kickoff prompt. It is still delivered on this path, but
113
113
  // hidden — canvas-context-intro's session_start handler re-fires on every
114
114
  // fresh/cycling revive (a new pi process boot rooting a fresh branch) and
115
- // re-injects buildContextIntro(), which already carries the sidecar's
116
- // <situational-context> block as a sibling of <crtr-bearings> (see
117
- // context-intro.test.ts for that seam's own coverage). buildReviveKickoff must
118
- // never duplicate it into the visible prompt.
115
+ // re-injects buildContextIntro(), which already carries the sidecar's stored
116
+ // situational card alongside <crtr-bearings> (see context-intro.test.ts for
117
+ // that seam's own coverage). buildReviveKickoff must never duplicate it into
118
+ // the visible prompt.
119
119
  const id = 'n3';
120
120
  const meta = createNode(node(id));
121
121
  appendSituationalContext(id, 'user is mid-checkout on the store applet');
122
122
  const kickoff = buildReviveKickoff(meta, drainBearings(meta));
123
- assert.doesNotMatch(kickoff, /<situational-context>/, 'visible kickoff never carries the ambient block');
123
+ assert.doesNotMatch(kickoff, /kind="situational"/, 'visible kickoff never carries the ambient card');
124
124
  assert.doesNotMatch(kickoff, /mid-checkout on the store applet/, 'visible kickoff never carries the ambient text');
125
125
  const hidden = buildContextIntro(id);
126
- assert.match(hidden, /<situational-context>/, 'delivered instead via the hidden bearings/context-intro seam');
126
+ assert.match(hidden, /<runtime kind="situational">/, 'delivered instead via the hidden bearings/context-intro seam');
127
127
  assert.match(hidden, /mid-checkout on the store applet/, 'hidden delivery carries the ambient text');
128
128
  });
129
129
  test('buildReviveKickoff is pure — building twice eats nothing', () => {
@@ -59,8 +59,8 @@ export declare function reportsDir(nodeId: string): string;
59
59
  export declare function messagesDir(nodeId: string): string;
60
60
  export declare function nodeMetaPath(nodeId: string): string;
61
61
  export declare function inboxPath(nodeId: string): string;
62
- /** The node-owned ambient "current situation" sidecar — the latest hidden
63
- * `<situational-context>` text injected into bearings/kickoff (see
62
+ /** The node-owned ambient "current situation" sidecar — ONE rendered runtime
63
+ * card, restated by the session-start bearings (see
64
64
  * `runtime/situational-context.ts`), upserted in place rather than appended so
65
65
  * only the newest applet/situation state is ever carried. Lives beside
66
66
  * meta.json/inbox.jsonl (node-runtime state) rather than under context/ (the
@@ -137,8 +137,8 @@ export function nodeMetaPath(nodeId) {
137
137
  export function inboxPath(nodeId) {
138
138
  return join(nodeDir(nodeId), 'inbox.jsonl');
139
139
  }
140
- /** The node-owned ambient "current situation" sidecar — the latest hidden
141
- * `<situational-context>` text injected into bearings/kickoff (see
140
+ /** The node-owned ambient "current situation" sidecar — ONE rendered runtime
141
+ * card, restated by the session-start bearings (see
142
142
  * `runtime/situational-context.ts`), upserted in place rather than appended so
143
143
  * only the newest applet/situation state is ever carried. Lives beside
144
144
  * meta.json/inbox.jsonl (node-runtime state) rather than under context/ (the
@@ -18,7 +18,7 @@ import { hostname } from 'node:os';
18
18
  import { cadenceDisplay } from '../wake.js';
19
19
  import { renderKnowledgeBlock, renderWorkspaceOpenDocs } from '../substrate/index.js';
20
20
  import { loadProfileManifest } from '../profiles/manifest.js';
21
- import { situationalContextBlock } from './situational-context.js';
21
+ import { situationalContextEnvelope } from './situational-context.js';
22
22
  import { statusRank } from '../canvas/node-order.js';
23
23
  /** The bearings custom type is shared with every attached renderer. */
24
24
  export { CONTEXT_INTRO_CUSTOM_TYPE } from '../../shared/generated-context.js';
@@ -369,9 +369,10 @@ export function buildContextBearings(nodeId, seen) {
369
369
  // A workspace mount is the initial read of its root (`applies-to: "."`).
370
370
  const workspaceContext = renderWorkspaceOpenDocs(nodeId, seen);
371
371
  // Hidden applet/situation-origin ambient context (see situational-context.ts)
372
- // — a SIBLING block, never nested under <crtr-bearings>, and never routed
373
- // through the visible chat/digest path. '' when nothing was ever set.
374
- const situational = situationalContextBlock(nodeId);
372
+ // — the stored `situational` card, placed in the bearings BODY, so it is a
373
+ // nested card inside the outer envelope. Never routed through the visible
374
+ // chat/digest path. '' when nothing was ever set.
375
+ const situational = situationalContextEnvelope(nodeId) ?? '';
375
376
  const projectCtx = node?.cwd !== undefined ? buildProjectContextBlock(node.cwd) : '';
376
377
  return [bearings.join('\n'), knowledge, workspaceContext, situational, projectCtx]
377
378
  .filter((s) => s !== '')
@@ -3,7 +3,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { AUTH_FAULT_RECOVERY_BODY, formatCard } from '../../../shared/generated-context.js';
6
+ import { AUTH_FAULT_RECOVERY_BODY, formatCard, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from '../../../shared/generated-context.js';
7
7
  import { emitEvent } from '../../events/emit.js';
8
8
  import { operationIdContext } from '../../events/operation-id.js';
9
9
  import { buildRefInventory } from '../../memory/inline-ref-inventory.js';
@@ -794,6 +794,15 @@ export function createFrameDispatchContext(deps) {
794
794
  if (notWritable(client, 'drive the engine', frame.id))
795
795
  return;
796
796
  const via = currentSession().isStreaming ? 'steer' : 'prompt';
797
+ // Ordered context cards ahead of the body, inside this ONE frame handling,
798
+ // so no other client's send can interleave between a card and its body.
799
+ // Nothing is awaited: `sendCustomMessage` completes synchronously on both
800
+ // branches that apply here — idle appends to the session and starts NO turn
801
+ // (the body is the sole trigger), streaming queues on the same steer queue
802
+ // the body's `prompt({streamingBehavior:'steer'})` uses, preserving order.
803
+ for (const card of frame.cards ?? []) {
804
+ void currentSession().sendCustomMessage({ customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE, content: card, display: true }, via === 'steer' ? { deliverAs: 'steer' } : undefined).catch((error) => emitEvent({ level: 'error', event: 'broker.deliver.card_failed', error }));
805
+ }
797
806
  let delivered = false;
798
807
  const ackAccepted = () => {
799
808
  if (delivered)
@@ -25,12 +25,18 @@ export declare function readBrokerCanceledEntryIds(nodeId: string): Set<BrokerIn
25
25
  * absent, absolute, or not a report path. Callers use it to tell whether the
26
26
  * report-node projection they hold can still resolve the ref. */
27
27
  export declare function reportRefNodeId(ref: string | undefined): string | undefined;
28
+ /** One-shot runtime cards an entry carries (rendered envelopes written by the
29
+ * message API). They ride the delivery their own entry belongs to, so a note
30
+ * can never land ahead of an unrelated person's message. */
31
+ export declare function entryCards(entry: BrokerInboxEntry): readonly string[];
28
32
  /** One deliverable user message: either a person's verbatim words or the whole
29
33
  * `<runtime kind="inbox">` envelope. Never both — a reader parses an envelope
30
- * only as a whole message, so mixing them attributes the card to the person. */
34
+ * only as a whole message, so mixing them attributes the card to the person.
35
+ * `cards` are the one-shot context envelopes that go out ahead of `text`. */
31
36
  export interface InboxDelivery {
32
37
  kind: 'human' | 'card';
33
38
  text: string;
39
+ cards: readonly string[];
34
40
  entries: readonly BrokerInboxEntry[];
35
41
  }
36
42
  /** Split unread entries into ordered deliveries: each human entry verbatim, and
@@ -157,6 +157,15 @@ function cardEntry(entry, reportNodes) {
157
157
  ...(entry.disposition === undefined ? {} : { disposition: entry.disposition }),
158
158
  };
159
159
  }
160
+ /** One-shot runtime cards an entry carries (rendered envelopes written by the
161
+ * message API). They ride the delivery their own entry belongs to, so a note
162
+ * can never land ahead of an unrelated person's message. */
163
+ export function entryCards(entry) {
164
+ const cards = entry.data?.['cards'];
165
+ if (!Array.isArray(cards))
166
+ return [];
167
+ return cards.filter((card) => typeof card === 'string');
168
+ }
160
169
  /** Split unread entries into ordered deliveries: each human entry verbatim, and
161
170
  * every node/system entry in one card landing where the first such sender fell. */
162
171
  export function coalesceBrokerInbox(entries, reportNodes) {
@@ -176,13 +185,18 @@ export function coalesceBrokerInbox(entries, reportNodes) {
176
185
  if (sender === 'human') {
177
186
  for (const entry of items) {
178
187
  const body = inlineBody(entry);
179
- deliveries.push({ kind: 'human', text: body === '' ? entry.label : body, entries: [entry] });
188
+ deliveries.push({
189
+ kind: 'human',
190
+ text: body === '' ? entry.label : body,
191
+ cards: entryCards(entry),
192
+ entries: [entry],
193
+ });
180
194
  }
181
195
  continue;
182
196
  }
183
197
  if (cardSlot < 0) {
184
198
  cardSlot = deliveries.length;
185
- deliveries.push({ kind: 'card', text: '', entries: [] });
199
+ deliveries.push({ kind: 'card', text: '', cards: [], entries: [] });
186
200
  }
187
201
  // The fallback to a CURRENT projected name is correct only for legacy
188
202
  // entries, which predate the producer's own `from_name` snapshot.
@@ -193,10 +207,12 @@ export function coalesceBrokerInbox(entries, reportNodes) {
193
207
  if (cardSlot >= 0) {
194
208
  // Card sections group by sender, so its entry list comes from the input
195
209
  // array instead: a delivery's entry ids must be in physical inbox order.
210
+ const cardEntries = entries.filter((entry) => (entry.from ?? 'system') !== 'human');
196
211
  deliveries[cardSlot] = {
197
212
  kind: 'card',
198
213
  text: formatInboxCard(sections),
199
- entries: entries.filter((entry) => (entry.from ?? 'system') !== 'human'),
214
+ cards: cardEntries.flatMap((entry) => entryCards(entry)),
215
+ entries: cardEntries,
200
216
  };
201
217
  }
202
218
  return deliveries;
@@ -3,6 +3,9 @@ export declare function editorLabelForBrokerNode(node: BrokerExtensionNodeDTO):
3
3
  /** Same environment-only project context rendered at normal boot, without a
4
4
  * canvas import. */
5
5
  export declare function buildProjectContextBlockForBroker(cwd: string): string;
6
+ /** `situational` is the node's stored situational card, placed in the bearings
7
+ * BODY — the one place in the system where a runtime card nests inside
8
+ * another (see situational-context.ts). '' when none is set. */
6
9
  export declare function buildContextBearingsFromState(state: BrokerExtensionStateDTO, projectContext: string, situational?: string, seen?: Set<string>): string;
7
10
  export declare function buildForkBearingsFromState(state: BrokerExtensionStateDTO, situational?: string): string;
8
11
  export declare function renderPreferencesFromState(state: BrokerExtensionStateDTO): string;
@@ -139,6 +139,9 @@ function reviewNote(node) {
139
139
  }
140
140
  return `You exist for one human review of \`${review.target_file}\`. The inherited conversation belongs to the origin node, not you — carry only the context needed for this review. The person's visible transcript begins at the boundary marker above; everything earlier is context they cannot see.`;
141
141
  }
142
+ /** `situational` is the node's stored situational card, placed in the bearings
143
+ * BODY — the one place in the system where a runtime card nests inside
144
+ * another (see situational-context.ts). '' when none is set. */
142
145
  export function buildContextBearingsFromState(state, projectContext, situational = '', seen) {
143
146
  const { node } = state;
144
147
  const bearings = ['<crtr-bearings>', identity(state)];
@@ -134,6 +134,11 @@ export interface DeliverFrame {
134
134
  id: string;
135
135
  text: string;
136
136
  images?: ImageContent[];
137
+ /** Rendered runtime-card envelopes delivered ahead of `text`, in order, in
138
+ * this ONE frame handling — so no other client's send can interleave
139
+ * between a card and the body it belongs to. Each is its own custom
140
+ * message and none triggers a turn; the body is the sole trigger. */
141
+ cards?: string[];
137
142
  }
138
143
  /** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
139
144
  * which runs the command, records a `bashExecution` message in context, and
@@ -6,8 +6,9 @@ export type LiveDeliverRoute = 'prompt' | 'steer';
6
6
  export type LiveInterruptOutcome = 'turn' | 'bash' | 'idle';
7
7
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
8
8
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
9
- * routed; resolves with the route taken. */
10
- export declare function deliverLive(nodeId: string, text: string): Promise<LiveDeliverRoute>;
9
+ * routed; resolves with the route taken. `cards` are rendered runtime-card
10
+ * envelopes placed ahead of the body inside that same frame handling. */
11
+ export declare function deliverLive(nodeId: string, text: string, cards?: readonly string[]): Promise<LiveDeliverRoute>;
11
12
  /** Abort whatever a LIVE node's engine is doing (turn or `!` bash). Resolves
12
13
  * once the broker has PROCESSED the abort — which, on the single frame loop,
13
14
  * also implies every earlier-accepted deliver was already routed (so a
@@ -14,14 +14,15 @@
14
14
  import { oneShotControllerRequest } from './broker-request.js';
15
15
  /** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
16
16
  * broker routes it itself (idle → prompt, streaming → steer) and acks once
17
- * routed; resolves with the route taken. */
18
- export function deliverLive(nodeId, text) {
17
+ * routed; resolves with the route taken. `cards` are rendered runtime-card
18
+ * envelopes placed ahead of the body inside that same frame handling. */
19
+ export function deliverLive(nodeId, text, cards = []) {
19
20
  return oneShotControllerRequest({
20
21
  nodeId,
21
22
  what: 'the interactive delivery',
22
23
  ackFor: 'deliver',
23
24
  correlated: true,
24
- frame: (id) => ({ type: 'deliver', id, text }),
25
+ frame: (id) => ({ type: 'deliver', id, text, ...(cards.length > 0 ? { cards: [...cards] } : {}) }),
25
26
  result: (detail) => (detail === 'steer' ? 'steer' : 'prompt'),
26
27
  });
27
28
  }
@@ -283,9 +283,10 @@ export function buildReviveKickoff(meta, bearings, wakeReason) {
283
283
  // Hidden applet/situation-origin ambient context (see situational-context.ts)
284
284
  // is deliberately NOT injected here — this kickoff is the VISIBLE revive prompt.
285
285
  // canvas-context-intro's session_start handler re-fires on every fresh/cycling
286
- // revive (a new pi process boot) and re-injects buildContextIntro(), which
287
- // already carries the sidecar as a hidden sibling of <crtr-bearings>; that seam
288
- // covers this path, so duplicating it into the visible prompt would leak it.
286
+ // revive (a new pi process boot) and re-injects buildContextIntro(), whose
287
+ // bearings body already nests the stored situational card; that seam covers
288
+ // this path, so duplicating it here would both repeat the card and put hidden
289
+ // ambient context into a visible prompt.
289
290
  // Live background bash jobs sit beside the context-dir listing: both describe
290
291
  // this node's OWN in-flight state, and both precede <feed>, which is about the
291
292
  // other nodes it awaits. Absent when nothing is running.
@@ -1,12 +1,19 @@
1
1
  /** The `customType` stamped on the hidden custom message the inbox watcher
2
2
  * sends for an existing node's situational-context update. */
3
3
  export { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../shared/generated-context.js';
4
+ /** Render prose supplied through a `situational_context` string entry point
5
+ * (`crtr node message --situational-context`, the node-create seed) as the
6
+ * card the sidecar stores. Card-supplying callers pass their own rendered
7
+ * envelope to `appendSituationalContext` instead. Blank prose renders as ''
8
+ * so the sidecar write stays a no-op rather than storing an empty card. */
9
+ export declare function formatSituationalProse(prose: string): string;
4
10
  /** Upsert the node's current situational context (replace, not append) — the
5
- * one sanctioned writer for the sidecar. No-op for blank text, so a caller
6
- * can pass through an optional/empty value with no guard at the call site. */
11
+ * one sanctioned writer for the sidecar, taking an ALREADY-RENDERED envelope.
12
+ * No-op for blank text, so a caller can pass through an optional/empty value
13
+ * with no guard at the call site. */
7
14
  export declare function appendSituationalContext(nodeId: string, text: string): void;
8
- /** Read the current situational context, or null if none was ever set. */
9
- export declare function readSituationalContext(nodeId: string): string | null;
10
- /** The `<situational-context>` block for `nodeId`, or '' when none is set —
11
- * callers append/filter this like any other optional sibling block. */
12
- export declare function situationalContextBlock(nodeId: string): string;
15
+ /** The node's stored situational card, verbatim, or null when none is set.
16
+ * The ONE read of the sidecar. A sidecar written before the card cut holds
17
+ * bare prose; that single legacy branch lives here alone and self-clears on
18
+ * the next situation change. */
19
+ export declare function situationalContextEnvelope(nodeId: string): string | null;
@@ -1,30 +1,44 @@
1
1
  // situational-context.ts — the hidden ambient "current situation" sidecar.
2
2
  //
3
3
  // A node-owned runtime file (nodes/<id>/situational-context.md, NOT under
4
- // context/ — see `situationalContextPath`) carrying the LATEST applet/
5
- // situation-origin ambient text: current state (e.g. which applet/page a
6
- // concierge is attached to right now), not task history, and never the node's
7
- // goal or a visible chat message. Upserted (replace), never appended: only the
8
- // newest block is ever carried, so stale applet pages never accumulate across
9
- // concierge reuse.
4
+ // context/ — see `situationalContextPath`) storing ONE rendered runtime-card
5
+ // envelope describing the LATEST situation: current state (e.g. which applet
6
+ // page a concierge is attached to right now), not task history, and never the
7
+ // node's goal or a visible chat message. Upserted (replace), never appended, so
8
+ // stale applet pages never accumulate across concierge reuse.
10
9
  //
11
- // Read at three injection seams, always rendered as a `<situational-context>`
12
- // block SIBLING to whatever bearings/kickoff frame is being built (never
13
- // nested inside it, and never routed through coalesce()'s visible digest
14
- // renderer):
15
- // - buildContextBearings() — fresh node kickoff (canvas-context-intro).
16
- // - buildReviveKickoff() — fresh/cycling revive.
17
- // - canvas-inbox-watcher's flush() — a hidden pi.sendMessage custom message
18
- // for an existing live/dormant node, delivered ahead of (and never as
19
- // part of) the visible inbox digest.
10
+ // `situationalContextEnvelope()` is the ONE read, and it yields the stored card
11
+ // verbatim — no caller downstream has a shape to check. Its three callers use
12
+ // it differently, and only the last nests:
13
+ // - canvas-inbox-watcher's context channel — delivered verbatim as a custom
14
+ // message's content, with SITUATIONAL_CONTEXT_CUSTOM_TYPE as delivery
15
+ // metadata only.
16
+ // - setSituationalContextLive() (warm-spare claim) — the same verbatim
17
+ // delivery, `deliverAs: 'nextTurn'`.
18
+ // - buildContextBearingsFromState() / buildForkBearingsFromState() — the card
19
+ // is placed in the bearings body, where it is deliberately a nested card
20
+ // inside the outer `bearings` card. The only nesting site in the system.
21
+ //
22
+ // The visible revive kickoff carries NO situational context: a fresh or cycling
23
+ // revive receives it through the session-start bearings instead.
20
24
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
21
25
  import { situationalContextPath, nodeDir } from '../canvas/paths.js';
26
+ import { formatCard, parseCard } from '../../shared/generated-context.js';
22
27
  /** The `customType` stamped on the hidden custom message the inbox watcher
23
28
  * sends for an existing node's situational-context update. */
24
29
  export { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../shared/generated-context.js';
30
+ /** Render prose supplied through a `situational_context` string entry point
31
+ * (`crtr node message --situational-context`, the node-create seed) as the
32
+ * card the sidecar stores. Card-supplying callers pass their own rendered
33
+ * envelope to `appendSituationalContext` instead. Blank prose renders as ''
34
+ * so the sidecar write stays a no-op rather than storing an empty card. */
35
+ export function formatSituationalProse(prose) {
36
+ return prose.trim() === '' ? '' : formatCard('situational', {}, prose);
37
+ }
25
38
  /** Upsert the node's current situational context (replace, not append) — the
26
- * one sanctioned writer for the sidecar. No-op for blank text, so a caller
27
- * can pass through an optional/empty value with no guard at the call site. */
39
+ * one sanctioned writer for the sidecar, taking an ALREADY-RENDERED envelope.
40
+ * No-op for blank text, so a caller can pass through an optional/empty value
41
+ * with no guard at the call site. */
28
42
  export function appendSituationalContext(nodeId, text) {
29
43
  const body = text.trim();
30
44
  if (body === '')
@@ -32,17 +46,22 @@ export function appendSituationalContext(nodeId, text) {
32
46
  mkdirSync(nodeDir(nodeId), { recursive: true });
33
47
  writeFileSync(situationalContextPath(nodeId), body + '\n', 'utf8');
34
48
  }
35
- /** Read the current situational context, or null if none was ever set. */
36
- export function readSituationalContext(nodeId) {
49
+ /** Read the stored text, or null if none was ever set. Module-private:
50
+ * `situationalContextEnvelope` is the one read, and it shape-checks. */
51
+ function readSituationalContext(nodeId) {
37
52
  const p = situationalContextPath(nodeId);
38
53
  if (!existsSync(p))
39
54
  return null;
40
55
  const body = readFileSync(p, 'utf8').trim();
41
56
  return body !== '' ? body : null;
42
57
  }
43
- /** The `<situational-context>` block for `nodeId`, or '' when none is set —
44
- * callers append/filter this like any other optional sibling block. */
45
- export function situationalContextBlock(nodeId) {
58
+ /** The node's stored situational card, verbatim, or null when none is set.
59
+ * The ONE read of the sidecar. A sidecar written before the card cut holds
60
+ * bare prose; that single legacy branch lives here alone and self-clears on
61
+ * the next situation change. */
62
+ export function situationalContextEnvelope(nodeId) {
46
63
  const text = readSituationalContext(nodeId);
47
- return text === null ? '' : `<situational-context>\n${text}\n</situational-context>`;
64
+ if (text === null)
65
+ return null;
66
+ return parseCard({ content: text }) === null ? formatSituationalProse(text) : text;
48
67
  }
@@ -1,5 +1,5 @@
1
- /** Write `text` to the node's sidecar and, when its broker is live, deliver the
2
- * rendered block into the running session for the NEXT turn.
1
+ /** Write `text` (prose) to the node's sidecar and, when its broker is live,
2
+ * deliver the stored card into the running session for the NEXT turn.
3
3
  *
4
4
  * Rejects when a live broker cannot be reached or refuses the frame — the
5
5
  * caller decides what that means. For a warm claim it is fatal (a node holding
@@ -1,25 +1,25 @@
1
1
  // situational-live.ts — hand a node its situational context WITHOUT waking it.
2
2
  //
3
- // The sidecar (`situational-context.ts`) is the durable half: every kickoff and
4
- // revive path renders it as a `<situational-context>` block. This module is the
5
- // LIVE half for a node whose session has already passed `session_start` — the
6
- // warm-pool claim, where a spare booted with no situational context is handed
7
- // to a claimant that has one.
3
+ // The sidecar (`situational-context.ts`) is the durable half: it stores one
4
+ // rendered `situational` card, which every revive path nests into the bearings.
5
+ // This module is the LIVE half for a node whose session has already passed
6
+ // `session_start` — the warm-pool claim, where a spare booted with no
7
+ // situational context is handed to a claimant that has one. The stored card is
8
+ // delivered UNWRAPPED; the customType is delivery metadata only.
8
9
  //
9
10
  // It is deliberately NOT the inbox watcher's situational send. That seam exists
10
- // to WAKE a live node when its applet situation changes, so it delivers with
11
- // `triggerTurn: true`. A freshly-claimed spare must not run a turn before its
12
- // owner says anything, so this path uses `nextTurn` delivery: the block rides
13
- // the claimant's first real turn instead of starting one.
11
+ // to WAKE a live node when its applet situation changes. A freshly-claimed
12
+ // spare must not run a turn before its owner says anything, so this path uses
13
+ // `nextTurn` delivery: the card rides the claimant's first real turn instead of
14
+ // starting one.
14
15
  //
15
16
  // Everything below the sidecar write is the shared live-delivery path
16
17
  // (`deliver-live.ts`), which the claim's bearings delivery also rides.
17
18
  import { getNode } from '../canvas/index.js';
18
19
  import { deliverCustomMessageLive } from './deliver-live.js';
19
- import { formatCard } from '../../shared/generated-context.js';
20
- import { appendSituationalContext, situationalContextBlock, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from './situational-context.js';
21
- /** Write `text` to the node's sidecar and, when its broker is live, deliver the
22
- * rendered block into the running session for the NEXT turn.
20
+ import { appendSituationalContext, formatSituationalProse, situationalContextEnvelope, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from './situational-context.js';
21
+ /** Write `text` (prose) to the node's sidecar and, when its broker is live,
22
+ * deliver the stored card into the running session for the NEXT turn.
23
23
  *
24
24
  * Rejects when a live broker cannot be reached or refuses the frame — the
25
25
  * caller decides what that means. For a warm claim it is fatal (a node holding
@@ -27,15 +27,15 @@ import { appendSituationalContext, situationalContextBlock, SITUATIONAL_CONTEXT_
27
27
  * makes. A DORMANT node needs no delivery at all: its next revive renders the
28
28
  * sidecar into the kickoff. */
29
29
  export async function setSituationalContextLive(nodeId, text) {
30
- appendSituationalContext(nodeId, text);
30
+ appendSituationalContext(nodeId, formatSituationalProse(text));
31
31
  if (getNode(nodeId) === null)
32
32
  throw new Error(`setSituationalContextLive: unknown node ${nodeId}`);
33
- const block = situationalContextBlock(nodeId);
34
- if (block === '')
35
- return; // blank text — appendSituationalContext no-ops, nothing to deliver
33
+ const envelope = situationalContextEnvelope(nodeId);
34
+ if (envelope === null)
35
+ return; // no sidecar at all — blank text on a node that never had one
36
36
  await deliverCustomMessageLive(nodeId, {
37
37
  customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
38
- content: formatCard('situational', {}, block),
38
+ content: envelope,
39
39
  deliverAs: 'nextTurn',
40
40
  });
41
41
  }
@@ -67,10 +67,10 @@ export interface SpawnChildOpts {
67
67
  /** Preallocated node id, used when a managed worktree must be named before birth. */
68
68
  nodeId?: string;
69
69
  /** Hidden ambient "current situation" text — NOT the visible prompt/body.
70
- * Persisted via `appendSituationalContext` BEFORE launch so canvas-context-
71
- * intro's session_start bearings carry it as a `<situational-context>`
72
- * sibling from the node's very first turn. Passed through unchanged into a
73
- * scheduled node-birth command. Omit for no ambient context. */
70
+ * Stored as a `situational` card BEFORE launch so canvas-context-intro's
71
+ * session_start bearings nest it from the node's very first turn. Passed
72
+ * through unchanged into a scheduled node-birth command. Omit for no
73
+ * ambient context. */
74
74
  situationalContext?: string;
75
75
  }
76
76
  /** Resolve a `--fork-from` value to an ABSOLUTE `.jsonl` source path for the
@@ -18,7 +18,7 @@ import { buildLaunchSpec, buildPiArgv } from './launch.js';
18
18
  import { createManagedWorktree, rollbackManagedWorktree } from '../worktree.js';
19
19
  import { usage, brokerLaunchFailed } from '../errors.js';
20
20
  import { writeGoal } from './kickoff.js';
21
- import { appendSituationalContext } from './situational-context.js';
21
+ import { appendSituationalContext, formatSituationalProse } from './situational-context.js';
22
22
  import { hasRoadmap, seedRoadmap } from './roadmap.js';
23
23
  import { buildWakeBearings } from './bearings.js';
24
24
  import { contextDir, getNode, fullName, recordPid } from '../canvas/index.js';
@@ -285,8 +285,9 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
285
285
  writeGoal(meta.node_id, opts.prompt);
286
286
  // Hidden ambient context, written BEFORE the engine launches so canvas-
287
287
  // context-intro's session_start bearings carry it from the first turn.
288
- if (opts.situationalContext !== undefined)
289
- appendSituationalContext(meta.node_id, opts.situationalContext);
288
+ if (opts.situationalContext !== undefined) {
289
+ appendSituationalContext(meta.node_id, formatSituationalProse(opts.situationalContext));
290
+ }
290
291
  // Compound births such as `node fork` may need durable state in place after
291
292
  // the row and context directory exist but before the broker can observe it.
292
293
  // A preparation failure leaves the row crashed and never launches a partial