@north-light/crouter 0.3.204 → 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.
Files changed (106) hide show
  1. package/dist/api/dto/broker.d.ts +2 -0
  2. package/dist/api/dto/messages.d.ts +22 -1
  3. package/dist/api/dto/nodes.d.ts +8 -5
  4. package/dist/api/index.d.ts +1 -0
  5. package/dist/api/index.js +1 -0
  6. package/dist/clients/attach/__tests__/context-message.test.js +54 -19
  7. package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/page-block.test.js +54 -0
  9. package/dist/clients/attach/render/chat-view.d.ts +3 -0
  10. package/dist/clients/attach/render/chat-view.js +56 -27
  11. package/dist/clients/attach/render/context-message.d.ts +6 -0
  12. package/dist/clients/attach/render/context-message.js +21 -3
  13. package/dist/clients/attach/render/group-activity.d.ts +5 -3
  14. package/dist/clients/attach/render/group-activity.js +10 -6
  15. package/dist/clients/attach/render/page-block.js +11 -11
  16. package/dist/clients/attach/viewer.js +717 -703
  17. package/dist/clients/conversation/projection.js +7 -37
  18. package/dist/commands/node/create.js +1 -1
  19. package/dist/commands/profile/project.js +1 -1
  20. package/dist/commands/profile/show.js +1 -1
  21. package/dist/commands/profile.js +1 -1
  22. package/dist/core/__tests__/canvas-inbox-watcher.test.js +90 -23
  23. package/dist/core/__tests__/context-intro.test.js +7 -8
  24. package/dist/core/__tests__/human-deliver.test.js +32 -4
  25. package/dist/core/__tests__/kickoff.test.js +6 -6
  26. package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
  27. package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
  28. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
  29. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
  30. package/dist/core/__tests__/serial/revive.test.js +8 -3
  31. package/dist/core/__tests__/session-cycles.test.js +10 -6
  32. package/dist/core/canvas/render-source.js +3 -0
  33. package/dist/core/feed/feed.d.ts +7 -1
  34. package/dist/core/feed/feed.js +8 -7
  35. package/dist/core/feed/inbox.d.ts +16 -12
  36. package/dist/core/feed/inbox.js +56 -56
  37. package/dist/core/human/page-eval.d.ts +9 -4
  38. package/dist/core/human/page-eval.js +3 -1
  39. package/dist/core/human/page-markdown.d.ts +3 -0
  40. package/dist/core/human/page-markdown.js +295 -0
  41. package/dist/core/human/page.js +4 -0
  42. package/dist/core/runtime/bearings.js +5 -4
  43. package/dist/core/runtime/broker/client-registry.d.ts +1 -0
  44. package/dist/core/runtime/broker/client-registry.js +1 -0
  45. package/dist/core/runtime/broker/fault-retry.js +8 -3
  46. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  47. package/dist/core/runtime/broker/frame-dispatch.js +13 -2
  48. package/dist/core/runtime/broker/inbox.d.ts +20 -2
  49. package/dist/core/runtime/broker/inbox.js +68 -25
  50. package/dist/core/runtime/broker/passive.d.ts +1 -0
  51. package/dist/core/runtime/broker/tool-groups.js +3 -2
  52. package/dist/core/runtime/broker-extension-render.d.ts +3 -0
  53. package/dist/core/runtime/broker-extension-render.js +3 -0
  54. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  55. package/dist/core/runtime/broker-protocol.js +11 -0
  56. package/dist/core/runtime/broker.d.ts +2 -2
  57. package/dist/core/runtime/broker.js +31 -6
  58. package/dist/core/runtime/deliver-live.js +2 -1
  59. package/dist/core/runtime/interactive-deliver.d.ts +3 -2
  60. package/dist/core/runtime/interactive-deliver.js +4 -3
  61. package/dist/core/runtime/kickoff.d.ts +3 -4
  62. package/dist/core/runtime/kickoff.js +27 -16
  63. package/dist/core/runtime/node-read.d.ts +2 -0
  64. package/dist/core/runtime/node-read.js +11 -3
  65. package/dist/core/runtime/session-cycles.d.ts +6 -1
  66. package/dist/core/runtime/session-cycles.js +17 -11
  67. package/dist/core/runtime/session-visibility.d.ts +7 -12
  68. package/dist/core/runtime/session-visibility.js +7 -13
  69. package/dist/core/runtime/situational-context.d.ts +14 -5
  70. package/dist/core/runtime/situational-context.js +39 -21
  71. package/dist/core/runtime/situational-live.d.ts +2 -2
  72. package/dist/core/runtime/situational-live.js +17 -16
  73. package/dist/core/runtime/spawn.js +4 -3
  74. package/dist/core/runtime/stop-guard.js +8 -4
  75. package/dist/core/runtime/warm-pool.d.ts +1 -1
  76. package/dist/core/runtime/warm-pool.js +6 -9
  77. package/dist/daemon/api/handlers/messages.js +101 -17
  78. package/dist/daemon/api/handlers/nodes.js +12 -8
  79. package/dist/daemon/human/finish.js +44 -4
  80. package/dist/daemon/messaging/node-message.d.ts +5 -0
  81. package/dist/daemon/messaging/node-message.js +6 -1
  82. package/dist/daemon/review/comment-notify.js +8 -0
  83. package/dist/daemon/review/deliver.js +2 -2
  84. package/dist/daemon/review/finish.js +1 -0
  85. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
  86. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
  87. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
  88. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
  89. package/dist/pi-extensions/broker-local.d.ts +0 -2
  90. package/dist/pi-extensions/broker-local.js +0 -2
  91. package/dist/pi-extensions/canvas-context-intro.js +9 -8
  92. package/dist/pi-extensions/canvas-goal-capture.js +10 -6
  93. package/dist/pi-extensions/canvas-inbox-watcher.js +116 -69
  94. package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
  95. package/dist/pi-extensions/canvas-passive-context.js +23 -3
  96. package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
  97. package/dist/pi-extensions/canvas-review-boundary.js +11 -3
  98. package/dist/pi-extensions/canvas-stophook.js +3 -3
  99. package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
  100. package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
  101. package/dist/shared/generated-context.d.ts +68 -36
  102. package/dist/shared/generated-context.js +300 -174
  103. package/dist/shared/tool-groups.js +3 -2
  104. package/package.json +1 -1
  105. package/runtime.lock.json +2 -2
  106. package/scripts/postinstall.mjs +9 -0
@@ -5,7 +5,8 @@
5
5
  // round-trip. Resuming a saved conversation needs none of this (the
6
6
  // conversation already holds the context).
7
7
  //
8
- // Layout (the framing a revived node sees):
8
+ // Layout (the framing a revived node sees), all of it inside one
9
+ // <runtime kind="revive" …> card envelope:
9
10
  // <roadmap file=…>…</roadmap> its evolving plan — the source of truth
10
11
  // <context-dir path=…>…</context-dir> what artifacts exist on disk
11
12
  // <feed>Awaiting N nodes … digest</feed> live wait state + unread/catch-up feed
@@ -25,8 +26,10 @@ import { readRoadmap, roadmapPath } from './roadmap.js';
25
26
  import { buildWakeBearings } from './bearings.js';
26
27
  import { personaDrift, commitPersonaAck } from './persona.js';
27
28
  import { readInboxSince, readCursor, writeCursor, coalesce, } from '../feed/inbox.js';
28
- import { REVIVE_KICKOFF_SENTINEL } from '../../shared/generated-context.js';
29
- export { REVIVE_KICKOFF_SENTINEL, RUNTIME_RESTART_CONTINUATION, } from '../../shared/generated-context.js';
29
+ import { formatCard } from '../../shared/generated-context.js';
30
+ /** The runtime-restart continuation exactly as delivered. `revive.ts` injects
31
+ * it as the prompt of a strict resume that follows a cleanly aborted turn. */
32
+ export const RUNTIME_RESTART_CONTINUATION = formatCard('restart-continuation', {}, 'continue');
30
33
  // ---------------------------------------------------------------------------
31
34
  // Companion context files: the goal (the spawning mandate) and the one-shot
32
35
  // yield message (a note from the prior self to the revived self).
@@ -63,9 +66,6 @@ export function captureGoalIfAbsent(nodeId, text) {
63
66
  writeGoal(nodeId, body);
64
67
  return true;
65
68
  }
66
- /** Sentinel opening the fresh-revive kickoff message (see buildReviveKickoff).
67
- * The goal-capture extension and attached renderers share its pure definition,
68
- * so the same exact contract controls both prompt exclusion and display. */
69
69
  /** The yield-message file — a short note `crtr node yield` records for the next
70
70
  * fresh/cycling revive ("on wake, do X"). It remains durable through failed
71
71
  * launch attempts and is cleared only when session_start confirms a boot. */
@@ -115,7 +115,9 @@ export function drainBearings(meta) {
115
115
  let unreadDigest = null;
116
116
  if (entries.length > 0) {
117
117
  writeCursor(nodeId, entries.at(-1).entry_id);
118
- unreadDigest = coalesce(entries);
118
+ // The launch-argv seam carries one message, so a mixed batch's split parts
119
+ // join back into this block's body; the kickoff is one whole envelope.
120
+ unreadDigest = coalesce(entries).map((delivery) => delivery.text).join('\n\n');
119
121
  }
120
122
  // Capture + commit any external persona drift (the second of the two delivery
121
123
  // sites). Committing the ack here is the mutation; the guidance is surfaced by
@@ -244,16 +246,15 @@ function backgroundJobsBlock(nodeId) {
244
246
  export function buildReviveKickoff(meta, bearings, wakeReason) {
245
247
  const nodeId = meta.node_id;
246
248
  const parts = [
247
- `${REVIVE_KICKOFF_SENTINEL} — your previous in-memory ` +
249
+ 'You have been revived fresh after a context refresh — your previous in-memory ' +
248
250
  'context is gone, by design. Everything below was just read from disk; it is your ' +
249
251
  'full bearings. Rebuild from it and continue toward your goal.',
250
252
  ];
251
253
  // Wake provenance (Invariant B/D): when a scheduled bare self-alarm fired this
252
254
  // revive, the <crtr-wake> block reframes the generic "you were revived" above
253
- // into "a TIMER woke you" — placed right after the sentinel (so the kickoff
254
- // still STARTS with REVIVE_KICKOFF_SENTINEL, which goal-capture keys on) and
255
- // before the roadmap/disk bearings, so "why you woke" precedes "what to rebuild
256
- // from". Only the daemon's bare-wake branch passes wakeReason.
255
+ // into "a TIMER woke you" — placed before the roadmap/disk bearings, so "why you
256
+ // woke" precedes "what to rebuild from". Only the daemon's bare-wake branch
257
+ // passes wakeReason.
257
258
  if (wakeReason !== undefined)
258
259
  parts.push(buildWakeBearings(wakeReason));
259
260
  // The roadmap is the source of truth on a fresh revive: its frozen core holds
@@ -282,9 +283,10 @@ export function buildReviveKickoff(meta, bearings, wakeReason) {
282
283
  // Hidden applet/situation-origin ambient context (see situational-context.ts)
283
284
  // is deliberately NOT injected here — this kickoff is the VISIBLE revive prompt.
284
285
  // canvas-context-intro's session_start handler re-fires on every fresh/cycling
285
- // revive (a new pi process boot) and re-injects buildContextIntro(), which
286
- // already carries the sidecar as a hidden sibling of <crtr-bearings>; that seam
287
- // 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.
288
290
  // Live background bash jobs sit beside the context-dir listing: both describe
289
291
  // this node's OWN in-flight state, and both precede <feed>, which is about the
290
292
  // other nodes it awaits. Absent when nothing is running.
@@ -316,5 +318,14 @@ export function buildReviveKickoff(meta, bearings, wakeReason) {
316
318
  if (bearings.driftGuidance !== null) {
317
319
  parts.push(`<persona-transition>\nYour role was changed while you were away. ${bearings.driftGuidance}\n</persona-transition>`);
318
320
  }
319
- return parts.join('\n\n');
321
+ // The whole kickoff is one runtime card. Its facts are what this producer
322
+ // knows about the revive, and they are the ONLY thing a reader may classify or
323
+ // summarize on — the prose above stays free to be reworded. The envelope is
324
+ // also what keeps a fresh revive out of canvas-goal-capture's first-real-input
325
+ // path, which would otherwise overwrite a bare root's mandate and rename it.
326
+ return formatCard('revive', {
327
+ mode: meta.mode,
328
+ ...(meta.cycle_pending === true ? { cycle: true } : {}),
329
+ ...(wakeReason === undefined ? {} : { wake: wakeReason.kind }),
330
+ }, `\n${parts.join('\n\n')}\n`);
320
331
  }
@@ -30,6 +30,8 @@ export interface NodeSnapshotRead {
30
30
  }
31
31
  export interface NodeMessagesPageRead {
32
32
  messages: unknown[];
33
+ /** Stable session-entry ids aligned 1:1 with `messages`. */
34
+ messageIds?: string[];
33
35
  nextCursor: string | null;
34
36
  }
35
37
  export interface NodeMessagesPageOptions {
@@ -153,10 +153,15 @@ async function reconstructVisibleMessages(nodeId) {
153
153
  copyFileSync(sessionFile, copy);
154
154
  const manager = SessionManager.open(copy);
155
155
  const context = manager.buildSessionContext();
156
- const messages = visibleMessages(cycleAwareMessages(manager), { boundaryReviewId: node.review_binding?.review_id });
156
+ const reconstructed = cycleAwareMessages(manager);
157
+ const identified = reconstructed === undefined
158
+ ? undefined
159
+ : visibleMessages(reconstructed, { boundaryReviewId: node.review_binding?.review_id });
160
+ const messages = identified?.map(({ message }) => message) ?? context.messages;
157
161
  return {
158
162
  sessionFile,
159
163
  messages,
164
+ identified,
160
165
  model: context.model,
161
166
  thinkingLevel: context.thinkingLevel,
162
167
  sessionId: manager.getSessionId(),
@@ -208,6 +213,7 @@ export async function readNodeSnapshot(nodeId) {
208
213
  const read = await reconstructVisibleMessages(nodeId);
209
214
  const snapshot = {
210
215
  messages: read.messages,
216
+ ...(read.identified === undefined ? {} : { messageIds: read.identified.map(({ id }) => id) }),
211
217
  stats: statsFromMessages(read.messages, read.sessionId, read.sessionFile),
212
218
  // pi's `get_state` shape (RpcSessionState), reconstructed offline: the
213
219
  // live-only fields report their at-rest values (nothing is streaming or
@@ -255,11 +261,13 @@ export async function readNodeMessagesPage(nodeId, { cursor, limit = 200 }) {
255
261
  next: 'Use a limit from 1 through 500.',
256
262
  });
257
263
  }
258
- const { messages } = await reconstructVisibleMessages(nodeId);
264
+ const { messages, identified } = await reconstructVisibleMessages(nodeId);
259
265
  const before = cursor === undefined ? messages.length : decodeMessagesCursor(cursor, messages.length);
260
266
  const start = Math.max(0, before - limit);
267
+ const page = identified?.slice(start, before);
261
268
  return {
262
- messages: messages.slice(start, before),
269
+ messages: page?.map(({ message }) => message) ?? messages.slice(start, before),
270
+ ...(page === undefined ? {} : { messageIds: page.map(({ id }) => id) }),
263
271
  nextCursor: start === 0 ? null : encodeMessagesCursor(start),
264
272
  };
265
273
  }
@@ -8,6 +8,11 @@ export declare const CRTR_CYCLE_CUSTOM_TYPE = "crtr-cycle";
8
8
  * snapshot rendering. Never persisted to the session file. */
9
9
  export declare const CRTR_CYCLE_DIVIDER_CUSTOM_TYPE = "crtr-cycle-divider";
10
10
  type SnapshotMessage = BrokerSnapshot['messages'][number];
11
+ /** One reconstructed message and the durable session entry that produced it. */
12
+ export type IdentifiedMessage = {
13
+ id: string;
14
+ message: SnapshotMessage;
15
+ };
11
16
  /** The structural slice of pi's SessionManager cycle rendering needs. The
12
17
  * broker's fake-engine test fixture implements only `buildSessionContext`, so
13
18
  * the tree accessors are optional — absent ⇒ single-cycle behavior. */
@@ -36,5 +41,5 @@ export declare function withoutYieldAbort(message: SnapshotMessage): SnapshotMes
36
41
  * the manager lacks tree accessors (fake engine), or when the current leaf was
37
42
  * tree-navigated back into a pre-yield branch (pi's active-branch semantics
38
43
  * then apply unchanged). */
39
- export declare function cycleAwareMessages(sm: CycleSessionManagerLike): SnapshotMessage[];
44
+ export declare function cycleAwareMessages(sm: CycleSessionManagerLike): IdentifiedMessage[] | undefined;
40
45
  export {};
@@ -14,7 +14,7 @@
14
14
  // boundary, then the live cycle's context — so the broker welcome snapshot
15
15
  // (broker.ts) and the dormant snapshot (node-snapshot.ts) both render one
16
16
  // continuous conversation.
17
- import { buildSessionContext } from '@earendil-works/pi-coding-agent';
17
+ import { buildContextEntries, sessionEntryToContextMessages, } from '@earendil-works/pi-coding-agent';
18
18
  /** customType of the persisted tree entry that ROOTS each post-yield cycle.
19
19
  * Data payload: `{ cycle, fromLeaf }` — `cycle` is the node's cycle counter at
20
20
  * relaunch, `fromLeaf` the previous cycle's leaf entry id. */
@@ -55,9 +55,14 @@ export function withoutYieldAbort(message) {
55
55
  const { stopReason: _stopReason, errorMessage: _errorMessage, ...visible } = message;
56
56
  return visible;
57
57
  }
58
+ function identifiedContextMessages(entries, leafId, byId) {
59
+ return buildContextEntries(entries, leafId, byId).flatMap((entry) => sessionEntryToContextMessages(entry).map((message) => ({ id: entry.id, message: message })));
60
+ }
58
61
  function priorCycleMessages(messages) {
59
62
  const last = messages.at(-1);
60
- return last === undefined ? messages : [...messages.slice(0, -1), withoutYieldAbort(last)];
63
+ return last === undefined
64
+ ? messages
65
+ : [...messages.slice(0, -1), { ...last, message: withoutYieldAbort(last.message) }];
61
66
  }
62
67
  /** The full multi-cycle message history for display: prior cycles (oldest
63
68
  * first, each closed by a divider), then the live cycle's context. Falls back
@@ -66,11 +71,13 @@ function priorCycleMessages(messages) {
66
71
  * tree-navigated back into a pre-yield branch (pi's active-branch semantics
67
72
  * then apply unchanged). */
68
73
  export function cycleAwareMessages(sm) {
69
- const current = sm.buildSessionContext().messages;
70
74
  if (typeof sm.getEntries !== 'function' || typeof sm.getBranch !== 'function')
71
- return current;
75
+ return undefined;
72
76
  // File order = append order = chronological cycle order.
73
77
  const entries = sm.getEntries();
78
+ const byId = new Map(entries.map((e) => [e.id, e]));
79
+ const currentBranch = sm.getBranch();
80
+ const current = identifiedContextMessages(entries, currentBranch.at(-1)?.id ?? null, byId);
74
81
  const markers = entries.filter((e) => e.type === 'custom' && e.customType === CRTR_CYCLE_CUSTOM_TYPE);
75
82
  if (markers.length === 0)
76
83
  return current;
@@ -78,14 +85,10 @@ export function cycleAwareMessages(sm) {
78
85
  // not a cycle marker, the leaf sits in the ORIGINAL (pre-yield) branch —
79
86
  // render just that branch. Otherwise replay every cycle up to and including
80
87
  // the one the marker closes.
81
- const currentRootId = sm.getBranch()[0]?.id;
88
+ const currentRootId = currentBranch[0]?.id;
82
89
  const rootMarkerIndex = markers.findIndex((m) => m.id === currentRootId);
83
90
  if (rootMarkerIndex === -1)
84
91
  return current;
85
- // Shared byId map — buildSessionContext would otherwise rebuild one from
86
- // `entries` on every call; one pass here amortizes it across every replayed
87
- // marker.
88
- const byId = new Map(entries.map((e) => [e.id, e]));
89
92
  const out = [];
90
93
  for (let i = 0; i <= rootMarkerIndex; i++) {
91
94
  const marker = markers[i];
@@ -95,9 +98,12 @@ export function cycleAwareMessages(sm) {
95
98
  // builder — preserving compaction semantics (one compactionSummary +
96
99
  // kept + post-compaction messages only) exactly like the live cycle
97
100
  // below.
98
- out.push(...priorCycleMessages(buildSessionContext(entries, fromLeaf, byId).messages));
101
+ out.push(...priorCycleMessages(identifiedContextMessages(entries, fromLeaf, byId)));
99
102
  }
100
- out.push(cycleDivider(marker.data?.cycle, marker.timestamp));
103
+ out.push({
104
+ id: `${marker.id}~divider`,
105
+ message: cycleDivider(marker.data?.cycle, marker.timestamp),
106
+ });
101
107
  }
102
108
  out.push(...current);
103
109
  return out;
@@ -1,16 +1,11 @@
1
- import type { BrokerSnapshot, WireSessionTreeNode } from './broker-protocol.js';
2
- /** customType of the crtr-authored boundary marker that opens a review
3
- * companion's visible transcript. Data payload: `{ reviewId, originNodeId,
4
- * targetFile }` — per-review facts that complement the inheritance prefix.
5
- *
6
- * The marker itself is a crtr-rendered divider shown to the person; everything
7
- * above it in the inherited session is context they cannot see.
8
- */
9
- export declare const REVIEW_BOUNDARY_CUSTOM_TYPE = "crtr-review-boundary";
1
+ import type { WireSessionTreeNode } from './broker-protocol.js';
2
+ import type { IdentifiedMessage } from './session-cycles.js';
3
+ import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
4
+ export { REVIEW_BOUNDARY_CUSTOM_TYPE };
10
5
  /** True when an entry is a durable review boundary, optionally for one review. */
11
6
  export declare function isReviewBoundary(entry: unknown, reviewId?: string): boolean;
12
7
  /**
13
- * Project snapshot messages to the visible window for a review companion.
8
+ * Project identified snapshot messages to the visible window for a review companion.
14
9
  *
15
10
  * A companion passes its binding's review id so the slice starts at ITS OWN
16
11
  * boundary marker: a node forked from another companion inherits that
@@ -27,9 +22,9 @@ export declare function isReviewBoundary(entry: unknown, reviewId?: string): boo
27
22
  * with a synthetic boundary while its retained tail remains visible. Every
28
23
  * other markerless shape fails closed with the synthetic boundary alone.
29
24
  */
30
- export declare function visibleMessages(messages: BrokerSnapshot['messages'], opts: {
25
+ export declare function visibleMessages(messages: IdentifiedMessage[], opts: {
31
26
  boundaryReviewId: string | undefined;
32
- }): BrokerSnapshot['messages'];
27
+ }): IdentifiedMessage[];
33
28
  /**
34
29
  * Project a session tree to the review companion's visible transcript boundary.
35
30
  *
@@ -9,14 +9,8 @@
9
9
  // Applied at the two BrokerSnapshot.messages producers (live broker and
10
10
  // dormant node read) so every presenter — attach, web node viewer, inspect — is
11
11
  // boundary-safe by construction with no presenter-side changes.
12
- /** customType of the crtr-authored boundary marker that opens a review
13
- * companion's visible transcript. Data payload: `{ reviewId, originNodeId,
14
- * targetFile }` — per-review facts that complement the inheritance prefix.
15
- *
16
- * The marker itself is a crtr-rendered divider shown to the person; everything
17
- * above it in the inherited session is context they cannot see.
18
- */
19
- export const REVIEW_BOUNDARY_CUSTOM_TYPE = 'crtr-review-boundary';
12
+ import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../../shared/generated-context.js';
13
+ export { REVIEW_BOUNDARY_CUSTOM_TYPE };
20
14
  /** True when an entry is a durable review boundary, optionally for one review. */
21
15
  export function isReviewBoundary(entry, reviewId) {
22
16
  if (entry === null || typeof entry !== 'object')
@@ -36,7 +30,7 @@ function syntheticBoundary(timestamp) {
36
30
  };
37
31
  }
38
32
  /**
39
- * Project snapshot messages to the visible window for a review companion.
33
+ * Project identified snapshot messages to the visible window for a review companion.
40
34
  *
41
35
  * A companion passes its binding's review id so the slice starts at ITS OWN
42
36
  * boundary marker: a node forked from another companion inherits that
@@ -54,16 +48,16 @@ function syntheticBoundary(timestamp) {
54
48
  * other markerless shape fails closed with the synthetic boundary alone.
55
49
  */
56
50
  export function visibleMessages(messages, opts) {
57
- const markerIndex = messages.findIndex((message) => isReviewBoundary(message, opts.boundaryReviewId));
51
+ const markerIndex = messages.findIndex(({ message }) => isReviewBoundary(message, opts.boundaryReviewId));
58
52
  if (markerIndex !== -1)
59
53
  return messages.slice(markerIndex);
60
54
  if (opts.boundaryReviewId === undefined)
61
55
  return messages;
62
56
  const [first, ...retainedTail] = messages;
63
- if (first?.role === 'compactionSummary') {
64
- return [syntheticBoundary(first.timestamp), ...retainedTail];
57
+ if (first?.message.role === 'compactionSummary') {
58
+ return [{ id: '~boundary', message: syntheticBoundary(first.message.timestamp) }, ...retainedTail];
65
59
  }
66
- return [syntheticBoundary(first?.timestamp)];
60
+ return [{ id: '~boundary', message: syntheticBoundary(first?.message.timestamp) }];
67
61
  }
68
62
  /**
69
63
  * Project a session tree to the review companion's visible transcript boundary.
@@ -1,12 +1,21 @@
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
15
  /** Read the current situational context, or null if none was ever set. */
9
16
  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;
17
+ /** The node's stored situational card, verbatim, or null when none is set.
18
+ * The ONE read of the sidecar. A sidecar written before the card cut holds
19
+ * bare prose; that single legacy branch lives here alone and self-clears on
20
+ * the next situation change. */
21
+ 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 === '')
@@ -40,9 +54,13 @@ export function readSituationalContext(nodeId) {
40
54
  const body = readFileSync(p, 'utf8').trim();
41
55
  return body !== '' ? body : null;
42
56
  }
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) {
57
+ /** The node's stored situational card, verbatim, or null when none is set.
58
+ * The ONE read of the sidecar. A sidecar written before the card cut holds
59
+ * bare prose; that single legacy branch lives here alone and self-clears on
60
+ * the next situation change. */
61
+ export function situationalContextEnvelope(nodeId) {
46
62
  const text = readSituationalContext(nodeId);
47
- return text === null ? '' : `<situational-context>\n${text}\n</situational-context>`;
63
+ if (text === null)
64
+ return null;
65
+ return parseCard({ content: text }) === null ? formatSituationalProse(text) : text;
48
66
  }
@@ -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,24 +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 { appendSituationalContext, situationalContextBlock, SITUATIONAL_CONTEXT_CUSTOM_TYPE, } from './situational-context.js';
20
- /** Write `text` to the node's sidecar and, when its broker is live, deliver the
21
- * 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.
22
23
  *
23
24
  * Rejects when a live broker cannot be reached or refuses the frame — the
24
25
  * caller decides what that means. For a warm claim it is fatal (a node holding
@@ -26,15 +27,15 @@ import { appendSituationalContext, situationalContextBlock, SITUATIONAL_CONTEXT_
26
27
  * makes. A DORMANT node needs no delivery at all: its next revive renders the
27
28
  * sidecar into the kickoff. */
28
29
  export async function setSituationalContextLive(nodeId, text) {
29
- appendSituationalContext(nodeId, text);
30
+ appendSituationalContext(nodeId, formatSituationalProse(text));
30
31
  if (getNode(nodeId) === null)
31
32
  throw new Error(`setSituationalContextLive: unknown node ${nodeId}`);
32
- const block = situationalContextBlock(nodeId);
33
- if (block === '')
33
+ const envelope = situationalContextEnvelope(nodeId);
34
+ if (envelope === null)
34
35
  return; // blank text — appendSituationalContext no-ops, nothing to deliver
35
36
  await deliverCustomMessageLive(nodeId, {
36
37
  customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
37
- content: block,
38
+ content: envelope,
38
39
  deliverAs: 'nextTurn',
39
40
  });
40
41
  }
@@ -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
@@ -23,16 +23,16 @@
23
23
  // final pushed. Re-prompt it to finish or escalate.
24
24
  import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
25
25
  import { activeBackgroundBashJobs } from '../bash-jobs.js';
26
- import { formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
26
+ import { formatCard, formatStructuredOutputReprompt, STALL_REPROMPT } from '../../shared/generated-context.js';
27
27
  import { readOutputRequest } from './structured-output.js';
28
28
  export { STALL_REPROMPT } from '../../shared/generated-context.js';
29
29
  /** Format the stop guard's reprompt for a valid structured-output request. */
30
30
  function formatValidStructuredOutputReprompt(schema) {
31
- return formatStructuredOutputReprompt(JSON.stringify(schema, null, 2));
31
+ return formatCard('stop-guard', { reason: 'structured-output' }, formatStructuredOutputReprompt(JSON.stringify(schema, null, 2)));
32
32
  }
33
33
  /** Format the stop guard's reprompt for an invalid structured-output request file. */
34
34
  function formatInvalidStructuredOutputReprompt(error) {
35
- return (`Structured-output request file is invalid and cannot be used: ${error}\n\n` +
35
+ return formatCard('stop-guard', { reason: 'structured-output' }, `Structured-output request file is invalid and cannot be used: ${error}\n\n` +
36
36
  `You have two options:\n` +
37
37
  `1. Remove the invalid file (the output-schema.json in your node directory has been corrupted)\n` +
38
38
  `2. Fix the corruption if you know the original request schema\n\n` +
@@ -96,5 +96,9 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
96
96
  || backgroundJobsRunning)
97
97
  return { action: 'allow', reason: 'awaiting' };
98
98
  // A terminal node with nothing live and no final pushed has stalled.
99
- return { action: 'reprompt', reason: 'stalled', message: STALL_REPROMPT };
99
+ return {
100
+ action: 'reprompt',
101
+ reason: 'stalled',
102
+ message: formatCard('stop-guard', { reason: 'stalled' }, STALL_REPROMPT),
103
+ };
100
104
  }
@@ -17,7 +17,7 @@ export interface WarmRequest {
17
17
  kind: string;
18
18
  mode: Mode;
19
19
  /** Where the node runs — already resolved by the create handler (pin >
20
- * spawner > profile home), never a raw caller directory. */
20
+ * spawner > launch cwd > profile home). */
21
21
  cwd: string;
22
22
  /** The RESOLVED profile id, or null for explicitly no profile. */
23
23
  profileId: string | null;
@@ -47,10 +47,9 @@
47
47
  // and at daemon start for the most recently used profiles, so the FIRST node
48
48
  // after sitting down is warm too.
49
49
  //
50
- // The pool does not fan out per directory: a node runs in its profile's home
51
- // (`resolveNodeCwd` in the create handler), so the cwd a spare freezes is a
52
- // function of its profile, and "which spare serves this create" is in practice
53
- // a question about the profile alone.
50
+ // A spare freezes the directory it was booted in, so a create launched from a
51
+ // different directory than the last one simply misses and cold-spawns — the
52
+ // pool serves the common case of returning to the same working directory.
54
53
  //
55
54
  // Nothing ever RESUMES a spare: the daemon's recovery sweep reads `listNodes`,
56
55
  // which hides the pool, so a spare whose broker dies (every daemon handover
@@ -63,7 +62,6 @@ import { claimWarmSpare, deleteNode, getNode, listNodes, listWarmSpares, registe
63
62
  import { recordedPidLiveness } from '../canvas/pid.js';
64
63
  import { nowIso } from '../fs-utils.js';
65
64
  import { spawnChildPrepared } from './spawn.js';
66
- import { profileHome } from '../profiles/manifest.js';
67
65
  import { buildLaunchSpec } from './launch.js';
68
66
  import { setModelLive } from './model-swap.js';
69
67
  import { setSituationalContextLive } from './situational-live.js';
@@ -335,10 +333,9 @@ export function prewarmRecentRecipes() {
335
333
  if (seen.has(profileId ?? ''))
336
334
  continue;
337
335
  seen.add(profileId ?? '');
338
- // The profile's home is where its next node will land. A profile without
339
- // one (root, or a home since deleted) still gets a spare, keyed on the
340
- // directory its last root actually ran in.
341
- candidates.push({ cwd: profileHome(profileId) ?? row.cwd, profileId });
336
+ // A node lands in the directory its launch names, so the profile's last
337
+ // root predicts the next one better than the profile's home does.
338
+ candidates.push({ cwd: row.cwd, profileId });
342
339
  if (candidates.length >= PREWARM_PROFILES)
343
340
  break;
344
341
  }