@north-light/crouter-api 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.
@@ -24,6 +24,8 @@ export interface BrokerErrorFrame {
24
24
  */
25
25
  export interface BrokerWelcomeSnapshot<M = unknown> {
26
26
  messages: M[];
27
+ /** Stable session-entry ids aligned 1:1 with `messages`. */
28
+ messageIds?: string[];
27
29
  state?: {
28
30
  isStreaming?: boolean;
29
31
  };
@@ -1,4 +1,14 @@
1
1
  import type { InboxTierDTO, IsoTime, NodeIdDTO } from './common.js';
2
+ /** One runtime card supplied by a caller: the runtime renders and escapes it
3
+ * exactly once, so a caller cannot emit a malformed card or smuggle markup
4
+ * into one. `kind` must be namespaced (contain `:`) — bare kinds are
5
+ * crouter's own closed vocabulary. */
6
+ export interface RuntimeCardRequest {
7
+ kind: string;
8
+ facts?: Record<string, string | number>;
9
+ /** Data, not markup. */
10
+ body?: string;
11
+ }
2
12
  /** `POST /v1/nodes/{id}/messages` body. */
3
13
  export interface SendMessageRequest {
4
14
  body: string;
@@ -16,6 +26,14 @@ export interface SendMessageRequest {
16
26
  * a `<situational-context>` block, never visible chat (`--situational-context`).
17
27
  * Immediate only; valid alone (no body). */
18
28
  situational_context?: string;
29
+ /** A runtime card that REPLACES the target's situational sidecar, delivered
30
+ * ahead of the body in the same turn and re-stated by the session-start
31
+ * bearings after a context refresh. Mutually exclusive with
32
+ * `situational_context`; valid alone (no body) on the durable path. */
33
+ situational_card?: RuntimeCardRequest;
34
+ /** One-shot runtime cards for this turn only — never persisted. Delivered in
35
+ * array order, after `situational_card` and ahead of the body. */
36
+ context_cards?: RuntimeCardRequest[];
19
37
  /** Raw JSON-schema string granting a one-off `submit` tool before delivery
20
38
  * (`--output-schema`). Immediate only. */
21
39
  output_schema?: string;
@@ -24,7 +42,10 @@ export interface SendMessageRequest {
24
42
  * instead of the durable inbox; a dormant or mid-revive target falls back to
25
43
  * the durable inbox + revive (watcher delivers post-boot). Plain immediate
26
44
  * body only — rejected with fresh/reopen/situational_context/
27
- * output_schema or tier 'deferred'. Absent durable inbox (unchanged). */
45
+ * output_schema or tier 'deferred'. Runtime cards ARE accepted: a
46
+ * card-bearing send is an ordinary human send that happens to carry context,
47
+ * and the live deliver frame places the cards ahead of the body in one turn.
48
+ * Absent → durable inbox (unchanged). */
28
49
  delivery?: 'interactive';
29
50
  }
30
51
  /** Result of an immediate message send. */
@@ -5,12 +5,13 @@ export interface CreateNodeRequest {
5
5
  prompt?: string;
6
6
  profile?: string;
7
7
  mode?: ModeDTO;
8
- /** The DIRECTORY CONTEXT the create came from not necessarily where the
9
- * node lands. It is what the daemon selects a profile from when the request
10
- * neither names one nor inherits one from a parent. */
8
+ /** The directory the create came from. It is what the daemon selects a
9
+ * profile from when the request neither names one nor inherits one from a
10
+ * parent, and for a node with no `pin_cwd` and no spawner to inherit from —
11
+ * where the node lands. */
11
12
  cwd?: string;
12
- /** Pin the node to this exact directory, overriding the profile home a node
13
- * otherwise runs in. See `crtr node new --cwd`. */
13
+ /** Pin the node to this exact directory, overriding both the spawner's
14
+ * directory and the launch cwd. See `crtr node new --cwd`. */
14
15
  pin_cwd?: string;
15
16
  /** Display name (tmux window + resume picker). Defaults to the kind. */
16
17
  name?: string;
@@ -187,6 +188,8 @@ export interface NodeMessagesPageDTO {
187
188
  node_id: NodeIdDTO;
188
189
  /** pi AgentMessage[] JSON, chronological ascending within this page. */
189
190
  messages: unknown[];
191
+ /** Stable session-entry ids aligned 1:1 with `messages`. */
192
+ message_ids?: string[];
190
193
  /** Opaque cursor toward older messages; null at the start of the session. */
191
194
  next_cursor: Cursor | null;
192
195
  captured_at: IsoTime;
@@ -3,6 +3,7 @@ export type { CrtrClientOptions } from './client.js';
3
3
  export { ApiError, isErrorBody } from './errors.js';
4
4
  export type { ErrorBody } from './errors.js';
5
5
  export { API_VERSION, routes } from './routes.js';
6
+ export * from '../shared/generated-context.js';
6
7
  export * from './dto/common.js';
7
8
  export * from './dto/health.js';
8
9
  export * from './dto/nodes.js';
package/dist/api/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  export { CrtrClient } from './client.js';
5
5
  export { ApiError, isErrorBody } from './errors.js';
6
6
  export { API_VERSION, routes } from './routes.js';
7
+ export * from '../shared/generated-context.js';
7
8
  export * from './dto/common.js';
8
9
  export * from './dto/health.js';
9
10
  export * from './dto/nodes.js';
@@ -0,0 +1,94 @@
1
+ /** Custom message carrying the node's session-start bearings. */
2
+ export declare const CONTEXT_INTRO_CUSTOM_TYPE = "crtr-context";
3
+ /** Custom message carrying an ambient situational-context update. */
4
+ export declare const SITUATIONAL_CONTEXT_CUSTOM_TYPE = "crtr-situational-context";
5
+ /** Custom message used when a context-size nudge waits for the next turn. */
6
+ export declare const CONTEXT_NUDGE_CUSTOM_TYPE = "crtr-context-nudge";
7
+ /** Custom message that opens a review companion's visible transcript. */
8
+ export declare const REVIEW_BOUNDARY_CUSTOM_TYPE = "crtr-review-boundary";
9
+ /** Opening text of a pre-envelope fresh-revive kickoff user message. */
10
+ export declare const REVIVE_KICKOFF_SENTINEL = "You have been revived fresh after a context refresh";
11
+ /** Generic completion mandate issued by the terminal-node stop guard. */
12
+ export declare const STALL_REPROMPT: string;
13
+ /** Static recovery prompts shared by the broker producer and display classifier. */
14
+ export declare 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.";
15
+ export declare 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.";
16
+ export declare 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.";
17
+ export type ModelFallbackRecoveryReason = 'credential' | 'not-found';
18
+ /** Format the stop guard's dynamic structured-output mandate. */
19
+ export declare function formatStructuredOutputReprompt(schema: string): string;
20
+ /** Keep model-fallback guidance editable without making it a reader contract. */
21
+ export declare function formatModelFallbackRecovery(previousModel: string, nextModel: string, reason: ModelFallbackRecoveryReason): string;
22
+ export interface GeneratedContextMessageLike {
23
+ role?: string;
24
+ customType?: string;
25
+ content?: unknown;
26
+ }
27
+ export type CardEntryDisposition = 'report' | 'human-answer' | 'human-canceled';
28
+ export interface CardEntry {
29
+ disposition: CardEntryDisposition;
30
+ kind: string;
31
+ body: string;
32
+ ref?: string;
33
+ }
34
+ export interface CardSender {
35
+ id: string;
36
+ name?: string;
37
+ updates: number;
38
+ finished: boolean;
39
+ entries: CardEntry[];
40
+ }
41
+ /** An already-resolved inbox entry supplied to the pure digest formatter. */
42
+ export interface InboxCardEntry {
43
+ kind: string;
44
+ body: string;
45
+ ref?: string;
46
+ disposition?: CardEntryDisposition;
47
+ }
48
+ /** An already-resolved sender section supplied to the pure digest formatter. */
49
+ export interface InboxCardSection {
50
+ id: string;
51
+ name?: string;
52
+ entries: readonly InboxCardEntry[];
53
+ updates?: number;
54
+ finished?: boolean;
55
+ }
56
+ export interface GeneratedCard {
57
+ kind: string;
58
+ facts: Readonly<Record<string, string>>;
59
+ body: string;
60
+ senders: CardSender[];
61
+ label: string;
62
+ summary: string;
63
+ expandable: boolean;
64
+ }
65
+ type CardSummary = (facts: Readonly<Record<string, string>>) => string;
66
+ export interface CardKindDefinition {
67
+ label: string;
68
+ summary: CardSummary;
69
+ expandable: boolean;
70
+ customTypes?: Readonly<Record<string, Partial<Pick<CardKindDefinition, 'label' | 'summary' | 'expandable'>>>>;
71
+ }
72
+ /** The closed crouter vocabulary, plus custom-role aliases for the same cards. */
73
+ export declare const KIND_TABLE: Readonly<Record<string, CardKindDefinition>>;
74
+ /** Contribute presentation for namespaced kinds this process will render.
75
+ * Idempotent and last-write-wins, so a re-imported module cannot fail a boot.
76
+ * A process that never registers is not degraded: an unregistered kind still
77
+ * parses and only falls back to the generic label and summary. */
78
+ export declare function registerCardKinds(kinds: Readonly<Record<string, CardKindDefinition>>): void;
79
+ /** Plain text from a generated message's string or text-block content. */
80
+ export declare function generatedContextText(message: GeneratedContextMessageLike): string;
81
+ /** Wrap a user-role runtime message in its whole-message card envelope. */
82
+ export declare function formatCard(kind: string, facts: Record<string, string | number | boolean | undefined>, body: string): string;
83
+ /** Wrap a card whose body is DATA, not markup — the only escape on the API
84
+ * path, so a caller can neither emit a malformed card nor smuggle markup into
85
+ * one. `formatCard` keeps its raw body for the trusted crouter producers that
86
+ * deliberately nest markup (bearings blocks, the inbox `<from>`/`<entry>`
87
+ * grammar `parseInboxBody` depends on). */
88
+ export declare function formatDataCard(kind: string, facts: Record<string, string | number | boolean | undefined>, body: string): string;
89
+ /** Parse a runtime envelope, with pre-envelope customType compatibility. */
90
+ export declare function parseCard(message: GeneratedContextMessageLike): GeneratedCard | null;
91
+ /** Format a complete inbox card from report bodies resolved by the caller. */
92
+ export declare function formatInboxCard(sections: readonly InboxCardSection[]): string;
93
+ export declare function isLegacyRuntimeText(text: string): boolean;
94
+ export {};
@@ -0,0 +1,376 @@
1
+ // Runtime-card grammar for crouter-authored context messages.
2
+ //
3
+ // Every runtime message carries one whole-message envelope. customType only
4
+ // controls delivery and visibility; parsing stays independent of runtime and clients.
5
+ /** Custom message carrying the node's session-start bearings. */
6
+ export const CONTEXT_INTRO_CUSTOM_TYPE = 'crtr-context';
7
+ /** Custom message carrying an ambient situational-context update. */
8
+ export const SITUATIONAL_CONTEXT_CUSTOM_TYPE = 'crtr-situational-context';
9
+ /** Custom message used when a context-size nudge waits for the next turn. */
10
+ export const CONTEXT_NUDGE_CUSTOM_TYPE = 'crtr-context-nudge';
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. */
14
+ export const REVIVE_KICKOFF_SENTINEL = 'You have been revived fresh after a context refresh';
15
+ /** Generic completion mandate issued by the terminal-node stop guard. */
16
+ export const STALL_REPROMPT = "You've stopped but you're not waiting on anyone and haven't finished. " +
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.";
18
+ /** Static recovery prompts shared by the broker producer and display classifier. */
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.';
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.';
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.';
22
+ const REVIEW_APPROVAL_OPEN = '<crtr-review-approval>';
23
+ const REVIEW_APPROVAL_CLOSE = '</crtr-review-approval>';
24
+ const MODEL_FALLBACK_RECOVERY_OPEN = '<model-fallback-recovery>';
25
+ const MODEL_FALLBACK_RECOVERY_CLOSE = '</model-fallback-recovery>';
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';
27
+ const STRUCTURED_OUTPUT_REPROMPT_SUFFIX = '\n```';
28
+ /** Format the stop guard's dynamic structured-output mandate. */
29
+ export function formatStructuredOutputReprompt(schema) {
30
+ return `${STRUCTURED_OUTPUT_REPROMPT_PREFIX}${schema}${STRUCTURED_OUTPUT_REPROMPT_SUFFIX}`;
31
+ }
32
+ /** Keep model-fallback guidance editable without making it a reader contract. */
33
+ export function formatModelFallbackRecovery(previousModel, nextModel, reason) {
34
+ return reason === 'credential'
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.`
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.`;
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';
70
+ }
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;
170
+ }
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('');
182
+ }
183
+ /** Plain text from a generated message's string or text-block content. */
184
+ export function generatedContextText(message) {
185
+ if (typeof message.content === 'string')
186
+ return message.content;
187
+ if (!Array.isArray(message.content))
188
+ return '';
189
+ return message.content
190
+ .filter((block) => typeof block === 'object'
191
+ && block !== null
192
+ && block.type === 'text'
193
+ && typeof block.text === 'string')
194
+ .map((block) => block.text)
195
+ .join('');
196
+ }
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) {
214
+ const senders = [];
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
+ });
247
+ }
248
+ return senders;
249
+ }
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
+ };
303
+ }
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);
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');
332
+ function isPersonaTransition(body) {
333
+ return body.startsWith(`${PERSONA_TRANSITION_OPEN}\n`) && body.endsWith(`\n${PERSONA_TRANSITION_CLOSE}`);
334
+ }
335
+ function isReviewApproval(body) {
336
+ return body.startsWith(`${REVIEW_APPROVAL_OPEN}\n`) && body.endsWith(`\n${REVIEW_APPROVAL_CLOSE}`);
337
+ }
338
+ function isStructuredOutputReprompt(body) {
339
+ if (!body.startsWith(STRUCTURED_OUTPUT_REPROMPT_PREFIX) || !body.endsWith(STRUCTURED_OUTPUT_REPROMPT_SUFFIX)) {
340
+ return false;
341
+ }
342
+ const schema = body.slice(STRUCTURED_OUTPUT_REPROMPT_PREFIX.length, -STRUCTURED_OUTPUT_REPROMPT_SUFFIX.length);
343
+ try {
344
+ JSON.parse(schema);
345
+ return true;
346
+ }
347
+ catch {
348
+ return false;
349
+ }
350
+ }
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\.$/;
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\.$/;
353
+ function isModelFallbackRecovery(body) {
354
+ if (!body.startsWith(`${MODEL_FALLBACK_RECOVERY_OPEN}\n`) || !body.endsWith(`\n${MODEL_FALLBACK_RECOVERY_CLOSE}`)) {
355
+ return false;
356
+ }
357
+ const guidance = body.slice(MODEL_FALLBACK_RECOVERY_OPEN.length + 1, -(MODEL_FALLBACK_RECOVERY_CLOSE.length + 1));
358
+ return MODEL_CREDENTIAL_RECOVERY_RE.test(guidance) || MODEL_NOT_FOUND_RECOVERY_RE.test(guidance);
359
+ }
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);
366
+ }
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);
376
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter-api",
3
- "version": "0.3.204",
3
+ "version": "0.3.206",
4
4
  "description": "Typed crtrd /v1 API contract — DTOs, route builders, the error contract, and the CrtrClient. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/api/index.js",
@@ -11,6 +11,12 @@
11
11
  "import": "./dist/api/index.js",
12
12
  "require": "./dist/api/index.js",
13
13
  "default": "./dist/api/index.js"
14
+ },
15
+ "./cards": {
16
+ "types": "./dist/shared/generated-context.d.ts",
17
+ "import": "./dist/shared/generated-context.js",
18
+ "require": "./dist/shared/generated-context.js",
19
+ "default": "./dist/shared/generated-context.js"
14
20
  }
15
21
  },
16
22
  "files": [