@north-light/crouter 0.3.203 → 0.3.205
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.
- package/dist/api/dto/broker.d.ts +2 -0
- package/dist/api/dto/nodes.d.ts +8 -5
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +54 -19
- package/dist/clients/attach/__tests__/page-block.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/page-block.test.js +54 -0
- package/dist/clients/attach/render/chat-view.d.ts +3 -0
- package/dist/clients/attach/render/chat-view.js +56 -27
- package/dist/clients/attach/render/context-message.d.ts +6 -0
- package/dist/clients/attach/render/context-message.js +21 -3
- package/dist/clients/attach/render/group-activity.d.ts +5 -3
- package/dist/clients/attach/render/group-activity.js +10 -6
- package/dist/clients/attach/render/page-block.js +11 -11
- package/dist/clients/attach/viewer.js +717 -703
- package/dist/clients/conversation/projection.js +7 -37
- package/dist/commands/node/create.js +1 -1
- package/dist/commands/profile/project.js +1 -1
- package/dist/commands/profile/show.js +1 -1
- package/dist/commands/profile.js +1 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +88 -21
- package/dist/core/__tests__/human-deliver.test.js +66 -5
- package/dist/core/__tests__/serial/broker-snapshot-history.test.js +5 -2
- package/dist/core/__tests__/serial/deferred-no-wake.test.js +7 -4
- package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -1
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +4 -1
- package/dist/core/__tests__/serial/revive.test.js +8 -3
- package/dist/core/__tests__/session-cycles.test.js +10 -6
- package/dist/core/canvas/render-source.js +3 -0
- package/dist/core/feed/feed.d.ts +7 -1
- package/dist/core/feed/feed.js +8 -7
- package/dist/core/feed/inbox.d.ts +16 -12
- package/dist/core/feed/inbox.js +56 -56
- package/dist/core/human/page-eval.d.ts +9 -4
- package/dist/core/human/page-eval.js +3 -1
- package/dist/core/human/page-markdown.d.ts +3 -0
- package/dist/core/human/page-markdown.js +295 -0
- package/dist/core/human/page.js +4 -0
- package/dist/core/runtime/broker/client-registry.d.ts +1 -0
- package/dist/core/runtime/broker/client-registry.js +1 -0
- package/dist/core/runtime/broker/fault-retry.js +8 -3
- package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
- package/dist/core/runtime/broker/frame-dispatch.js +4 -2
- package/dist/core/runtime/broker/inbox.d.ts +14 -2
- package/dist/core/runtime/broker/inbox.js +52 -25
- package/dist/core/runtime/broker/passive.d.ts +1 -0
- package/dist/core/runtime/broker/tool-groups.js +3 -2
- package/dist/core/runtime/broker-protocol.d.ts +9 -0
- package/dist/core/runtime/broker-protocol.js +11 -0
- package/dist/core/runtime/broker.d.ts +2 -2
- package/dist/core/runtime/broker.js +31 -6
- package/dist/core/runtime/deliver-live.js +2 -1
- package/dist/core/runtime/kickoff.d.ts +3 -4
- package/dist/core/runtime/kickoff.js +23 -13
- package/dist/core/runtime/node-read.d.ts +2 -0
- package/dist/core/runtime/node-read.js +11 -3
- package/dist/core/runtime/session-cycles.d.ts +6 -1
- package/dist/core/runtime/session-cycles.js +17 -11
- package/dist/core/runtime/session-visibility.d.ts +7 -12
- package/dist/core/runtime/session-visibility.js +7 -13
- package/dist/core/runtime/situational-live.js +2 -1
- package/dist/core/runtime/stop-guard.js +8 -4
- package/dist/core/runtime/warm-pool.d.ts +1 -1
- package/dist/core/runtime/warm-pool.js +6 -9
- package/dist/daemon/api/handlers/nodes.js +10 -7
- package/dist/daemon/companion-retire.d.ts +2 -2
- package/dist/daemon/companion-retire.js +16 -9
- package/dist/daemon/human/finish.d.ts +2 -2
- package/dist/daemon/human/finish.js +48 -8
- package/dist/daemon/human/sweep.js +3 -2
- package/dist/daemon/messaging/node-message.d.ts +5 -0
- package/dist/daemon/messaging/node-message.js +6 -1
- package/dist/daemon/review/comment-notify.js +8 -0
- package/dist/daemon/review/deliver.js +2 -2
- package/dist/daemon/review/finish.js +3 -2
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +96 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +2 -2
- package/dist/pi-extensions/broker-local.d.ts +0 -2
- package/dist/pi-extensions/broker-local.js +0 -2
- package/dist/pi-extensions/canvas-context-intro.js +4 -3
- package/dist/pi-extensions/canvas-goal-capture.js +10 -6
- package/dist/pi-extensions/canvas-inbox-watcher.js +36 -25
- package/dist/pi-extensions/canvas-passive-context.d.ts +10 -0
- package/dist/pi-extensions/canvas-passive-context.js +23 -3
- package/dist/pi-extensions/canvas-review-boundary.d.ts +1 -1
- package/dist/pi-extensions/canvas-review-boundary.js +11 -3
- package/dist/pi-extensions/canvas-stophook.js +3 -3
- package/dist/shared/__tests__/generated-context-grammar.test.d.ts +1 -0
- package/dist/shared/__tests__/generated-context-grammar.test.js +85 -0
- package/dist/shared/generated-context.d.ts +57 -36
- package/dist/shared/generated-context.js +273 -174
- package/dist/shared/tool-groups.js +3 -2
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/postinstall.mjs +9 -0
package/dist/core/feed/feed.js
CHANGED
|
@@ -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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
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[];
|
package/dist/core/feed/inbox.js
CHANGED
|
@@ -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
|
|
315
|
-
*
|
|
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
|
|
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
|
-
*
|
|
332
|
+
* One entry's card body.
|
|
332
333
|
*
|
|
333
|
-
* A short pushed report is inlined as its own body
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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
|
|
341
|
+
function entryBody(e) {
|
|
341
342
|
const report = inlineReport(e);
|
|
342
|
-
if (report !== null)
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
394
|
-
//
|
|
395
|
-
//
|
|
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
|
-
|
|
390
|
+
deliveries.push({ kind: 'human', text: body === '' ? e.label : body, entries: [e] });
|
|
401
391
|
}
|
|
402
392
|
continue;
|
|
403
393
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 !== '' ? `])/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
|
+
}
|
package/dist/core/human/page.js
CHANGED
|
@@ -96,6 +96,8 @@ function scanPage(document, code, productComponents) {
|
|
|
96
96
|
const pages = [];
|
|
97
97
|
const collectPages = (nodes) => {
|
|
98
98
|
for (const node of nodes) {
|
|
99
|
+
if (node.kind === 'text')
|
|
100
|
+
continue;
|
|
99
101
|
if (node.tag === 'Page')
|
|
100
102
|
pages.push(node);
|
|
101
103
|
collectPages(node.children);
|
|
@@ -126,6 +128,8 @@ function scanPage(document, code, productComponents) {
|
|
|
126
128
|
let stepCount = 0;
|
|
127
129
|
const collectSlots = (nodes, step) => {
|
|
128
130
|
for (const node of nodes) {
|
|
131
|
+
if (node.kind === 'text')
|
|
132
|
+
continue;
|
|
129
133
|
const inStep = node.tag === 'Step' ? stepCount++ : step;
|
|
130
134
|
const builtin = BUILTIN_KIND_BY_TAG[node.tag];
|
|
131
135
|
const product = productTags.get(node.tag);
|