@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
@@ -1,27 +1,17 @@
1
- // generated-context.ts — the display contract for crouter-authored context messages.
1
+ // Runtime-card grammar for crouter-authored context messages.
2
2
  //
3
- // The runtime sends these messages in the role that preserves their delivery
4
- // semantics: fresh-revive kickoffs, mid-run nudges, persona transitions, stop
5
- // guards, recovery prompts, and node inbox digests are user messages, while
6
- // bearings, situational updates, and held nudges are custom messages. Attached
7
- // views should still present all of them as generated context rather than as
8
- // ordinary human prompts. This pure module owns their stable identifiers,
9
- // producer formatting, and display classification so the terminal and web
10
- // renderers cannot drift.
11
- import { REVIEW_BOUNDARY_CUSTOM_TYPE } from '../core/runtime/session-visibility.js';
12
- export { REVIEW_BOUNDARY_CUSTOM_TYPE };
3
+ // Every runtime message carries one whole-message envelope. customType only
4
+ // controls delivery and visibility; parsing stays independent of runtime and clients.
13
5
  /** Custom message carrying the node's session-start bearings. */
14
6
  export const CONTEXT_INTRO_CUSTOM_TYPE = 'crtr-context';
15
7
  /** Custom message carrying an ambient situational-context update. */
16
8
  export const SITUATIONAL_CONTEXT_CUSTOM_TYPE = 'crtr-situational-context';
17
9
  /** Custom message used when a context-size nudge waits for the next turn. */
18
10
  export const CONTEXT_NUDGE_CUSTOM_TYPE = 'crtr-context-nudge';
19
- /** Opening text of a fresh-revive kickoff user message. */
11
+ /** Custom message that opens a review companion's visible transcript. */
12
+ export const REVIEW_BOUNDARY_CUSTOM_TYPE = 'crtr-review-boundary';
13
+ /** Opening text of a pre-envelope fresh-revive kickoff user message. */
20
14
  export const REVIVE_KICKOFF_SENTINEL = 'You have been revived fresh after a context refresh';
21
- /** Prefix shared by the producer and the compatibility classifier for context nudges. */
22
- export const CONTEXT_NUDGE_PREFIX = '[crtr] Context ~';
23
- /** Runtime-authored continuation after a cleanly aborted turn survives a restart. */
24
- export const RUNTIME_RESTART_CONTINUATION = '<runtime-restart-continuation>\ncontinue\n</runtime-restart-continuation>';
25
15
  /** Generic completion mandate issued by the terminal-node stop guard. */
26
16
  export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone and haven't finished. " +
27
17
  "Pipe the result to `crtr push final` through a single-quoted heredoc if the work is done, or use `crtr human send` if you are blocked or need the user.";
@@ -29,50 +19,166 @@ export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone a
29
19
  export const AUTH_FAULT_RECOVERY_BODY = 'Provider credentials were just updated (a new login landed). Your previous turn stopped on a provider authentication failure. Continue from where you left off and retry the work that failed.';
30
20
  export const CONNECTION_FAULT_RECOVERY_BODY = 'The network connection is back online. Your previous turn stopped on a connection error (the network was down). Continue from where you left off and retry the work that failed.';
31
21
  export const PROVIDER_FAULT_RECOVERY_BODY = 'Your previous turn stopped on a provider fault. Continue from where you left off and retry the work that failed.';
32
- const PERSONA_TRANSITION_OPEN = '<persona-transition>';
33
- const PERSONA_TRANSITION_CLOSE = '</persona-transition>';
34
22
  const REVIEW_APPROVAL_OPEN = '<crtr-review-approval>';
35
23
  const REVIEW_APPROVAL_CLOSE = '</crtr-review-approval>';
36
24
  const MODEL_FALLBACK_RECOVERY_OPEN = '<model-fallback-recovery>';
37
25
  const MODEL_FALLBACK_RECOVERY_CLOSE = '</model-fallback-recovery>';
38
26
  const STRUCTURED_OUTPUT_REPROMPT_PREFIX = 'You must call the `submit` tool with a result matching the required schema before you can stop. You cannot finish or go dormant any other way while this request is pending.\n\nRequired schema:\n\n```json\n';
39
27
  const STRUCTURED_OUTPUT_REPROMPT_SUFFIX = '\n```';
40
- /** Mark an active persona transition without changing the guidance inside it. */
41
- export function formatPersonaTransition(guidance) {
42
- return `${PERSONA_TRANSITION_OPEN}\n${guidance}\n${PERSONA_TRANSITION_CLOSE}`;
43
- }
44
- /** Mark a daemon-delivered review approval for compact viewer presentation. */
45
- export function formatReviewApproval(body) {
46
- return `${REVIEW_APPROVAL_OPEN}\n${body}\n${REVIEW_APPROVAL_CLOSE}`;
47
- }
48
28
  /** Format the stop guard's dynamic structured-output mandate. */
49
29
  export function formatStructuredOutputReprompt(schema) {
50
30
  return `${STRUCTURED_OUTPUT_REPROMPT_PREFIX}${schema}${STRUCTURED_OUTPUT_REPROMPT_SUFFIX}`;
51
31
  }
52
- /** Mark a model-route fallback re-drive while retaining its full guidance. */
32
+ /** Keep model-fallback guidance editable without making it a reader contract. */
53
33
  export function formatModelFallbackRecovery(previousModel, nextModel, reason) {
54
- const guidance = reason === 'credential'
34
+ return reason === 'credential'
55
35
  ? `The previous model (${previousModel}) had no usable provider credential, so you were automatically switched to ${nextModel}. Continue the task from where the failed turn left off.`
56
36
  : `The previous model (${previousModel}) was unavailable (provider 404 not_found), so you were automatically switched to ${nextModel}. Continue the task from where the failed turn left off.`;
57
- return `${MODEL_FALLBACK_RECOVERY_OPEN}\n${guidance}\n${MODEL_FALLBACK_RECOVERY_CLOSE}`;
58
- }
59
- const AUTO_LOADED_CONTEXT_BLOCK = /<auto-loaded-context>([\s\S]*?)<\/auto-loaded-context>/;
60
- const YIELD_MESSAGE_BLOCK = /<yield-message>([\s\S]*?)<\/yield-message>/;
61
- /** The display-only disclosure body for a generated context card. The full
62
- * message remains in `body` for the model and transcript bookkeeping; the two
63
- * prompt-sized cards expose the one piece a person needs to inspect. */
64
- function unmarkReviewApprovals(body) {
65
- return body.replaceAll(`${REVIEW_APPROVAL_OPEN}\n`, '').replaceAll(`\n${REVIEW_APPROVAL_CLOSE}`, '');
66
- }
67
- export function generatedContextExpandedBody(presentation) {
68
- let body = presentation.body;
69
- if (presentation.label === 'crtr context') {
70
- body = AUTO_LOADED_CONTEXT_BLOCK.exec(body)?.[1]?.trim() ?? '';
37
+ }
38
+ function countFact(facts, key) {
39
+ const value = facts[key];
40
+ if (value === undefined || !/^(?:0|[1-9]\d*)$/.test(value))
41
+ return null;
42
+ return Number(value);
43
+ }
44
+ function inboxSummary(facts) {
45
+ const updates = countFact(facts, 'updates');
46
+ const senders = countFact(facts, 'senders');
47
+ if (updates === null)
48
+ return 'inbox update';
49
+ if (senders === null || senders <= 1)
50
+ return `${updates === 1 ? 'message' : `${updates} messages`} received`;
51
+ return `${updates} messages received from ${senders} nodes`;
52
+ }
53
+ function recoverySummaryFromFacts(facts) {
54
+ switch (facts.reason) {
55
+ case 'model-fallback': return 'model route changed';
56
+ case 'connection': return 'network connection restored';
57
+ case 'provider': return 'provider retry';
58
+ case 'auth': return 'provider credentials updated';
59
+ default: return 'runtime recovery';
60
+ }
61
+ }
62
+ function reviewCommentSummary(facts) {
63
+ switch (facts.verb) {
64
+ case 'create': return 'review comment created';
65
+ case 'edit': return 'review comment edited';
66
+ case 'resolve': return 'review comment resolved';
67
+ case 'reopen': return 'review comment reopened';
68
+ case 'delete': return 'review comment deleted';
69
+ default: return 'review comment updated';
71
70
  }
72
- else if (presentation.kind === 'revive') {
73
- body = YIELD_MESSAGE_BLOCK.exec(body)?.[1]?.trim() ?? '';
71
+ }
72
+ /** The closed crouter vocabulary, plus custom-role aliases for the same cards. */
73
+ export const KIND_TABLE = {
74
+ inbox: { label: 'crtr inbox', summary: inboxSummary, expandable: true },
75
+ revive: { label: 'crtr revive', summary: () => 'fresh context kickoff', expandable: true },
76
+ 'restart-continuation': { label: 'crouter continuation', summary: () => 'continuing from where it left off', expandable: true },
77
+ 'stop-guard': {
78
+ label: 'crtr stop guard',
79
+ summary: (facts) => facts.reason === 'structured-output' ? 'structured output required' : 'completion required',
80
+ expandable: true,
81
+ },
82
+ recovery: { label: 'crouter continuation', summary: recoverySummaryFromFacts, expandable: true },
83
+ 'persona-transition': { label: 'crtr persona', summary: () => 'runtime role update', expandable: true },
84
+ 'review-approval': { label: 'crtr review', summary: () => 'review approved by the user', expandable: true },
85
+ 'review-comment': { label: 'crtr review', summary: reviewCommentSummary, expandable: true },
86
+ 'review-queued': { label: 'crtr review', summary: () => 'review submitted; completion pending', expandable: true },
87
+ 'context-nudge': {
88
+ label: 'crtr',
89
+ summary: (facts) => facts.size === undefined ? 'context-window guidance' : `Context ${facts.size}`,
90
+ expandable: true,
91
+ customTypes: { [CONTEXT_NUDGE_CUSTOM_TYPE]: {} },
92
+ },
93
+ bearings: {
94
+ label: 'crtr context',
95
+ summary: () => 'orienting bearings',
96
+ expandable: true,
97
+ customTypes: { [CONTEXT_INTRO_CUSTOM_TYPE]: {} },
98
+ },
99
+ 'review-boundary': {
100
+ label: 'review boundary',
101
+ summary: () => 'earlier conversation is not shown',
102
+ expandable: true,
103
+ customTypes: { [REVIEW_BOUNDARY_CUSTOM_TYPE]: {} },
104
+ },
105
+ situational: {
106
+ label: 'situational context',
107
+ summary: () => 'ambient context update',
108
+ expandable: true,
109
+ customTypes: { [SITUATIONAL_CONTEXT_CUSTOM_TYPE]: {} },
110
+ },
111
+ };
112
+ const GENERIC_CARD = {
113
+ label: 'crtr runtime',
114
+ summary: () => 'runtime context',
115
+ expandable: true,
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
+ }
132
+ const XML_ATTRIBUTE_NAME = /^[A-Za-z_:][A-Za-z0-9_:.-]*$/;
133
+ const ATTRIBUTE_RE = /\s+([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/gy;
134
+ function escapeXmlAttribute(value) {
135
+ return value
136
+ .replaceAll('&', '&amp;')
137
+ .replaceAll('<', '&lt;')
138
+ .replaceAll('>', '&gt;')
139
+ .replaceAll('"', '&quot;')
140
+ .replaceAll("'", '&apos;');
141
+ }
142
+ function escapeXmlText(value) {
143
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
144
+ }
145
+ function unescapeXmlAttribute(value) {
146
+ return value.replace(/&(?:amp|lt|gt|quot|apos);/g, (entity) => ({
147
+ '&amp;': '&',
148
+ '&lt;': '<',
149
+ '&gt;': '>',
150
+ '&quot;': '"',
151
+ '&apos;': "'",
152
+ })[entity]);
153
+ }
154
+ function parseAttributes(source) {
155
+ const attributes = {};
156
+ ATTRIBUTE_RE.lastIndex = 0;
157
+ let index = 0;
158
+ while (index < source.length) {
159
+ if (/^\s*$/.test(source.slice(index)))
160
+ break;
161
+ ATTRIBUTE_RE.lastIndex = index;
162
+ const match = ATTRIBUTE_RE.exec(source);
163
+ if (match === null)
164
+ return null;
165
+ const [, key, doubleQuoted, singleQuoted] = match;
166
+ if (key === undefined || Object.hasOwn(attributes, key))
167
+ return null;
168
+ attributes[key] = unescapeXmlAttribute(doubleQuoted ?? singleQuoted ?? '');
169
+ index = ATTRIBUTE_RE.lastIndex;
74
170
  }
75
- return unmarkReviewApprovals(body);
171
+ return attributes;
172
+ }
173
+ function formatAttributes(attributes) {
174
+ return Object.entries(attributes)
175
+ .filter(([, value]) => value !== undefined)
176
+ .map(([key, value]) => {
177
+ if (!XML_ATTRIBUTE_NAME.test(key))
178
+ throw new Error(`Invalid XML attribute name: ${key}`);
179
+ return ` ${key}="${escapeXmlAttribute(String(value))}"`;
180
+ })
181
+ .join('');
76
182
  }
77
183
  /** Plain text from a generated message's string or text-block content. */
78
184
  export function generatedContextText(message) {
@@ -81,62 +187,148 @@ export function generatedContextText(message) {
81
187
  if (!Array.isArray(message.content))
82
188
  return '';
83
189
  return message.content
84
- .filter((block) => typeof block === 'object' &&
85
- block !== null &&
86
- block.type === 'text' &&
87
- typeof block.text === 'string')
190
+ .filter((block) => typeof block === 'object'
191
+ && block !== null
192
+ && block.type === 'text'
193
+ && typeof block.text === 'string')
88
194
  .map((block) => block.text)
89
195
  .join('');
90
196
  }
91
- /** Format the runtime's visible context-size guidance. */
92
- export function formatContextNudge(note) {
93
- return `[crtr] ${note}`;
94
- }
95
- function nudgeSummary(body) {
96
- const size = body.match(/^\[crtr\]\s+Context\s+(~\d+k)\b/)?.[1];
97
- return size === undefined ? 'context-window guidance' : `Context ${size}`;
98
- }
99
- // Stable header emitted by core/feed/inbox.ts coalesce(). Human-authored inbox
100
- // messages are deliberately delivered verbatim and never carry this header.
101
- // Senders are node ids, the two reserved words, or the daemon itself (crtrd —
102
- // review notes and other daemon-originated node messages).
103
- const INBOX_HEADER_RE = /^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/m;
104
- const INBOX_HEADERS_RE = /^From ([a-z0-9]+(?:-[a-z0-9]+)+|system|human|crtrd) — (\d+) update/gm;
105
- // Entry lines inside a section, rendered by core/runtime/broker/inbox.ts as
106
- // ` [<kind>]`. A final is the one kind that ends the sender's node.
107
- const FINISHED_ENTRY_RE = /^ {2}\[(?:final|completed)\]/m;
108
- export function isInboxDigest(text) {
109
- return INBOX_HEADER_RE.test(text);
110
- }
111
- export function extractInboxSender(text) {
112
- return INBOX_HEADER_RE.exec(text)?.[1] ?? null;
113
- }
114
- /** Node ids whose section of this digest reports a finished node. Sections are
115
- * delimited by their `From <sender>` headers, so a final never attaches to the
116
- * neighbouring sender. */
117
- export function inboxFinishedSenders(text) {
118
- const headers = [...text.matchAll(INBOX_HEADERS_RE)];
197
+ /** Wrap a user-role runtime message in its whole-message card envelope. */
198
+ export function formatCard(kind, facts, body) {
199
+ if (kind === '')
200
+ throw new Error('Runtime card kind is required');
201
+ if (Object.hasOwn(facts, 'kind'))
202
+ throw new Error('Runtime card facts cannot replace kind');
203
+ return `<runtime${formatAttributes({ kind, ...facts })}>${body}</runtime>`;
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
+ }
213
+ function parseInboxBody(body) {
119
214
  const senders = [];
120
- for (const [index, header] of headers.entries()) {
121
- const start = header.index ?? 0;
122
- const end = headers[index + 1]?.index ?? text.length;
123
- const sender = header[1];
124
- if (sender !== undefined && FINISHED_ENTRY_RE.test(text.slice(start, end)))
125
- senders.push(sender);
215
+ const fromRe = /<from\b([^>]*)>([\s\S]*?)<\/from>/g;
216
+ for (const fromMatch of body.matchAll(fromRe)) {
217
+ const attributes = parseAttributes(fromMatch[1] ?? '');
218
+ if (attributes?.id === undefined || attributes.updates === undefined)
219
+ continue;
220
+ const updates = Number(attributes.updates);
221
+ if (!Number.isFinite(updates))
222
+ continue;
223
+ const entries = [];
224
+ const entryBody = fromMatch[2] ?? '';
225
+ const entryRe = /<entry\b([^>]*)>([\s\S]*?)<\/entry>/g;
226
+ for (const entryMatch of entryBody.matchAll(entryRe)) {
227
+ const entryAttributes = parseAttributes(entryMatch[1] ?? '');
228
+ if (entryAttributes?.kind === undefined)
229
+ continue;
230
+ const disposition = entryAttributes.disposition === 'human-answer' || entryAttributes.disposition === 'human-canceled'
231
+ ? entryAttributes.disposition
232
+ : 'report';
233
+ entries.push({
234
+ kind: entryAttributes.kind,
235
+ disposition,
236
+ body: unescapeXmlAttribute(entryMatch[2] ?? ''),
237
+ ...(entryAttributes.ref === undefined ? {} : { ref: entryAttributes.ref }),
238
+ });
239
+ }
240
+ senders.push({
241
+ id: attributes.id,
242
+ ...(attributes.name === undefined ? {} : { name: attributes.name }),
243
+ updates,
244
+ finished: attributes.finished === 'true' || entries.some((entry) => entry.kind === 'final'),
245
+ entries,
246
+ });
126
247
  }
127
248
  return senders;
128
249
  }
129
- function inboxSummary(body) {
130
- const headers = [...body.matchAll(INBOX_HEADERS_RE)];
131
- if (headers.length === 0)
132
- return 'node update';
133
- const updates = headers.reduce((total, match) => total + Number(match[2] ?? 0), 0);
134
- if (headers.length === 1) {
135
- const sender = headers[0]?.[1] ?? 'system';
136
- return `${updates} update${updates === 1 ? '' : 's'} from ${sender}`;
250
+ function cardFor(kind, facts, body, senders) {
251
+ const definition = KIND_TABLE[kind] ?? KIND_REGISTRY[kind] ?? GENERIC_CARD;
252
+ return {
253
+ kind,
254
+ facts,
255
+ body,
256
+ senders,
257
+ label: definition.label,
258
+ summary: definition.summary(facts),
259
+ expandable: definition.expandable,
260
+ };
261
+ }
262
+ /** Parse a whole-message runtime envelope, regardless of delivery role. */
263
+ function parseEnvelope(text) {
264
+ const opening = /^<runtime\b([^>]*)>/.exec(text);
265
+ if (opening === null || !text.endsWith('</runtime>'))
266
+ return null;
267
+ const attributes = parseAttributes(opening[1] ?? '');
268
+ if (attributes?.kind === undefined)
269
+ return null;
270
+ const raw = text.slice(opening[0].length, -'</runtime>'.length);
271
+ const { kind, ...factValues } = attributes;
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;
277
+ return cardFor(kind, facts, body, kind === 'inbox' ? parseInboxBody(body) : []);
278
+ }
279
+ /** Parse a runtime envelope, with pre-envelope customType compatibility. */
280
+ export function parseCard(message) {
281
+ const text = generatedContextText(message);
282
+ const envelope = parseEnvelope(text);
283
+ if (envelope !== null)
284
+ return envelope;
285
+ if (message.role !== 'custom')
286
+ return null;
287
+ // Pre-envelope custom-message fallback for old sessions. New custom messages
288
+ // classify through their envelope; customType is delivery metadata only.
289
+ for (const [kind, definition] of Object.entries(KIND_TABLE)) {
290
+ const override = message.customType === undefined ? undefined : definition.customTypes?.[message.customType];
291
+ if (override === undefined)
292
+ continue;
293
+ const facts = Object.freeze({});
294
+ return {
295
+ kind,
296
+ facts,
297
+ body: text,
298
+ senders: [],
299
+ label: override.label ?? definition.label,
300
+ summary: (override.summary ?? definition.summary)(facts),
301
+ expandable: override.expandable ?? definition.expandable,
302
+ };
137
303
  }
138
- return `${updates} updates from ${headers.length} senders`;
304
+ return null;
305
+ }
306
+ /** Format a complete inbox card from report bodies resolved by the caller. */
307
+ export function formatInboxCard(sections) {
308
+ const body = sections.map((section) => {
309
+ const updates = section.updates ?? section.entries.length;
310
+ const finished = section.finished ?? section.entries.some((entry) => entry.kind === 'final');
311
+ const entries = section.entries.map((entry) => {
312
+ const disposition = entry.disposition === 'human-answer' || entry.disposition === 'human-canceled'
313
+ ? entry.disposition
314
+ : undefined;
315
+ return `<entry${formatAttributes({ kind: entry.kind, ref: entry.ref, disposition })}>${escapeXmlText(entry.body)}</entry>`;
316
+ }).join('\n');
317
+ return `<from${formatAttributes({ id: section.id, name: section.name, updates, finished: finished || undefined })}>${entries}</from>`;
318
+ }).join('\n');
319
+ const updates = sections.reduce((total, section) => total + (section.updates ?? section.entries.length), 0);
320
+ return formatCard('inbox', { senders: sections.length, updates }, body);
139
321
  }
322
+ // LEGACY — landed 2026-08-14. Pre-envelope runtime text, kept ONLY so old
323
+ // scrollback is not attributed to the person. Delete this section and its
324
+ // callers one week after that date; after deletion, pre-cut scrollback
325
+ // misattributes and that cost is accepted.
326
+ const LEGACY_RESTART_CONTINUATION = '<runtime-restart-continuation>\ncontinue\n</runtime-restart-continuation>';
327
+ const CONTEXT_NUDGE_PREFIX = '[crtr] Context ~';
328
+ const PERSONA_TRANSITION_OPEN = '<persona-transition>';
329
+ const PERSONA_TRANSITION_CLOSE = '</persona-transition>';
330
+ const INBOX_SENDER_ID = '[a-z0-9]+(?:-[a-z0-9]+)+';
331
+ const INBOX_HEADER_RE = new RegExp(`^From (?:(.+?) \\[(${INBOX_SENDER_ID})\\]|(${INBOX_SENDER_ID}|system|human|crtrd)) — (\\d+) update`, 'm');
140
332
  function isPersonaTransition(body) {
141
333
  return body.startsWith(`${PERSONA_TRANSITION_OPEN}\n`) && body.endsWith(`\n${PERSONA_TRANSITION_CLOSE}`);
142
334
  }
@@ -159,92 +351,26 @@ function isStructuredOutputReprompt(body) {
159
351
  const MODEL_CREDENTIAL_RECOVERY_RE = /^The previous model \([^\n]+\) had no usable provider credential, so you were automatically switched to [^\n]+\. Continue the task from where the failed turn left off\.$/;
160
352
  const MODEL_NOT_FOUND_RECOVERY_RE = /^The previous model \([^\n]+\) was unavailable \(provider 404 not_found\), so you were automatically switched to [^\n]+\. Continue the task from where the failed turn left off\.$/;
161
353
  function isModelFallbackRecovery(body) {
162
- if (!body.startsWith(`${MODEL_FALLBACK_RECOVERY_OPEN}\n`)
163
- || !body.endsWith(`\n${MODEL_FALLBACK_RECOVERY_CLOSE}`)) {
354
+ if (!body.startsWith(`${MODEL_FALLBACK_RECOVERY_OPEN}\n`) || !body.endsWith(`\n${MODEL_FALLBACK_RECOVERY_CLOSE}`)) {
164
355
  return false;
165
356
  }
166
357
  const guidance = body.slice(MODEL_FALLBACK_RECOVERY_OPEN.length + 1, -(MODEL_FALLBACK_RECOVERY_CLOSE.length + 1));
167
358
  return MODEL_CREDENTIAL_RECOVERY_RE.test(guidance) || MODEL_NOT_FOUND_RECOVERY_RE.test(guidance);
168
359
  }
169
- /** Exact producer-owned marker for the continuation a successor broker inserts
170
- * after a runtime restart. Other recovery bodies intentionally do not match. */
171
- export function isRuntimeRestartContinuation(message) {
172
- return message.role === 'user' && generatedContextText(message) === RUNTIME_RESTART_CONTINUATION;
173
- }
174
- function recoverySummary(body) {
175
- if (body === AUTH_FAULT_RECOVERY_BODY)
176
- return 'provider credentials updated';
177
- if (body === CONNECTION_FAULT_RECOVERY_BODY)
178
- return 'network connection restored';
179
- if (body === PROVIDER_FAULT_RECOVERY_BODY)
180
- return 'provider retry';
181
- if (body === RUNTIME_RESTART_CONTINUATION)
182
- return 'continuing from where it left off';
183
- if (isModelFallbackRecovery(body))
184
- return 'model route changed';
185
- return null;
360
+ function isRecoveryBody(body) {
361
+ return body === AUTH_FAULT_RECOVERY_BODY
362
+ || body === CONNECTION_FAULT_RECOVERY_BODY
363
+ || body === PROVIDER_FAULT_RECOVERY_BODY
364
+ || body === LEGACY_RESTART_CONTINUATION
365
+ || isModelFallbackRecovery(body);
186
366
  }
187
- /**
188
- * Classify a crouter-generated context message for display. The checks are
189
- * exact producer-owned contracts: custom messages use their customType, and
190
- * user messages use stable sentinels or the same exact formatter/constants as
191
- * their producers. Ordinary user prompts remain ordinary user prompts.
192
- */
193
- export function generatedContextPresentation(message) {
194
- const body = generatedContextText(message);
195
- if (message.role === 'custom') {
196
- if (message.customType === REVIEW_BOUNDARY_CUSTOM_TYPE) {
197
- return { kind: 'bearings', label: 'review boundary', summary: 'earlier conversation is not shown', body };
198
- }
199
- if (message.customType === CONTEXT_INTRO_CUSTOM_TYPE) {
200
- return { kind: 'bearings', label: 'crtr context', summary: 'orienting bearings', body };
201
- }
202
- if (message.customType === SITUATIONAL_CONTEXT_CUSTOM_TYPE) {
203
- return {
204
- kind: 'situational',
205
- label: 'situational context',
206
- summary: 'ambient context update',
207
- body,
208
- };
209
- }
210
- if (message.customType === CONTEXT_NUDGE_CUSTOM_TYPE) {
211
- return { kind: 'context-nudge', label: 'crtr', summary: nudgeSummary(body), body };
212
- }
213
- return null;
214
- }
215
- if (message.role !== 'user')
216
- return null;
217
- if (body.startsWith(REVIVE_KICKOFF_SENTINEL)) {
218
- return { kind: 'revive', label: 'crtr revive', summary: 'fresh context kickoff', body };
219
- }
220
- if (body.startsWith(CONTEXT_NUDGE_PREFIX)) {
221
- return { kind: 'context-nudge', label: 'crtr', summary: nudgeSummary(body), body };
222
- }
223
- if (isInboxDigest(body)) {
224
- return { kind: 'inbox', label: 'crtr inbox', summary: inboxSummary(body), body };
225
- }
226
- if (isReviewApproval(body)) {
227
- return { kind: 'review-approval', label: 'crtr review', summary: 'review approved by the user', body };
228
- }
229
- if (isPersonaTransition(body)) {
230
- return {
231
- kind: 'persona-transition',
232
- label: 'crtr persona',
233
- summary: 'runtime role update',
234
- body,
235
- };
236
- }
237
- if (body === STALL_REPROMPT || isStructuredOutputReprompt(body)) {
238
- return {
239
- kind: 'stop-guard',
240
- label: 'crtr stop guard',
241
- summary: body === STALL_REPROMPT ? 'completion required' : 'structured output required',
242
- body,
243
- };
244
- }
245
- const recovery = recoverySummary(body);
246
- if (recovery !== null) {
247
- return { kind: 'recovery', label: 'crouter continuation', summary: recovery, body };
248
- }
249
- return null;
367
+ export function isLegacyRuntimeText(text) {
368
+ return text.startsWith(REVIVE_KICKOFF_SENTINEL)
369
+ || text.startsWith(CONTEXT_NUDGE_PREFIX)
370
+ || INBOX_HEADER_RE.test(text)
371
+ || isReviewApproval(text)
372
+ || isPersonaTransition(text)
373
+ || text === STALL_REPROMPT
374
+ || isStructuredOutputReprompt(text)
375
+ || isRecoveryBody(text);
250
376
  }
@@ -1,7 +1,7 @@
1
1
  // Shared transcript-boundary rules for broker-owned tool-group summaries.
2
2
  // Keep this independent of renderer state: both live event tracking and snapshot
3
3
  // reconstruction must agree on which prose closes a run of tools.
4
- import { generatedContextPresentation, generatedContextText } from './generated-context.js';
4
+ import { generatedContextText, isLegacyRuntimeText, parseCard } from './generated-context.js';
5
5
  const TOOL_GROUP_SUMMARY_KEYS = new Set(['bullets', 'filesDeleted']);
6
6
  const MAX_TOOL_GROUP_BULLETS = 5;
7
7
  const MAX_TOOL_GROUP_BULLET_LENGTH = 1_000;
@@ -53,5 +53,6 @@ export function assistantVisibleText(message) {
53
53
  /** A person-authored user message closes the preceding group. Runtime-generated
54
54
  * user context retains role=user for delivery semantics but is not human prose. */
55
55
  export function isTrueUserMessage(message) {
56
- return message.role === 'user' && generatedContextPresentation(message) === null;
56
+ const text = generatedContextText(message);
57
+ return message.role === 'user' && parseCard(message) === null && !isLegacyRuntimeText(text);
57
58
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.204",
3
+ "version": "0.3.206",
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.204",
3
+ "version": "0.3.206",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.204",
9
+ "version": "0.3.206",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
3
4
  import { dirname, join, resolve } from 'node:path';
4
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
6
  import { patchPiTreeRootOrdering } from './patch-pi-tree-root-ordering.mjs';
@@ -34,6 +35,14 @@ async function main() {
34
35
  console.warn('[crtr postinstall] pi tree-root-ordering patch failed (non-fatal):', error?.message ?? error);
35
36
  }
36
37
  if (process.env.CI) return;
38
+ // A published tarball always ships `dist/`; a source checkout being installed
39
+ // never does, and its runtime generation comes from an explicit
40
+ // `npm run build && npm run install-runtime` afterwards. Staging an unbuilt
41
+ // tree can only fail its smoke validation.
42
+ if (!existsSync(join(ROOT, 'dist', 'index.js'))) {
43
+ console.log('crouter source checkout installed. Run `npm run build && npm run install-runtime` to produce a runtime generation.');
44
+ return;
45
+ }
37
46
  try {
38
47
  await installRuntime();
39
48
  } catch (error) {