@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,8 +1,11 @@
1
+ import type { InboxDisposition } from './inbox.js';
1
2
  export type PushKind = 'update' | 'urgent' | 'final';
2
3
  export interface PushOpts {
3
4
  kind: PushKind;
4
5
  body: string;
5
6
  from?: string;
7
+ /** Attribution snapshotted by the human-ticket settlement paths; absent means an ordinary report. */
8
+ disposition?: InboxDisposition;
6
9
  }
7
10
  export interface PushResult {
8
11
  reportPath: string;
@@ -28,6 +31,9 @@ export declare function pushUrgent(nodeId: string, body: string, opts?: {
28
31
  }): Promise<PushResult>;
29
32
  export declare function pushFinal(nodeId: string, body: string, opts?: {
30
33
  from?: string;
34
+ disposition?: InboxDisposition;
31
35
  }): Promise<PushResult>;
32
36
  /** Re-fan a previously committed final to its ticket origin without finalizing again. */
33
- export declare function redeliverFinalReport(nodeId: string, originNodeId: string, reportBasename: string, body: string): PushResult;
37
+ export declare function redeliverFinalReport(nodeId: string, originNodeId: string, reportBasename: string, body: string, opts?: {
38
+ disposition?: InboxDisposition;
39
+ }): PushResult;
@@ -2,7 +2,7 @@
2
2
  import { existsSync, linkSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { openDb, reportsDir, subscribersOf, cancelCronsOnWake } from '../canvas/index.js';
5
+ import { openDb, reportsDir, subscribersOf, cancelCronsOnWake, getNode } from '../canvas/index.js';
6
6
  import { appendInbox } from './inbox.js';
7
7
  import { appendPassive } from './passive.js';
8
8
  import { operationIdContext } from '../events/operation-id.js';
@@ -54,13 +54,13 @@ function firstLine(text, maxLen = 80) {
54
54
  function tierFor(kind) {
55
55
  return kind === 'urgent' ? 'urgent' : 'normal';
56
56
  }
57
- function deliver(subscribers, from, kind, reportRef, body) {
57
+ function deliver(subscribers, from, fromName, kind, reportRef, body, disposition) {
58
58
  const deliveredTo = [];
59
59
  const failedTo = [];
60
60
  const label = firstLine(body);
61
61
  for (const sub of subscribers) {
62
62
  try {
63
- const entry = { from, tier: tierFor(kind), kind, ref: reportRef, label };
63
+ const entry = { from, ...(fromName === undefined ? {} : { from_name: fromName }), tier: tierFor(kind), kind, ref: reportRef, label, ...(disposition === undefined ? {} : { disposition }) };
64
64
  if (sub.active)
65
65
  appendInbox(sub.node_id, entry);
66
66
  else
@@ -110,6 +110,7 @@ async function pushWithContext(nodeId, opts) {
110
110
  throw new Error(`push: empty nodeId (kind=${opts.kind}) — a report must belong to a node`);
111
111
  const staged = stageReport(nodeId, opts.kind, opts.body);
112
112
  const from = opts.from ?? nodeId;
113
+ const fromName = getNode(from)?.name;
113
114
  if (opts.kind === 'final') {
114
115
  const { subscribers } = commitCanonicalFinal(nodeId, staged);
115
116
  // The finalization latch ends every wait this node was holding. It flips the
@@ -120,14 +121,14 @@ async function pushWithContext(nodeId, opts) {
120
121
  // cancel-on-wake rows anchored here go; standing crons survive. Outside
121
122
  // commitCanonicalFinal's transaction — it takes its own write boundary.
122
123
  cancelCronsOnWake(nodeId);
123
- const { deliveredTo, failedTo } = deliver(subscribers, from, opts.kind, `${nodeId}:reports/${staged.basename}`, opts.body);
124
+ const { deliveredTo, failedTo } = deliver(subscribers, from, fromName, opts.kind, `${nodeId}:reports/${staged.basename}`, opts.body, opts.disposition);
124
125
  if (failedTo.length > 0)
125
126
  throw new FinalDeliveryError(staged.finalPath, deliveredTo, failedTo);
126
127
  return { reportPath: staged.finalPath, deliveredTo };
127
128
  }
128
129
  try {
129
130
  install(staged);
130
- const { deliveredTo, failedTo } = deliver(subscribersOf(nodeId), from, opts.kind, `${nodeId}:reports/${staged.basename}`, opts.body);
131
+ const { deliveredTo, failedTo } = deliver(subscribersOf(nodeId), from, fromName, opts.kind, `${nodeId}:reports/${staged.basename}`, opts.body, opts.disposition);
131
132
  if (failedTo.length > 0)
132
133
  throw new Error(`report published at ${staged.finalPath}; delivery failed for ${failedTo.join(', ')}`);
133
134
  return { reportPath: staged.finalPath, deliveredTo };
@@ -151,11 +152,11 @@ export async function pushFinal(nodeId, body, opts) {
151
152
  return push(nodeId, { kind: 'final', body, ...opts });
152
153
  }
153
154
  /** Re-fan a previously committed final to its ticket origin without finalizing again. */
154
- export function redeliverFinalReport(nodeId, originNodeId, reportBasename, body) {
155
+ export function redeliverFinalReport(nodeId, originNodeId, reportBasename, body, opts) {
155
156
  if (reportBasename === '' || reportBasename.includes('/') || reportBasename.includes('\\')) {
156
157
  throw new Error(`invalid canonical final report basename: ${reportBasename}`);
157
158
  }
158
- const { deliveredTo, failedTo } = deliver([{ node_id: originNodeId, active: true, created: '' }], nodeId, 'final', `${nodeId}:reports/${reportBasename}`, body);
159
+ const { deliveredTo, failedTo } = deliver([{ node_id: originNodeId, active: true, created: '' }], nodeId, getNode(nodeId)?.name, 'final', `${nodeId}:reports/${reportBasename}`, body, opts?.disposition);
159
160
  const reportPath = join(reportsDir(nodeId), reportBasename);
160
161
  if (failedTo.length > 0)
161
162
  throw new FinalDeliveryError(reportPath, deliveredTo, failedTo);
@@ -1,6 +1,7 @@
1
1
  import type { OperationId } from '../events/types.js';
2
2
  export type InboxTier = 'critical' | 'urgent' | 'normal' | 'deferred';
3
3
  export type InboxKind = 'update' | 'urgent' | 'final' | 'message' | 'completed';
4
+ export type InboxDisposition = 'human-answer' | 'human-canceled';
4
5
  export type InboxEntryId = string & {
5
6
  readonly __inboxEntryId: unique symbol;
6
7
  };
@@ -18,6 +19,8 @@ export interface InboxEntry {
18
19
  ts: string;
19
20
  /** Node id of the sender, or null for system-generated entries. */
20
21
  from: string | null;
22
+ /** Display-name snapshot captured when a node created the entry. */
23
+ from_name?: string;
21
24
  /** Priority band for the receiver's attention. */
22
25
  tier: InboxTier;
23
26
  /** Semantic kind of the push event. */
@@ -30,6 +33,8 @@ export interface InboxEntry {
30
33
  label: string;
31
34
  /** Arbitrary structured payload for non-push message entries. */
32
35
  data?: Record<string, unknown>;
36
+ /** Set only by the human-ticket settlement paths; absent means an ordinary report. */
37
+ disposition?: InboxDisposition;
33
38
  }
34
39
  export type InboxEntryInput = Omit<InboxEntry, 'entry_id' | 'ts' | 'operation_id'> & {
35
40
  operation_id?: string;
@@ -70,15 +75,14 @@ export declare function clipBody(body: string): {
70
75
  text: string;
71
76
  clipped: boolean;
72
77
  };
73
- /**
74
- * Render many unread inbox pointers into one compact digest string.
75
- *
76
- * Format (per sender group):
77
- * From <sender> — <N> update(s):
78
- * [<kind>]
79
- * <short report body>
80
- * (report: <absolute report path>)
81
- *
82
- * Large reports remain canonical pointers and add one shared dereference hint.
83
- */
84
- export declare function coalesce(entries: InboxEntry[]): string;
78
+ /** One deliverable user message: either a person's verbatim words or the whole
79
+ * `<runtime kind="inbox">` envelope. Never both — a reader parses an envelope
80
+ * only as a whole message, so mixing them attributes the card to the person. */
81
+ export interface InboxDelivery {
82
+ kind: 'human' | 'card';
83
+ text: string;
84
+ entries: readonly InboxEntry[];
85
+ }
86
+ /** Split unread inbox pointers into ordered deliveries: each human entry
87
+ * verbatim, and every other sender in one `<runtime kind="inbox">` card. */
88
+ export declare function coalesce(entries: InboxEntry[]): readonly InboxDelivery[];
@@ -18,6 +18,7 @@ import { resolveRef } from '../canvas/history.js';
18
18
  import { emitEvent } from '../events/emit.js';
19
19
  import { errorClassFromError } from '../events/errors.js';
20
20
  import { generateOperationId, operationIdContext, validateOperationId } from '../events/operation-id.js';
21
+ import { formatInboxCard } from '../../shared/generated-context.js';
21
22
  const INBOX_ENTRY_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
22
23
  function validateInboxEntryId(value) {
23
24
  if (typeof value !== 'string' || !INBOX_ENTRY_ID.test(value))
@@ -311,8 +312,8 @@ export function clipBody(body) {
311
312
  }
312
313
  return { text: text.trimEnd(), clipped };
313
314
  }
314
- /** Resolve and inline a short pushed report. The report's absolute path remains
315
- * visible so the durable artifact is still available without a second command. */
315
+ /** Resolve a short pushed report body for inlining. Larger reports stay
316
+ * pointers, carried by the entry's `ref` attribute alone. */
316
317
  function inlineReport(e) {
317
318
  if (e.ref === undefined || e.ref.startsWith('/'))
318
319
  return null;
@@ -320,7 +321,7 @@ function inlineReport(e) {
320
321
  const report = resolveRef(e.ref);
321
322
  if (report?.source !== 'report' || report.path === null || report.body.length >= INLINE_REPORT_MAX_CHARS)
322
323
  return null;
323
- return { body: report.body, path: report.path };
324
+ return report.body;
324
325
  }
325
326
  catch {
326
327
  // A malformed or unavailable history ref remains a pointer in the digest.
@@ -328,55 +329,42 @@ function inlineReport(e) {
328
329
  }
329
330
  }
330
331
  /**
331
- * Render one entry's digest line(s).
332
+ * One entry's card body.
332
333
  *
333
- * A short pushed report is inlined as its own body, followed by a pointer to
334
- * the durable artifact. The label is NOT repeated above it: a report's label is
335
- * just its first line (feed.ts `firstLine`), so printing both reads as a
336
- * truncated preview stacked on the full text it previews. Larger reports stay
337
- * canonical history pointers. Direct messages retain their bounded
338
- * inline-body/spill behavior.
334
+ * A short pushed report is inlined as its own body; the label is NOT repeated
335
+ * above it, because a report's label is just its first line (feed.ts
336
+ * `firstLine`), so carrying both reads as a truncated preview stacked on the
337
+ * full text it previews. A larger report has only its label, with `ref`
338
+ * carrying the pointer. Direct messages keep their bounded inline-body/spill
339
+ * behavior.
339
340
  */
340
- function renderEntry(e) {
341
+ function entryBody(e) {
341
342
  const report = inlineReport(e);
342
- if (report !== null) {
343
- const indented = report.body.trimEnd().split('\n').map((l) => ` ${l}`).join('\n');
344
- return {
345
- text: ` [${e.kind}]\n${indented}\n (report: ${report.path})`,
346
- hasCanonicalRef: false,
347
- };
348
- }
343
+ if (report !== null)
344
+ return report.trimEnd();
349
345
  const body = inlineBody(e);
350
- if (body === '') {
351
- const hasCanonicalRef = e.ref !== undefined && !e.ref.startsWith('/');
352
- return {
353
- text: e.ref !== undefined
354
- ? ` [${e.kind}] ${e.label} (ref: ${e.ref})`
355
- : ` [${e.kind}] ${e.label}`,
356
- hasCanonicalRef,
357
- };
358
- }
346
+ if (body === '')
347
+ return e.label;
359
348
  const { text, clipped } = clipBody(body);
360
- const indented = text.split('\n').map((l) => ` ${l}`).join('\n');
361
- const more = e.ref !== undefined
362
- ? `\n … (full body: ${e.ref})`
363
- : clipped ? '\n … (body clipped)' : '';
364
- return { text: ` [${e.kind}]\n${indented}${more}`, hasCanonicalRef: false };
349
+ return clipped && e.ref === undefined ? `${text}\n… (body clipped)` : text;
365
350
  }
366
- /**
367
- * Render many unread inbox pointers into one compact digest string.
368
- *
369
- * Format (per sender group):
370
- * From <sender> — <N> update(s):
371
- * [<kind>]
372
- * <short report body>
373
- * (report: <absolute report path>)
374
- *
375
- * Large reports remain canonical pointers and add one shared dereference hint.
376
- */
351
+ function cardEntry(e) {
352
+ return {
353
+ kind: e.kind,
354
+ body: entryBody(e),
355
+ ...(e.ref === undefined ? {} : { ref: e.ref }),
356
+ ...(e.disposition === undefined ? {} : { disposition: e.disposition }),
357
+ };
358
+ }
359
+ /** The sender's display name as its last entry snapshotted it. */
360
+ function sectionName(items) {
361
+ return items.map((item) => item.from_name).filter((name) => name !== undefined && name !== '').at(-1);
362
+ }
363
+ /** Split unread inbox pointers into ordered deliveries: each human entry
364
+ * verbatim, and every other sender in one `<runtime kind="inbox">` card. */
377
365
  export function coalesce(entries) {
378
366
  if (entries.length === 0)
379
- return '(inbox empty)';
367
+ return [];
380
368
  // Group by `from` (null → 'system').
381
369
  const groups = new Map();
382
370
  for (const e of entries) {
@@ -385,27 +373,39 @@ export function coalesce(entries) {
385
373
  groups.set(key, []);
386
374
  groups.get(key).push(e);
387
375
  }
388
- let hasCanonicalRef = false;
376
+ // Human words stay outside the card, so a batch mixing both keeps the card
377
+ // where the first node sender fell rather than reordering the digest.
378
+ const deliveries = [];
389
379
  const sections = [];
380
+ let cardSlot = -1;
390
381
  for (const [sender, items] of groups) {
391
382
  // A human typing directly into a node's box is a CONVERSATION TURN, not a
392
383
  // notification the node is being woken to triage. Deliver those words
393
- // verbatim as the woken turn's user message — no "From … — N updates"
394
- // digest framing, no `[message]` kind tag. This makes the dormant path
395
- // identical to the live path (type "hi" → the node just reads "hi").
396
- // Every other sender (a child push, a system notice) keeps the digest.
384
+ // verbatim as the woken turn's user message — no card, no `<from>` section.
385
+ // This makes the dormant path identical to the live path (type "hi" → the
386
+ // node just reads "hi"). Every other sender is digest content.
397
387
  if (sender === 'human') {
398
388
  for (const e of items) {
399
389
  const body = inlineBody(e);
400
- sections.push(body === '' ? e.label : body);
390
+ deliveries.push({ kind: 'human', text: body === '' ? e.label : body, entries: [e] });
401
391
  }
402
392
  continue;
403
393
  }
404
- const lines = items.map(renderEntry);
405
- if (lines.some((line) => line.hasCanonicalRef))
406
- hasCanonicalRef = true;
407
- sections.push(`From ${sender} — ${items.length} update${items.length === 1 ? '' : 's'}:\n${lines.map((line) => line.text).join('\n')}`);
394
+ if (cardSlot < 0) {
395
+ cardSlot = deliveries.length;
396
+ deliveries.push({ kind: 'card', text: '', entries: [] });
397
+ }
398
+ const name = sectionName(items);
399
+ sections.push({ id: sender, ...(name === undefined ? {} : { name }), entries: items.map(cardEntry) });
400
+ }
401
+ if (cardSlot >= 0) {
402
+ // Card sections group by sender, so its entry list comes from the input
403
+ // array instead, keeping physical inbox order.
404
+ deliveries[cardSlot] = {
405
+ kind: 'card',
406
+ text: formatInboxCard(sections),
407
+ entries: entries.filter((e) => (e.from ?? 'system') !== 'human'),
408
+ };
408
409
  }
409
- const digest = sections.join('\n\n');
410
- return hasCanonicalRef ? `${digest}\n\nDereference a ref with \`crtr canvas history read <ref>\`.` : digest;
410
+ return deliveries;
411
411
  }
@@ -1,11 +1,16 @@
1
- /** One element of the tree an authored page returns. `tag` is the JSX identifier the
2
- * author wrote: a registry component (`UserQuestion`), a display component (`Card`),
3
- * or a plain element (`div`). Helper components are expanded before they get here. */
4
- export interface PageNode {
1
+ /** One node in the rendered tree. Helper components are expanded before this point. */
2
+ export type PageNode = PageElementNode | PageTextNode;
3
+ export interface PageElementNode {
4
+ kind: 'element';
5
+ /** Registry component (`UserQuestion`), display component (`Card`), or plain element (`div`). */
5
6
  tag: string;
6
7
  props: Record<string, unknown>;
7
8
  children: PageNode[];
8
9
  }
10
+ export interface PageTextNode {
11
+ kind: 'text';
12
+ text: string;
13
+ }
9
14
  /** Evaluate the compiled page module and return the element tree its default export renders.
10
15
  *
11
16
  * Each in-scope component name is bound to itself, so `<UserQuestion>` renders as the tag
@@ -84,6 +84,8 @@ export function evaluatePageTree(code, tags) {
84
84
  throw new PageAuthoringError('page nests deeper than a page can render; check for a component that renders itself');
85
85
  if (Date.now() > deadline)
86
86
  throw new PageAuthoringError('page took too long to render; check for a component that never finishes');
87
+ if (typeof value === 'string' || typeof value === 'number')
88
+ return [{ kind: 'text', text: String(value) }];
87
89
  if (Array.isArray(value))
88
90
  return value.flatMap((item) => normalize(item, depth + 1));
89
91
  if (!isRawElement(value))
@@ -95,7 +97,7 @@ export function evaluatePageTree(code, tags) {
95
97
  if (typeof type !== 'string')
96
98
  return nested;
97
99
  const { children: _children, ...rest } = props;
98
- return [{ tag: type, props: rest, children: nested }];
100
+ return [{ kind: 'element', tag: type, props: rest, children: nested }];
99
101
  };
100
102
  try {
101
103
  return normalize(rendered, 0);
@@ -0,0 +1,3 @@
1
+ import type { PageManifest } from './page-schema.js';
2
+ /** Project the authored display JSX only. Manifest slots remain the authoritative terminal rendering for questions, inputs, charts, and product components. */
3
+ export declare function projectPageDisplayMarkdown(document: string, manifest: PageManifest): string;
@@ -0,0 +1,295 @@
1
+ import { BUILTIN_KIND_BY_TAG, BUILTIN_PAGE_COMPONENT_TAGS, DISPLAY_PAGE_COMPONENT_TAGS, pageComponentTag } from './page-catalog.js';
2
+ import { evaluatePageTree } from './page-eval.js';
3
+ import { PageAuthoringError } from './page-errors.js';
4
+ import { compilePage } from './page.js';
5
+ const SLOT_TAGS = new Set(Object.keys(BUILTIN_KIND_BY_TAG));
6
+ const BLOCK_TAGS = new Set([
7
+ 'Page', 'Step',
8
+ 'Card', 'CardHeader', 'CardTitle', 'CardDescription', 'CardContent', 'CardFooter', 'CardAction',
9
+ 'Separator', 'Progress', 'Alert', 'AlertTitle', 'AlertDescription',
10
+ 'Table', 'TableHeader', 'TableBody', 'TableFooter', 'TableRow', 'TableHead', 'TableCell', 'TableCaption',
11
+ 'Tabs', 'TabsList', 'TabsTrigger', 'TabsContent',
12
+ 'Accordion', 'AccordionItem', 'AccordionTrigger', 'AccordionContent',
13
+ 'address', 'article', 'aside', 'blockquote', 'details', 'dialog', 'div', 'dl', 'dt', 'dd', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
14
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'ul',
15
+ ]);
16
+ function isElement(node) {
17
+ return node.kind === 'element';
18
+ }
19
+ function markdownText(value) {
20
+ return value.replace(/\s+/g, ' ').replace(/([\\`*_[\]<>#])/g, '\\$1');
21
+ }
22
+ function cleanInline(value) {
23
+ return value.replace(/[ \t]+\n/g, '\n').replace(/\n[ \t]+/g, '\n').trim();
24
+ }
25
+ function rawText(nodes, context) {
26
+ let out = '';
27
+ for (const node of nodes) {
28
+ if (!isElement(node)) {
29
+ out += node.text;
30
+ continue;
31
+ }
32
+ if (context.slotTags.has(node.tag))
33
+ continue;
34
+ out += rawText(node.children, context);
35
+ }
36
+ return out;
37
+ }
38
+ function inlineCode(value) {
39
+ const text = value.replace(/\s+/g, ' ').trim();
40
+ if (text === '')
41
+ return '';
42
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
43
+ const fence = '`'.repeat(longest + 1);
44
+ const pad = /^`|`$|^ | $/.test(text) ? ' ' : '';
45
+ return `${fence}${pad}${text}${pad}${fence}`;
46
+ }
47
+ function inline(nodes, context) {
48
+ let out = '';
49
+ for (const node of nodes) {
50
+ if (!isElement(node)) {
51
+ out += markdownText(node.text);
52
+ continue;
53
+ }
54
+ if (context.slotTags.has(node.tag))
55
+ continue;
56
+ const body = () => inline(node.children, context);
57
+ switch (node.tag) {
58
+ case 'br':
59
+ out += '\n';
60
+ break;
61
+ case 'code':
62
+ out += inlineCode(rawText(node.children, context));
63
+ break;
64
+ case 'strong':
65
+ case 'b': {
66
+ const text = cleanInline(body());
67
+ if (text !== '')
68
+ out += `**${text}**`;
69
+ break;
70
+ }
71
+ case 'em':
72
+ case 'i': {
73
+ const text = cleanInline(body());
74
+ if (text !== '')
75
+ out += `*${text}*`;
76
+ break;
77
+ }
78
+ case 'del':
79
+ case 's': {
80
+ const text = cleanInline(body());
81
+ if (text !== '')
82
+ out += `~~${text}~~`;
83
+ break;
84
+ }
85
+ case 'a': {
86
+ const text = cleanInline(body());
87
+ const href = node.props['href'];
88
+ out += typeof href === 'string' && href !== ''
89
+ ? `[${text === '' ? markdownText(href) : text}](${href.replace(/([\\()])/g, '\\$1')})`
90
+ : text;
91
+ break;
92
+ }
93
+ case 'img': {
94
+ const alt = typeof node.props['alt'] === 'string' ? markdownText(node.props['alt']) : 'image';
95
+ const src = node.props['src'];
96
+ out += typeof src === 'string' && src !== '' ? `![${alt}](${src.replace(/([\\()])/g, '\\$1')})` : alt;
97
+ break;
98
+ }
99
+ case 'Badge': {
100
+ const text = cleanInline(body());
101
+ if (text !== '')
102
+ out += `**${text}**`;
103
+ break;
104
+ }
105
+ default:
106
+ out += body();
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+ function quote(block) {
112
+ return block.split('\n').map((line) => line === '' ? '>' : `> ${line}`).join('\n');
113
+ }
114
+ function languageFor(node) {
115
+ const candidates = [node.props['className']];
116
+ const code = node.children.find((child) => isElement(child) && child.tag === 'code');
117
+ if (code !== undefined)
118
+ candidates.push(code.props['className']);
119
+ for (const candidate of candidates) {
120
+ if (typeof candidate !== 'string')
121
+ continue;
122
+ const language = /(?:^|\s)language-([A-Za-z0-9_+-]+)/.exec(candidate)?.[1];
123
+ if (language !== undefined)
124
+ return language;
125
+ }
126
+ return '';
127
+ }
128
+ function codeBlock(node, context) {
129
+ const source = rawText(node.children, context).replace(/^\n/, '').replace(/\n$/, '');
130
+ if (source === '')
131
+ return '';
132
+ const longest = Math.max(0, ...[...source.matchAll(/`+/g)].map((match) => match[0].length));
133
+ const fence = '`'.repeat(Math.max(3, longest + 1));
134
+ return `${fence}${languageFor(node)}\n${source}\n${fence}`;
135
+ }
136
+ function list(node, ordered, context) {
137
+ const items = node.children.filter((child) => isElement(child) && child.tag === 'li');
138
+ return items.map((item, index) => {
139
+ const marker = ordered ? `${index + 1}.` : '-';
140
+ const body = flow(item.children, context);
141
+ if (body === '')
142
+ return '';
143
+ const continuation = ' '.repeat(marker.length + 1);
144
+ return body.split('\n').map((line, lineIndex) => lineIndex === 0 ? `${marker} ${line}` : line === '' ? '' : `${continuation}${line}`).join('\n');
145
+ }).filter((item) => item !== '').join('\n');
146
+ }
147
+ function table(node, context) {
148
+ const rows = [];
149
+ let caption = '';
150
+ const visit = (nodes) => {
151
+ for (const child of nodes) {
152
+ if (!isElement(child) || context.slotTags.has(child.tag))
153
+ continue;
154
+ if (child.tag === 'TableCaption' || child.tag === 'caption') {
155
+ caption = cleanInline(inline(child.children, context));
156
+ continue;
157
+ }
158
+ if (child.tag === 'TableRow' || child.tag === 'tr') {
159
+ const cells = child.children.filter((cell) => isElement(cell) && ['TableHead', 'TableCell', 'th', 'td'].includes(cell.tag));
160
+ if (cells.length > 0) {
161
+ rows.push({
162
+ header: cells.every((cell) => cell.tag === 'TableHead' || cell.tag === 'th'),
163
+ cells: cells.map((cell) => cleanInline(inline(cell.children, context)).replaceAll('|', '\\|').replaceAll('\n', '<br>')),
164
+ });
165
+ }
166
+ continue;
167
+ }
168
+ visit(child.children);
169
+ }
170
+ };
171
+ visit(node.children);
172
+ if (rows.length === 0)
173
+ return flow(node.children, context);
174
+ const headerIndex = rows.findIndex((row) => row.header);
175
+ const header = headerIndex === -1 ? rows[0] : rows[headerIndex];
176
+ const data = rows.filter((_row, index) => index !== (headerIndex === -1 ? 0 : headerIndex));
177
+ const width = Math.max(header.cells.length, ...data.map((row) => row.cells.length));
178
+ const cells = (row) => Array.from({ length: width }, (_value, index) => row[index] ?? '');
179
+ const lines = [
180
+ `| ${cells(header.cells).join(' | ')} |`,
181
+ `| ${Array.from({ length: width }, () => '---').join(' | ')} |`,
182
+ ...data.map((row) => `| ${cells(row.cells).join(' | ')} |`),
183
+ ];
184
+ if (caption !== '')
185
+ lines.push('', `*${caption}*`);
186
+ return lines.join('\n');
187
+ }
188
+ function block(node, context) {
189
+ if (context.slotTags.has(node.tag))
190
+ return '';
191
+ const contents = () => flow(node.children, context);
192
+ switch (node.tag) {
193
+ case 'Page': return contents();
194
+ case 'Step': {
195
+ const title = node.props['title'];
196
+ const body = contents();
197
+ const heading = typeof title === 'string' && title.trim() !== '' ? `## ${markdownText(title.trim())}` : '';
198
+ return [heading, body].filter((part) => part !== '').join('\n\n');
199
+ }
200
+ case 'p': return cleanInline(inline(node.children, context));
201
+ case 'h1':
202
+ case 'h2':
203
+ case 'h3':
204
+ case 'h4':
205
+ case 'h5':
206
+ case 'h6': {
207
+ const text = cleanInline(inline(node.children, context));
208
+ const level = Math.min(6, Number(node.tag[1]) + 1);
209
+ return text === '' ? '' : `${'#'.repeat(level)} ${text}`;
210
+ }
211
+ case 'ul': return list(node, false, context);
212
+ case 'ol': return list(node, true, context);
213
+ case 'li': return cleanInline(contents());
214
+ case 'pre': return codeBlock(node, context);
215
+ case 'blockquote': return quote(contents());
216
+ case 'hr':
217
+ case 'Separator': return '---';
218
+ case 'CardTitle':
219
+ case 'AlertTitle': {
220
+ const text = cleanInline(inline(node.children, context));
221
+ return text === '' ? '' : `**${text}**`;
222
+ }
223
+ case 'CardDescription': return cleanInline(inline(node.children, context));
224
+ case 'Alert': {
225
+ const body = contents();
226
+ return body === '' ? '' : quote(body);
227
+ }
228
+ case 'AlertDescription': return contents();
229
+ case 'Table':
230
+ case 'table': return table(node, context);
231
+ case 'TableRow':
232
+ case 'TableHead':
233
+ case 'TableCell':
234
+ case 'tr':
235
+ case 'th':
236
+ case 'td': return cleanInline(inline(node.children, context));
237
+ case 'TabsTrigger':
238
+ case 'AccordionTrigger':
239
+ case 'summary':
240
+ case 'dt': {
241
+ const text = cleanInline(inline(node.children, context));
242
+ return text === '' ? '' : `**${text}**`;
243
+ }
244
+ case 'Progress': {
245
+ const body = contents();
246
+ const value = node.props['value'];
247
+ return body !== '' ? body : typeof value === 'number' || typeof value === 'string' ? `Progress: ${markdownText(String(value))}%` : '';
248
+ }
249
+ default: return contents();
250
+ }
251
+ }
252
+ function flow(nodes, context) {
253
+ const blocks = [];
254
+ let pending = '';
255
+ const flush = () => {
256
+ const text = cleanInline(pending);
257
+ pending = '';
258
+ if (text !== '')
259
+ blocks.push(text);
260
+ };
261
+ for (const node of nodes) {
262
+ if (!isElement(node) || (!context.slotTags.has(node.tag) && !BLOCK_TAGS.has(node.tag))) {
263
+ pending += isElement(node) ? inline([node], context) : markdownText(node.text);
264
+ continue;
265
+ }
266
+ flush();
267
+ const rendered = block(node, context).trim();
268
+ if (rendered !== '')
269
+ blocks.push(rendered);
270
+ }
271
+ flush();
272
+ return blocks.join('\n\n');
273
+ }
274
+ /** Project the authored display JSX only. Manifest slots remain the authoritative terminal rendering for questions, inputs, charts, and product components. */
275
+ export function projectPageDisplayMarkdown(document, manifest) {
276
+ if (manifest.dialect !== 'jsx')
277
+ return '';
278
+ const productTags = new Set(manifest.slots.filter((slot) => slot.unvalidated === true).map((slot) => pageComponentTag(slot.kind)));
279
+ const inScope = [...BUILTIN_PAGE_COMPONENT_TAGS, ...DISPLAY_PAGE_COMPONENT_TAGS, ...productTags];
280
+ const roots = evaluatePageTree(compilePage(document), inScope);
281
+ const pages = [];
282
+ const collect = (nodes) => {
283
+ for (const node of nodes) {
284
+ if (!isElement(node))
285
+ continue;
286
+ if (node.tag === 'Page')
287
+ pages.push(node);
288
+ collect(node.children);
289
+ }
290
+ };
291
+ collect(roots);
292
+ if (pages.length !== 1)
293
+ throw new PageAuthoringError(`page display projection expected one <Page> element; found ${pages.length}`);
294
+ return flow(pages[0].children, { slotTags: new Set([...SLOT_TAGS, ...productTags]) }).trim();
295
+ }