@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.
- package/dist/api/dto/broker.d.ts +2 -0
- package/dist/api/dto/messages.d.ts +22 -1
- 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 +90 -23
- package/dist/core/__tests__/context-intro.test.js +7 -8
- package/dist/core/__tests__/human-deliver.test.js +32 -4
- package/dist/core/__tests__/kickoff.test.js +6 -6
- 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/bearings.js +5 -4
- 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 +13 -2
- package/dist/core/runtime/broker/inbox.d.ts +20 -2
- package/dist/core/runtime/broker/inbox.js +68 -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-extension-render.d.ts +3 -0
- package/dist/core/runtime/broker-extension-render.js +3 -0
- package/dist/core/runtime/broker-protocol.d.ts +14 -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/interactive-deliver.d.ts +3 -2
- package/dist/core/runtime/interactive-deliver.js +4 -3
- package/dist/core/runtime/kickoff.d.ts +3 -4
- package/dist/core/runtime/kickoff.js +27 -16
- 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-context.d.ts +14 -5
- package/dist/core/runtime/situational-context.js +39 -21
- package/dist/core/runtime/situational-live.d.ts +2 -2
- package/dist/core/runtime/situational-live.js +17 -16
- package/dist/core/runtime/spawn.js +4 -3
- 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/messages.js +101 -17
- package/dist/daemon/api/handlers/nodes.js +12 -8
- package/dist/daemon/human/finish.js +44 -4
- 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 +1 -0
- 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 +9 -8
- package/dist/pi-extensions/canvas-goal-capture.js +10 -6
- package/dist/pi-extensions/canvas-inbox-watcher.js +116 -69
- 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 +68 -36
- package/dist/shared/generated-context.js +300 -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
|
@@ -9,7 +9,8 @@ import { cancelPendingHumanMessages } from '../../../core/feed/inbox.js';
|
|
|
9
9
|
import { withFreshTerminalGuard } from '../../../core/canvas/canvas.js';
|
|
10
10
|
import { assertNotFinalized, assertFinalizedForReopen, commitReopen } from '../../../core/runtime/reopen.js';
|
|
11
11
|
import { writeOutputSchema } from '../../../core/runtime/structured-output.js';
|
|
12
|
-
import { appendSituationalContext } from '../../../core/runtime/situational-context.js';
|
|
12
|
+
import { appendSituationalContext, formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
13
|
+
import { formatDataCard } from '../../../shared/generated-context.js';
|
|
13
14
|
import { hasNoNaturalCycle } from '../../../core/runtime/revive-all.js';
|
|
14
15
|
import { readGoal } from '../../../core/runtime/kickoff.js';
|
|
15
16
|
import { readRoadmap } from '../../../core/runtime/roadmap.js';
|
|
@@ -20,6 +21,53 @@ import { ApiError } from '../../../api/index.js';
|
|
|
20
21
|
// POST /v1/nodes/{id}/messages
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
23
|
const TIERS = ['critical', 'urgent', 'normal', 'deferred'];
|
|
24
|
+
/** Validate + narrow one caller-supplied runtime card. A bare kind is crouter's
|
|
25
|
+
* own closed vocabulary, so a caller must namespace: no product can shadow a
|
|
26
|
+
* core kind, and the parser's decode rule keys on exactly this property. */
|
|
27
|
+
function parseCardRequest(value, field) {
|
|
28
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
29
|
+
throw usage(`${field} must be an object { kind, facts?, body? }`);
|
|
30
|
+
}
|
|
31
|
+
const raw = value;
|
|
32
|
+
const kind = raw['kind'];
|
|
33
|
+
if (typeof kind !== 'string' || kind.trim() === '')
|
|
34
|
+
throw usage(`${field}.kind is required`);
|
|
35
|
+
if (!kind.includes(':'))
|
|
36
|
+
throw usage(`${field}.kind must be namespaced (contain ':'): ${kind}`);
|
|
37
|
+
const card = { kind };
|
|
38
|
+
const facts = raw['facts'];
|
|
39
|
+
if (facts !== undefined) {
|
|
40
|
+
if (typeof facts !== 'object' || facts === null || Array.isArray(facts)) {
|
|
41
|
+
throw usage(`${field}.facts must be an object of string or number values`);
|
|
42
|
+
}
|
|
43
|
+
const values = {};
|
|
44
|
+
for (const [name, fact] of Object.entries(facts)) {
|
|
45
|
+
if (typeof fact !== 'string' && typeof fact !== 'number') {
|
|
46
|
+
throw usage(`${field}.facts.${name} must be a string or number`);
|
|
47
|
+
}
|
|
48
|
+
values[name] = fact;
|
|
49
|
+
}
|
|
50
|
+
card.facts = values;
|
|
51
|
+
}
|
|
52
|
+
const body = raw['body'];
|
|
53
|
+
if (body !== undefined) {
|
|
54
|
+
if (typeof body !== 'string')
|
|
55
|
+
throw usage(`${field}.body must be a string`);
|
|
56
|
+
card.body = body;
|
|
57
|
+
}
|
|
58
|
+
return card;
|
|
59
|
+
}
|
|
60
|
+
/** Render a validated card. `formatDataCard` escapes the body exactly once —
|
|
61
|
+
* this is the ONLY escape on the path — and rejects a fact name that cannot be
|
|
62
|
+
* an XML attribute, which is caller input and so a 400, not a 500. */
|
|
63
|
+
function renderCard(card, field) {
|
|
64
|
+
try {
|
|
65
|
+
return formatDataCard(card.kind, card.facts ?? {}, card.body ?? '');
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
throw usage(`${field}: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
23
71
|
/** Validate + narrow the send-message body (spec §6.2). */
|
|
24
72
|
function parseSendBody(body) {
|
|
25
73
|
if (typeof body !== 'object' || body === null) {
|
|
@@ -34,10 +82,29 @@ function parseSendBody(body) {
|
|
|
34
82
|
const hasBody = typeof text === 'string' && text.trim() !== '';
|
|
35
83
|
const hasSituational = typeof situational === 'string' && situational.trim() !== '';
|
|
36
84
|
const hasSchema = typeof outputSchema === 'string' && outputSchema.trim() !== '';
|
|
37
|
-
|
|
85
|
+
const situationalCardRaw = b['situational_card'];
|
|
86
|
+
const contextCardsRaw = b['context_cards'];
|
|
87
|
+
const situationalCard = situationalCardRaw === undefined
|
|
88
|
+
? undefined
|
|
89
|
+
: parseCardRequest(situationalCardRaw, 'situational_card');
|
|
90
|
+
if (contextCardsRaw !== undefined && !Array.isArray(contextCardsRaw)) {
|
|
91
|
+
throw usage('context_cards must be an array of { kind, facts?, body? }');
|
|
92
|
+
}
|
|
93
|
+
const contextCards = contextCardsRaw?.map((card, index) => parseCardRequest(card, `context_cards[${index}]`));
|
|
94
|
+
// The sidecar is singular, so the card form and the prose form are one slot.
|
|
95
|
+
if (situationalCard !== undefined && typeof situational === 'string' && situational !== '') {
|
|
96
|
+
throw usage('situational_card and situational_context are mutually exclusive');
|
|
97
|
+
}
|
|
98
|
+
const hasCards = situationalCard !== undefined || (contextCards !== undefined && contextCards.length > 0);
|
|
99
|
+
// A body is required unless this is a fresh revive, a context-only update,
|
|
38
100
|
// or a one-off output-schema request.
|
|
39
|
-
if (!fresh && !hasBody && !hasSituational && !hasSchema) {
|
|
40
|
-
throw usage('a message requires a non-empty body (or fresh: true, situational_context, or output_schema)');
|
|
101
|
+
if (!fresh && !hasBody && !hasSituational && !hasSchema && !hasCards) {
|
|
102
|
+
throw usage('a message requires a non-empty body (or fresh: true, situational_context, a runtime card, or output_schema)');
|
|
103
|
+
}
|
|
104
|
+
// A fresh revive appends no inbox entry, so a one-shot card would have no
|
|
105
|
+
// carrier. The sidecar card survives the revive through the bearings.
|
|
106
|
+
if (fresh && contextCards !== undefined && contextCards.length > 0) {
|
|
107
|
+
throw usage('context_cards cannot ride a fresh revive (no inbox entry carries them)');
|
|
41
108
|
}
|
|
42
109
|
if (tierRaw !== undefined && !TIERS.includes(tierRaw)) {
|
|
43
110
|
throw usage(`invalid tier: ${String(tierRaw)} (expected critical|urgent|normal|deferred)`);
|
|
@@ -51,6 +118,10 @@ function parseSendBody(body) {
|
|
|
51
118
|
out.reopen = true;
|
|
52
119
|
if (typeof situational === 'string')
|
|
53
120
|
out.situational_context = situational;
|
|
121
|
+
if (situationalCard !== undefined)
|
|
122
|
+
out.situational_card = situationalCard;
|
|
123
|
+
if (contextCards !== undefined)
|
|
124
|
+
out.context_cards = contextCards;
|
|
54
125
|
if (typeof outputSchema === 'string')
|
|
55
126
|
out.output_schema = outputSchema;
|
|
56
127
|
if (typeof b['from'] === 'string')
|
|
@@ -62,8 +133,11 @@ function parseSendBody(body) {
|
|
|
62
133
|
if (delivery === 'interactive') {
|
|
63
134
|
// Interactive delivery is a live-conversation send: a plain immediate body,
|
|
64
135
|
// nothing that only makes sense on the durable path.
|
|
136
|
+
// Runtime cards ARE accepted here: a card-bearing send is an ordinary human
|
|
137
|
+
// send that happens to carry context, and the live deliver frame places the
|
|
138
|
+
// cards ahead of the body in one turn (D11).
|
|
65
139
|
if (fresh || out.reopen === true || out.situational_context !== undefined || out.output_schema !== undefined) {
|
|
66
|
-
throw usage('interactive delivery supports a
|
|
140
|
+
throw usage('interactive delivery supports a body and runtime cards only (no fresh, reopen, situational_context, or output_schema)');
|
|
67
141
|
}
|
|
68
142
|
if (!hasBody)
|
|
69
143
|
throw usage('interactive delivery requires a non-empty body');
|
|
@@ -122,9 +196,14 @@ async function handleMessage(ctx) {
|
|
|
122
196
|
const tier = (req.tier ?? 'normal');
|
|
123
197
|
const from = req.from ?? null;
|
|
124
198
|
const hasBody = req.body.trim() !== '';
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
199
|
+
// One sidecar slot: the caller's rendered card, or prose rendered as one.
|
|
200
|
+
const sidecar = req.situational_card !== undefined
|
|
201
|
+
? renderCard(req.situational_card, 'situational_card')
|
|
202
|
+
: req.situational_context !== undefined
|
|
203
|
+
? formatSituationalProse(req.situational_context.trim())
|
|
204
|
+
: '';
|
|
205
|
+
const hasSidecar = sidecar !== '';
|
|
206
|
+
const oneShotCards = (req.context_cards ?? []).map((card, index) => renderCard(card, `context_cards[${index}]`));
|
|
128
207
|
const schema = req.output_schema !== undefined && req.output_schema.trim() !== ''
|
|
129
208
|
? parseSchema(req.output_schema)
|
|
130
209
|
: null;
|
|
@@ -138,8 +217,8 @@ async function handleMessage(ctx) {
|
|
|
138
217
|
if (!req.reopen)
|
|
139
218
|
assertNotFinalized(id);
|
|
140
219
|
assertRecoverableForFresh(id);
|
|
141
|
-
if (
|
|
142
|
-
appendSituationalContext(id,
|
|
220
|
+
if (hasSidecar)
|
|
221
|
+
appendSituationalContext(id, sidecar);
|
|
143
222
|
if (req.reopen)
|
|
144
223
|
commitReopen(id, expectedFinalReport);
|
|
145
224
|
const result = reviveNode(id, { resume: false });
|
|
@@ -158,7 +237,9 @@ async function handleMessage(ctx) {
|
|
|
158
237
|
// straight there (append + revive, watcher delivers post-boot). ---
|
|
159
238
|
if (req.delivery === 'interactive' && isBrokerLive(meta)) {
|
|
160
239
|
try {
|
|
161
|
-
|
|
240
|
+
if (hasSidecar)
|
|
241
|
+
appendSituationalContext(id, sidecar);
|
|
242
|
+
await deliverLive(id, req.body, [...(hasSidecar ? [sidecar] : []), ...oneShotCards]);
|
|
162
243
|
const body = {
|
|
163
244
|
node_id: id,
|
|
164
245
|
delivered: true,
|
|
@@ -182,10 +263,11 @@ async function handleMessage(ctx) {
|
|
|
182
263
|
assertNotFinalized(id);
|
|
183
264
|
if (schema !== null)
|
|
184
265
|
writeOutputSchema(id, 'oneoff', schema);
|
|
185
|
-
if (
|
|
186
|
-
appendSituationalContext(id,
|
|
266
|
+
if (hasSidecar)
|
|
267
|
+
appendSituationalContext(id, sidecar);
|
|
187
268
|
if (req.reopen)
|
|
188
269
|
commitReopen(id, expectedFinalReport);
|
|
270
|
+
const cards = oneShotCards.length > 0 ? { cards: oneShotCards } : {};
|
|
189
271
|
if (hasBody || schema !== null) {
|
|
190
272
|
const messageBody = hasBody
|
|
191
273
|
? req.body
|
|
@@ -195,16 +277,18 @@ async function handleMessage(ctx) {
|
|
|
195
277
|
tier,
|
|
196
278
|
kind: 'message',
|
|
197
279
|
label: label(messageBody),
|
|
198
|
-
data: { body: messageBody, ...(
|
|
280
|
+
data: { body: messageBody, ...(hasSidecar ? { situational: true } : {}), ...cards },
|
|
199
281
|
});
|
|
200
282
|
}
|
|
201
|
-
//
|
|
283
|
+
// Context-only: a hidden wake marker excluded from the visible digest. Its
|
|
284
|
+
// one-shot cards have no visible delivery to ride, so the watcher's context
|
|
285
|
+
// channel carries them.
|
|
202
286
|
return appendInbox(id, {
|
|
203
287
|
from,
|
|
204
288
|
tier,
|
|
205
289
|
kind: 'message',
|
|
206
|
-
label: '(ambient context updated)',
|
|
207
|
-
data: { situational: true, situationalOnly: true },
|
|
290
|
+
label: hasSidecar ? '(ambient context updated)' : '(context delivered)',
|
|
291
|
+
data: { ...(hasSidecar ? { situational: true } : {}), situationalOnly: true, ...cards },
|
|
208
292
|
});
|
|
209
293
|
};
|
|
210
294
|
// Deferred guard: a done/canceled/finalized target has no natural
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readdirSync } from 'node:fs';
|
|
10
10
|
import { resolve } from 'node:path';
|
|
11
11
|
import { appendSituationalContext, closeNode, getNode, listNodes, nowIso, reviveNode, } from '../../../index.js';
|
|
12
|
+
import { formatSituationalProse } from '../../../core/runtime/situational-context.js';
|
|
12
13
|
import { assertLaunchModelRegistered, forkNode, resolveProfileId, spawnChild } from '../../../core/runtime/spawn.js';
|
|
13
14
|
import { childrenOf, subscribersOf, subscriptionsOf, setMessageWait, updateNode } from '../../../core/canvas/canvas.js';
|
|
14
15
|
import { subtreeIds } from '../../../core/canvas/nav-model.js';
|
|
@@ -98,14 +99,14 @@ function parseCreateBody(body) {
|
|
|
98
99
|
}
|
|
99
100
|
/** The directory a new node is pinned to. An explicit `pin_cwd` wins; else a
|
|
100
101
|
* managed child inherits its spawner's cwd (which is what keeps a worktree
|
|
101
|
-
* node's whole subtree inside that worktree); else the
|
|
102
|
-
*
|
|
103
|
-
* the last resort, for a profile that has no home (notably root).
|
|
102
|
+
* node's whole subtree inside that worktree); else the launch's own `cwd`, the
|
|
103
|
+
* directory the create came from; else the profile's home.
|
|
104
104
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
105
|
+
* The launch directory beating the profile home is what lets one profile cover
|
|
106
|
+
* many working directories — a root started inside a planted instance runs in
|
|
107
|
+
* that instance while still taking its identity, purview, and memory from the
|
|
108
|
+
* profile covering it. The profile supplies a home only for a create that named
|
|
109
|
+
* no directory at all. */
|
|
109
110
|
function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
110
111
|
if (req.pin_cwd !== undefined && req.pin_cwd !== '')
|
|
111
112
|
return resolve(req.pin_cwd);
|
|
@@ -114,6 +115,8 @@ function resolveNodeCwd(req, parent, profileId, contextCwd) {
|
|
|
114
115
|
if (spawner !== null)
|
|
115
116
|
return spawner.cwd;
|
|
116
117
|
}
|
|
118
|
+
if (req.cwd !== undefined && req.cwd !== '')
|
|
119
|
+
return resolve(req.cwd);
|
|
117
120
|
return profileHome(profileId) ?? contextCwd;
|
|
118
121
|
}
|
|
119
122
|
/** Build the immediate spawn recipe. */
|
|
@@ -328,6 +331,7 @@ async function handleMessages(ctx) {
|
|
|
328
331
|
const body = {
|
|
329
332
|
node_id: id,
|
|
330
333
|
messages: read.messages,
|
|
334
|
+
...(read.messageIds === undefined ? {} : { message_ids: read.messageIds }),
|
|
331
335
|
next_cursor: read.nextCursor,
|
|
332
336
|
captured_at: nowIso(),
|
|
333
337
|
};
|
|
@@ -540,7 +544,7 @@ export async function handleConfig(ctx, deps = {}) {
|
|
|
540
544
|
let meta = requireMeta(id);
|
|
541
545
|
const patch = ctx.body ?? {};
|
|
542
546
|
if (patch.situational_context !== undefined) {
|
|
543
|
-
appendSituationalContext(id, patch.situational_context);
|
|
547
|
+
appendSituationalContext(id, formatSituationalProse(patch.situational_context));
|
|
544
548
|
}
|
|
545
549
|
if (patch.model !== undefined) {
|
|
546
550
|
const pinned = isProviderPinnedModelToken(patch.model);
|
|
@@ -9,7 +9,7 @@ import { pagePath, readTicketReplyRoute, responsePath } from '../../core/human/c
|
|
|
9
9
|
import { readPageFeedback } from '../../core/human/feedback.js';
|
|
10
10
|
import { pageDocumentSha256 } from '../../core/human/feedback-companion.js';
|
|
11
11
|
import { appendInbox } from '../../core/feed/inbox.js';
|
|
12
|
-
import { FinalDeliveryError, pushFinal } from '../../core/feed/feed.js';
|
|
12
|
+
import { FinalDeliveryError, pushFinal, pushUpdate } from '../../core/feed/feed.js';
|
|
13
13
|
import { transition } from '../../core/runtime/lifecycle.js';
|
|
14
14
|
import { emitEvent } from '../../core/events/emit.js';
|
|
15
15
|
import { retireCompanion } from '../companion-retire.js';
|
|
@@ -80,15 +80,51 @@ function pageTitle(dir) {
|
|
|
80
80
|
return undefined;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
/** Person-authored response values, derived from the structured answer rather
|
|
84
|
+
* than display prose. Empty values have no words to attribute. */
|
|
85
|
+
function personWords(answer) {
|
|
86
|
+
if (answer.kind === 'acknowledgement')
|
|
87
|
+
return '';
|
|
88
|
+
const words = [];
|
|
89
|
+
for (const slot of answer.slots) {
|
|
90
|
+
if (slot.state !== 'answered')
|
|
91
|
+
continue;
|
|
92
|
+
if (slot.raw !== undefined) {
|
|
93
|
+
words.push(JSON.stringify(slot.raw));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (slot.text !== undefined) {
|
|
97
|
+
if (slot.text.edited || slot.text.singleLine === true)
|
|
98
|
+
words.push(slot.text.value);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const group of slot.groups)
|
|
102
|
+
words.push(...group.picked.map((item) => item.label));
|
|
103
|
+
if (slot.freetext !== undefined)
|
|
104
|
+
words.push(slot.freetext);
|
|
105
|
+
words.push(...slot.comments.map((comment) => comment.text));
|
|
106
|
+
}
|
|
107
|
+
return words.filter((word) => word !== '').join('\n\n');
|
|
108
|
+
}
|
|
109
|
+
/** The answer push: the person's words alone when the page captured any, tagged
|
|
110
|
+
* so every reader attributes them to the person without re-deriving it from
|
|
111
|
+
* prose. A page that captured nothing stays an ordinary report carrying the
|
|
112
|
+
* full record, because there is nothing of the person's to attribute. */
|
|
83
113
|
function renderPageAnswer(dir, result, resultPath) {
|
|
84
114
|
let manifest;
|
|
85
115
|
try {
|
|
86
116
|
manifest = parsePage(dir);
|
|
87
117
|
}
|
|
88
118
|
catch {
|
|
89
|
-
return renderCorruptPageAnswer(result, resultPath);
|
|
119
|
+
return { body: renderCorruptPageAnswer(result, resultPath) };
|
|
90
120
|
}
|
|
91
|
-
|
|
121
|
+
const answer = describePageAnswer(manifest, result.responses, { completedAt: result.completedAt });
|
|
122
|
+
const rendered = renderAnswerText(answer, resultPath);
|
|
123
|
+
const addendum = feedbackAddendum(dir);
|
|
124
|
+
const words = personWords(answer);
|
|
125
|
+
return words === ''
|
|
126
|
+
? { body: rendered + addendum }
|
|
127
|
+
: { body: words, disposition: 'human-answer', ...(addendum === '' ? {} : { feedback: addendum.trim() }) };
|
|
92
128
|
}
|
|
93
129
|
function deliveryFailed(error) {
|
|
94
130
|
// A canonical final is committed inside its own write boundary and can never
|
|
@@ -198,6 +234,7 @@ export async function deliverTerminalResult(ticketId) {
|
|
|
198
234
|
kind: 'message',
|
|
199
235
|
label: subject,
|
|
200
236
|
data: { body },
|
|
237
|
+
disposition: 'human-canceled',
|
|
201
238
|
});
|
|
202
239
|
}
|
|
203
240
|
catch {
|
|
@@ -210,7 +247,10 @@ export async function deliverTerminalResult(ticketId) {
|
|
|
210
247
|
return true;
|
|
211
248
|
}
|
|
212
249
|
if (result.kind === 'page') {
|
|
213
|
-
|
|
250
|
+
const answer = renderPageAnswer(dir, result, responsePath(dir));
|
|
251
|
+
if (answer.feedback !== undefined)
|
|
252
|
+
await pushUpdate(bridgeNodeId, answer.feedback);
|
|
253
|
+
await pushFinal(bridgeNodeId, answer.body, answer.disposition === undefined ? undefined : { disposition: answer.disposition });
|
|
214
254
|
}
|
|
215
255
|
return true;
|
|
216
256
|
}
|
|
@@ -4,6 +4,10 @@ export interface NodeMessageDelivery {
|
|
|
4
4
|
via: 'engine' | 'inbox';
|
|
5
5
|
revived: boolean;
|
|
6
6
|
}
|
|
7
|
+
export interface RuntimeMessageCard {
|
|
8
|
+
kind: string;
|
|
9
|
+
facts: Record<string, string | number | boolean | undefined>;
|
|
10
|
+
}
|
|
7
11
|
/**
|
|
8
12
|
* Deliver one daemon-originated node message. Interactive messages use the live
|
|
9
13
|
* broker when available; every other successful path is a durable inbox entry.
|
|
@@ -15,4 +19,5 @@ export declare function deliverNodeMessage(args: {
|
|
|
15
19
|
from?: string | null;
|
|
16
20
|
mode: NodeMessageMode;
|
|
17
21
|
data?: Record<string, unknown>;
|
|
22
|
+
runtime?: RuntimeMessageCard;
|
|
18
23
|
}): Promise<NodeMessageDelivery>;
|
|
@@ -7,6 +7,7 @@ import { isBrokerLive } from '../../core/runtime/model-swap.js';
|
|
|
7
7
|
import { reviveNode } from '../../core/runtime/revive.js';
|
|
8
8
|
import { hasNoNaturalCycle } from '../../core/runtime/revive-all.js';
|
|
9
9
|
import { ReviewOperationError } from '../../core/review/types.js';
|
|
10
|
+
import { formatCard } from '../../shared/generated-context.js';
|
|
10
11
|
function deliveryUnroutable(nodeId, reason) {
|
|
11
12
|
throw new ReviewOperationError('delivery_unroutable', reason === 'origin_missing' ? 'message target was not found' : 'message target has no natural delivery cycle', { node_id: nodeId, reason });
|
|
12
13
|
}
|
|
@@ -33,7 +34,11 @@ export async function deliverNodeMessage(args) {
|
|
|
33
34
|
deliveryUnroutable(args.node_id, 'origin_missing');
|
|
34
35
|
if (args.mode === 'interactive' && isBrokerLive(target)) {
|
|
35
36
|
try {
|
|
36
|
-
|
|
37
|
+
// Inbox delivery supplies the raw body as an entry in its outer inbox
|
|
38
|
+
// card; only live delivery needs this message's own envelope.
|
|
39
|
+
await deliverLive(args.node_id, args.runtime === undefined
|
|
40
|
+
? args.body
|
|
41
|
+
: formatCard(args.runtime.kind, args.runtime.facts, args.body));
|
|
37
42
|
// The broker acknowledgement is asynchronous to the SQLite guard. Recheck
|
|
38
43
|
// before reporting an engine receipt so a final that committed during the
|
|
39
44
|
// request is not represented as a routable delivery.
|
|
@@ -70,6 +70,14 @@ export async function notifyComment(args) {
|
|
|
70
70
|
comment_id: args.comment.comment_id,
|
|
71
71
|
verb: args.verb,
|
|
72
72
|
},
|
|
73
|
+
runtime: {
|
|
74
|
+
kind: 'review-comment',
|
|
75
|
+
facts: {
|
|
76
|
+
review_id: args.review.review_id,
|
|
77
|
+
comment_id: args.comment.comment_id,
|
|
78
|
+
verb: args.verb,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
73
81
|
});
|
|
74
82
|
}
|
|
75
83
|
catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FinalizationError, pushFinal, redeliverFinalReport } from '../../core/feed/feed.js';
|
|
2
|
-
import {
|
|
2
|
+
import { formatCard } from '../../shared/generated-context.js';
|
|
3
3
|
import { deliverNodeMessage } from '../messaging/node-message.js';
|
|
4
4
|
function approvalBody(review, resultPath) {
|
|
5
5
|
const summary = review.changed
|
|
@@ -9,7 +9,7 @@ function approvalBody(review, resultPath) {
|
|
|
9
9
|
}
|
|
10
10
|
/** Render the immutable approval fact without replaying comment text. */
|
|
11
11
|
export function renderApprovalMessage(review, resultPath) {
|
|
12
|
-
return
|
|
12
|
+
return formatCard('review-approval', { review: review.review_id, file: review.file }, approvalBody(review, resultPath));
|
|
13
13
|
}
|
|
14
14
|
function recordedFailedFanout(review, reportBasename) {
|
|
15
15
|
const error = review.delivery_error;
|
|
@@ -164,6 +164,7 @@ async function noteSubmitQueued(review) {
|
|
|
164
164
|
mode: 'quiet',
|
|
165
165
|
label: 'human review submitted — result to follow',
|
|
166
166
|
data: { review_id: review.review_id },
|
|
167
|
+
runtime: { kind: 'review-queued', facts: { review_id: review.review_id } },
|
|
167
168
|
body: `The human submitted their review of \`${review.file}\`; the document is still being edited. `
|
|
168
169
|
+ "You'll be notified with the result once the review completes. Nothing is needed from you now.",
|
|
169
170
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/pi-extensions/__tests__/canvas-goal-capture-envelope.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The revive-kickoff exclusion in canvas-goal-capture. A fresh revive's kickoff
|
|
4
|
+
// arrives as a LAUNCH PROMPT — an ordinary interactive input event, not an
|
|
5
|
+
// extension injection — so without an exclusion it looks exactly like a bare
|
|
6
|
+
// root's first typed message: it would be written to context/initial-prompt.md
|
|
7
|
+
// as the node's mandate and would name the node from the runtime's own words.
|
|
8
|
+
//
|
|
9
|
+
// The exclusion used to key on the kickoff's opening sentence. It now keys on
|
|
10
|
+
// the <runtime kind="revive" …> envelope, and this file pins that the real
|
|
11
|
+
// builder's real bytes are still excluded — the two changes are one unit
|
|
12
|
+
// precisely because the envelope moves the bytes the old check read.
|
|
13
|
+
//
|
|
14
|
+
// The naming attempt is observed at the daemon socket: generateAndCommitInitialName
|
|
15
|
+
// starts by asking crtrd for the node's projection, so a request for a node id is
|
|
16
|
+
// proof that naming ran for it, and no request is proof that it did not.
|
|
17
|
+
import { test, before, after } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
20
|
+
import { createServer } from 'node:net';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import registerCanvasGoalCapture from '../canvas-goal-capture.js';
|
|
24
|
+
import { createNode } from '../../core/canvas/canvas.js';
|
|
25
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
26
|
+
import { apiSocketPath } from '../../core/canvas/paths.js';
|
|
27
|
+
import { buildReviveKickoff, drainBearings } from '../../core/runtime/kickoff.js';
|
|
28
|
+
let home;
|
|
29
|
+
let socket;
|
|
30
|
+
/** Every request line the extension's naming path sent to the daemon socket. */
|
|
31
|
+
const requests = [];
|
|
32
|
+
function node(id) {
|
|
33
|
+
return {
|
|
34
|
+
node_id: id,
|
|
35
|
+
name: id,
|
|
36
|
+
created: new Date().toISOString(),
|
|
37
|
+
cwd: '/tmp/work',
|
|
38
|
+
kind: 'general',
|
|
39
|
+
mode: 'base',
|
|
40
|
+
lifecycle: 'terminal',
|
|
41
|
+
status: 'active',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Register the extension and hand back its input handler. */
|
|
45
|
+
function handler() {
|
|
46
|
+
let captured;
|
|
47
|
+
registerCanvasGoalCapture({ on: (_event, fn) => { captured = fn; } });
|
|
48
|
+
assert.ok(captured !== undefined, 'the extension registers an input handler');
|
|
49
|
+
return captured;
|
|
50
|
+
}
|
|
51
|
+
function initialPromptPath(id) {
|
|
52
|
+
return join(home, 'nodes', id, 'context', 'initial-prompt.md');
|
|
53
|
+
}
|
|
54
|
+
async function waitForRequestMentioning(id) {
|
|
55
|
+
for (let i = 0; i < 200; i++) {
|
|
56
|
+
if (requests.some((line) => line.includes(id)))
|
|
57
|
+
return;
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`no daemon request mentioning ${id} arrived`);
|
|
61
|
+
}
|
|
62
|
+
before(async () => {
|
|
63
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-goal-capture-'));
|
|
64
|
+
process.env['CRTR_HOME'] = home;
|
|
65
|
+
// A stub daemon: it records the request and hangs up, so the naming path fails
|
|
66
|
+
// fast (and silently, as it must) without any model call.
|
|
67
|
+
socket = createServer((connection) => {
|
|
68
|
+
connection.on('data', (chunk) => {
|
|
69
|
+
requests.push(chunk.toString('utf8'));
|
|
70
|
+
connection.destroy();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
await new Promise((resolve) => socket.listen(apiSocketPath(), resolve));
|
|
74
|
+
});
|
|
75
|
+
after(async () => {
|
|
76
|
+
closeDb();
|
|
77
|
+
await new Promise((resolve) => socket.close(() => resolve()));
|
|
78
|
+
rmSync(home, { recursive: true, force: true });
|
|
79
|
+
delete process.env['CRTR_HOME'];
|
|
80
|
+
delete process.env['CRTR_NODE_ID'];
|
|
81
|
+
});
|
|
82
|
+
test('a fresh-revive kickoff seeds no mandate and no name; a human first message seeds both', async () => {
|
|
83
|
+
const revived = createNode(node('revived-node'));
|
|
84
|
+
const kickoff = buildReviveKickoff(revived, drainBearings(revived));
|
|
85
|
+
process.env['CRTR_NODE_ID'] = revived.node_id;
|
|
86
|
+
handler()({ type: 'input', text: kickoff, source: 'interactive' });
|
|
87
|
+
const bare = createNode(node('bare-root'));
|
|
88
|
+
process.env['CRTR_NODE_ID'] = bare.node_id;
|
|
89
|
+
handler()({ type: 'input', text: 'build me a receipt tracker', source: 'interactive' });
|
|
90
|
+
// The human message's naming call is the clock: both inputs ran through the
|
|
91
|
+
// same handler, so once its request has landed the revive's would have too.
|
|
92
|
+
await waitForRequestMentioning(bare.node_id);
|
|
93
|
+
assert.equal(existsSync(initialPromptPath(revived.node_id)), false, 'a fresh revive never overwrites the node mandate with the runtime kickoff');
|
|
94
|
+
assert.equal(requests.some((line) => line.includes(revived.node_id)), false, 'a fresh revive never names the node from the runtime kickoff');
|
|
95
|
+
assert.equal(readFileSync(initialPromptPath(bare.node_id), 'utf8').trim(), 'build me a receipt tracker', "a bare root's first typed message is still captured as its mandate");
|
|
96
|
+
});
|
|
@@ -409,7 +409,7 @@ test('stalled leaf (nothing live to await, no final) is still reprompted', async
|
|
|
409
409
|
let shutdown = false;
|
|
410
410
|
await settle(pi, stopEvent('I think I am basically done here'), { shutdown: () => { shutdown = true; } });
|
|
411
411
|
assert.equal(pi.injected.length, 1, 'the stall reprompt fired');
|
|
412
|
-
assert.equal(pi.injected[0].content, STALL_REPROMPT
|
|
412
|
+
assert.equal(pi.injected[0].content, `<runtime kind="stop-guard" reason="stalled">${STALL_REPROMPT}</runtime>`, 'reprompt carries the stall nudge to push final / ask');
|
|
413
413
|
assert.equal(pi.injected[0].deliverAs, 'followUp', 'reprompt delivered as a followUp');
|
|
414
414
|
assert.equal(shutdown, false, 'a stalled leaf is NOT shut down — it is re-prompted to finish');
|
|
415
415
|
assert.notEqual(getNode('leaf')?.intent, 'idle-release', 'a stalled leaf does not idle-release');
|
|
@@ -98,7 +98,7 @@ test('band crossing mid-run (tool calls present) → context nudge is a STEER',
|
|
|
98
98
|
await pi.fire('turn_end', turnEnd([{ type: 'toolCall', id: 't1', name: 'bash', arguments: {} }], 'toolUse'), ctxAt(150_000));
|
|
99
99
|
assert.equal(pi.steered.length, 1, 'exactly one steer delivered');
|
|
100
100
|
assert.equal(pi.steered[0].deliverAs, 'steer');
|
|
101
|
-
assert.match(pi.steered[0].content,
|
|
101
|
+
assert.match(pi.steered[0].content, /^<runtime kind="context-nudge">Context ~150k/);
|
|
102
102
|
assert.equal(pi.held.length, 0, 'nothing held for nextTurn');
|
|
103
103
|
});
|
|
104
104
|
test('band crossing on the FINAL message (no tool calls) → nudge is HELD as nextTurn, never a steer', async () => {
|
|
@@ -111,7 +111,7 @@ test('band crossing on the FINAL message (no tool calls) → nudge is HELD as ne
|
|
|
111
111
|
assert.equal(pi.held.length, 1, 'nudge held for the next user message');
|
|
112
112
|
assert.equal(pi.held[0].deliverAs, 'nextTurn');
|
|
113
113
|
assert.equal(pi.held[0].customType, 'crtr-context-nudge');
|
|
114
|
-
assert.match(pi.held[0].content,
|
|
114
|
+
assert.match(pi.held[0].content, /^<runtime kind="context-nudge">Context ~150k/);
|
|
115
115
|
assert.notEqual(pi.held[0].triggerTurn, true, 'must not trigger a turn');
|
|
116
116
|
});
|
|
117
117
|
test('a truncated tool-calling turn (stopReason length) still steers — the loop continues past failed calls', async () => {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { REVIVE_KICKOFF_SENTINEL } from '../shared/generated-context.js';
|
|
2
|
-
export { REVIVE_KICKOFF_SENTINEL };
|
|
3
1
|
/** Persist a bare root's first real interactive message without clobbering an
|
|
4
2
|
* existing spawning mandate. This is broker-local file state, not canvas.db. */
|
|
5
3
|
export declare function captureGoalIfAbsent(nodeId: string, text: string): boolean;
|
|
@@ -7,11 +7,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { CRTR_DIR_NAME } from '../types.js';
|
|
10
|
-
import { REVIVE_KICKOFF_SENTINEL } from '../shared/generated-context.js';
|
|
11
10
|
import { brokerExtensionState, commitBrokerGeneratedName, } from '../core/runtime/broker/daemon-ops.js';
|
|
12
11
|
import { publishNodeNamed } from '../core/runtime/broker/node-named.js';
|
|
13
12
|
import { headlessName, headlessNameFromConversation, sanitizeSessionName, slugFromPrompt, } from '../core/runtime/naming.js';
|
|
14
|
-
export { REVIVE_KICKOFF_SENTINEL };
|
|
15
13
|
function contextDir(nodeId) {
|
|
16
14
|
if (nodeId === '' || nodeId === '.' || nodeId === '..' || /[\\/\0]/u.test(nodeId)) {
|
|
17
15
|
throw new TypeError('node_id must be a non-empty path segment');
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
//
|
|
27
27
|
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
28
28
|
// crouter's own tsc build without a dep on the pi packages.
|
|
29
|
-
import { CONTEXT_INTRO_CUSTOM_TYPE } from '../shared/generated-context.js';
|
|
29
|
+
import { CONTEXT_INTRO_CUSTOM_TYPE, formatCard, parseCard } from '../shared/generated-context.js';
|
|
30
30
|
import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
31
31
|
import { buildContextBearingsFromState, buildForkBearingsFromState, buildProjectContextBlockForBroker } from '../core/runtime/broker-extension-render.js';
|
|
32
|
-
import {
|
|
32
|
+
import { situationalContextEnvelope } from '../core/runtime/situational-context.js';
|
|
33
33
|
import { saveInjectedDocs, sharedInjectedDocs } from '../core/substrate/injected-store.js';
|
|
34
34
|
import { truncateToWidth } from './truncate.js';
|
|
35
35
|
/** The `customType` stamped on the injected session message. Used both to write
|
|
@@ -44,13 +44,13 @@ export { CONTEXT_INTRO_CUSTOM_TYPE };
|
|
|
44
44
|
* crouter context. Exported for testing. */
|
|
45
45
|
export async function buildContextIntro(nodeId, seen) {
|
|
46
46
|
const state = await brokerExtensionState(nodeId);
|
|
47
|
-
return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd),
|
|
47
|
+
return buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextEnvelope(nodeId) ?? '', seen);
|
|
48
48
|
}
|
|
49
49
|
/** Build the compact node-scoped update for a fork whose copied branch already
|
|
50
50
|
* carries a full crouter baseline. Exported for testing. */
|
|
51
51
|
export async function buildForkContextIntro(nodeId) {
|
|
52
52
|
const state = await brokerExtensionState(nodeId);
|
|
53
|
-
return buildForkBearingsFromState(state,
|
|
53
|
+
return buildForkBearingsFromState(state, situationalContextEnvelope(nodeId) ?? '');
|
|
54
54
|
}
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
// Collapsed-by-default rendering
|
|
@@ -112,7 +112,8 @@ export function renderContextMessage(message, options, theme) {
|
|
|
112
112
|
return [paint('dim', truncateToWidth(stub, w))];
|
|
113
113
|
}
|
|
114
114
|
const lines = [paint('customMessageLabel', truncateToWidth(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`, w)), ''];
|
|
115
|
-
|
|
115
|
+
const body = parseCard({ role: 'custom', customType: message.customType, content: messageText(message) })?.body ?? messageText(message);
|
|
116
|
+
for (const raw of body.split('\n')) {
|
|
116
117
|
for (const wrapped of wrapLine(raw, w))
|
|
117
118
|
lines.push(paint('customMessageText', wrapped));
|
|
118
119
|
}
|
|
@@ -168,11 +169,11 @@ export function registerCanvasContextIntro(pi) {
|
|
|
168
169
|
// docs and needs no set.
|
|
169
170
|
const seen = inherited && forked ? undefined : sharedInjectedDocs(nodeId);
|
|
170
171
|
const content = inherited && forked
|
|
171
|
-
? buildForkBearingsFromState(state,
|
|
172
|
-
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd),
|
|
172
|
+
? buildForkBearingsFromState(state, situationalContextEnvelope(nodeId) ?? '')
|
|
173
|
+
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextEnvelope(nodeId) ?? '', seen);
|
|
173
174
|
pi.sendMessage({
|
|
174
175
|
customType: CONTEXT_INTRO_CUSTOM_TYPE,
|
|
175
|
-
content,
|
|
176
|
+
content: formatCard('bearings', {}, content),
|
|
176
177
|
display: true,
|
|
177
178
|
details: { nodeId },
|
|
178
179
|
});
|