@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
|
@@ -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,7 +26,7 @@
|
|
|
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
32
|
import { situationalContextBlock } from '../core/runtime/situational-context.js';
|
|
@@ -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
|
}
|
|
@@ -172,7 +173,7 @@ export function registerCanvasContextIntro(pi) {
|
|
|
172
173
|
: buildContextBearingsFromState(state, buildProjectContextBlockForBroker(state.node.cwd), situationalContextBlock(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
|
});
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
// live-update the editor label. The first message may be a human's line OR
|
|
19
19
|
// a delegated child's kickoff task — naming off the agent prompt is fine.
|
|
20
20
|
//
|
|
21
|
-
// Both skip extension-injected messages (inbox wakes, steering) and
|
|
22
|
-
// fresh-revive kickoff
|
|
21
|
+
// Both skip extension-injected messages (inbox wakes, steering) and every
|
|
22
|
+
// runtime-authored message (the fresh-revive kickoff and its siblings, each one
|
|
23
|
+
// a <runtime kind="…"> card), so neither is mistaken for a first input.
|
|
23
24
|
//
|
|
24
25
|
// Pure observation — it writes the goal file as a side effect and always lets
|
|
25
26
|
// the message through unchanged (returns nothing ⇒ continue). Registered before
|
|
@@ -28,7 +29,8 @@
|
|
|
28
29
|
//
|
|
29
30
|
// Plain TS-with-types — no imports from @earendil-works/* so this compiles inside
|
|
30
31
|
// crouter's own tsc build without a dep on the pi packages.
|
|
31
|
-
import { captureGoalIfAbsent, generateAndCommitInitialName,
|
|
32
|
+
import { captureGoalIfAbsent, generateAndCommitInitialName, } from './broker-local.js';
|
|
33
|
+
import { parseCard } from '../shared/generated-context.js';
|
|
32
34
|
/**
|
|
33
35
|
* Register the goal-capture handler on `pi`.
|
|
34
36
|
*
|
|
@@ -46,11 +48,13 @@ export function registerCanvasGoalCapture(pi) {
|
|
|
46
48
|
if (text === '')
|
|
47
49
|
return;
|
|
48
50
|
// Never seed a mandate or a name from an extension-injected message (inbox
|
|
49
|
-
// wakes, steering nudges) or
|
|
50
|
-
//
|
|
51
|
+
// wakes, steering nudges) or any runtime-authored message — a fresh-revive
|
|
52
|
+
// kickoff arrives as a launch prompt, not an injection, and would otherwise
|
|
53
|
+
// masquerade as the node's first real input: overwriting a bare root's
|
|
54
|
+
// mandate and renaming the node from the runtime's own words.
|
|
51
55
|
if (event.source === 'extension')
|
|
52
56
|
return;
|
|
53
|
-
if (text
|
|
57
|
+
if (parseCard({ role: 'user', content: text }) !== null)
|
|
54
58
|
return;
|
|
55
59
|
// Goal capture is bare-root only: a delegated child already had its goal
|
|
56
60
|
// persisted at birth (writeGoal), so only a genuine human-typed prompt
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// canvas-inbox-watcher.ts — in-process delivery of a canvas node's active inbox.
|
|
2
2
|
//
|
|
3
3
|
// The watcher polls the append-only inbox in physical order, coalesces each quiet
|
|
4
|
-
// burst, and hands hidden situational context and visible
|
|
5
|
-
//
|
|
4
|
+
// burst, and hands hidden situational context and visible deliveries to pi. A
|
|
5
|
+
// burst mixing a person's words with node reports splits into several visible
|
|
6
|
+
// deliveries, one per cycle. A send return is only a handoff. Exact cursor persistence at agent_settled is the
|
|
6
7
|
// durable boundary; an uncommitted process exit is recovered by rereading from
|
|
7
8
|
// the unchanged cursor.
|
|
8
9
|
import { coalesceBrokerInbox, readBrokerCanceledEntryIds, readBrokerCursor, readBrokerInboxSince, reportRefNodeId, } from '../core/runtime/broker/inbox.js';
|
|
@@ -11,6 +12,7 @@ import { emitEvent } from '../core/events/emit.js';
|
|
|
11
12
|
import { errorClassFromError } from '../core/events/errors.js';
|
|
12
13
|
import { operationIdContext } from '../core/events/operation-id.js';
|
|
13
14
|
import { readSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../core/runtime/situational-context.js';
|
|
15
|
+
import { formatCard } from '../shared/generated-context.js';
|
|
14
16
|
const DEFAULT_TICK_MS = 800;
|
|
15
17
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
16
18
|
const MAX_PENDING_HANDOFFS = 64;
|
|
@@ -664,19 +666,19 @@ class CanvasInboxWatcher {
|
|
|
664
666
|
return this.sendDelivery('hidden', entries, () => {
|
|
665
667
|
this.pi.sendMessage({
|
|
666
668
|
customType: SITUATIONAL_CONTEXT_CUSTOM_TYPE,
|
|
667
|
-
content,
|
|
669
|
+
content: formatCard('situational', {}, content),
|
|
668
670
|
display: true,
|
|
669
671
|
details: { nodeId },
|
|
670
672
|
}, { deliverAs: 'followUp', triggerTurn: true });
|
|
671
673
|
}, stage);
|
|
672
674
|
}
|
|
673
|
-
sendVisible(entries,
|
|
675
|
+
sendVisible(entries, content, route, stage) {
|
|
674
676
|
return this.sendDelivery('visible', entries, () => {
|
|
675
677
|
if (route === 'idle') {
|
|
676
|
-
this.pi.sendUserMessage(
|
|
678
|
+
this.pi.sendUserMessage(content);
|
|
677
679
|
}
|
|
678
680
|
else if (route === 'steer' || route === 'followUp') {
|
|
679
|
-
this.pi.sendUserMessage(
|
|
681
|
+
this.pi.sendUserMessage(content, { deliverAs: route });
|
|
680
682
|
}
|
|
681
683
|
else {
|
|
682
684
|
throw new Error(`visible send requested for non-send route ${route}`);
|
|
@@ -694,52 +696,61 @@ class CanvasInboxWatcher {
|
|
|
694
696
|
requeueFromBatch(batch, entryIds) {
|
|
695
697
|
if (entryIds.size === 0)
|
|
696
698
|
return;
|
|
697
|
-
const
|
|
698
|
-
const
|
|
699
|
-
const tail = this.buffer.filter((entry) => !
|
|
700
|
-
this.buffer.splice(0, this.buffer.length, ...
|
|
699
|
+
const requeued = batch.filter((entry) => entryIds.has(entry.entry_id));
|
|
700
|
+
const requeuedIds = new Set(requeued.map((entry) => entry.entry_id));
|
|
701
|
+
const tail = this.buffer.filter((entry) => !requeuedIds.has(entry.entry_id));
|
|
702
|
+
this.buffer.splice(0, this.buffer.length, ...requeued, ...tail);
|
|
701
703
|
}
|
|
702
704
|
executePlan(nodeId, plan, stage) {
|
|
703
|
-
const
|
|
705
|
+
const requeueEntryIds = new Set();
|
|
704
706
|
const situationalContent = plan.hiddenEntries.length > 0
|
|
705
707
|
? this.situationalContent(nodeId, plan.hiddenEntries)
|
|
706
708
|
: undefined;
|
|
707
|
-
const
|
|
708
|
-
? this.digest(plan.visibleEntries, stage)
|
|
709
|
-
: undefined;
|
|
709
|
+
const deliveries = plan.visibleEntries.length > 0 ? this.digest(plan.visibleEntries, stage) : [];
|
|
710
710
|
let hiddenStartedTurn = false;
|
|
711
711
|
if (plan.hiddenEntries.length > 0) {
|
|
712
712
|
const result = this.sendHidden(nodeId, plan.hiddenEntries, situationalContent, stage);
|
|
713
713
|
if (result === 'inactive')
|
|
714
|
-
return { inactive: true,
|
|
714
|
+
return { inactive: true, requeueEntryIds };
|
|
715
715
|
if (result === 'failed') {
|
|
716
716
|
for (const entry of plan.hiddenEntries)
|
|
717
|
-
|
|
717
|
+
requeueEntryIds.add(entry.entry_id);
|
|
718
718
|
}
|
|
719
719
|
else {
|
|
720
720
|
hiddenStartedTurn = true;
|
|
721
721
|
}
|
|
722
722
|
}
|
|
723
723
|
if (!this.canMutate())
|
|
724
|
-
return { inactive: true,
|
|
724
|
+
return { inactive: true, requeueEntryIds };
|
|
725
|
+
// Route over the WHOLE visible set, not just the part sent below: a critical
|
|
726
|
+
// entry anywhere still aborts and requeues the batch before anything is sent.
|
|
725
727
|
const visibleRoute = this.visibleRoute(plan.visibleEntries, hiddenStartedTurn);
|
|
726
728
|
if (visibleRoute === 'critical') {
|
|
727
729
|
this.abortCritical();
|
|
728
730
|
if (!this.canMutate())
|
|
729
|
-
return { inactive: true,
|
|
731
|
+
return { inactive: true, requeueEntryIds };
|
|
730
732
|
for (const entry of plan.visibleEntries)
|
|
731
|
-
|
|
733
|
+
requeueEntryIds.add(entry.entry_id);
|
|
732
734
|
}
|
|
733
735
|
else if (visibleRoute === 'idle' || visibleRoute === 'steer' || visibleRoute === 'followUp') {
|
|
734
|
-
|
|
736
|
+
// A mixed batch splits into several deliveries, and exactly one goes out
|
|
737
|
+
// per cycle: pi marks the session streaming only after awaits inside
|
|
738
|
+
// prompt(), so two user sends in one tick start two concurrent agent runs.
|
|
739
|
+
// The rest requeue and go out on later ticks, in order, behind this one.
|
|
740
|
+
const [delivery, ...deferred] = deliveries;
|
|
741
|
+
for (const rest of deferred) {
|
|
742
|
+
for (const entry of rest.entries)
|
|
743
|
+
requeueEntryIds.add(entry.entry_id);
|
|
744
|
+
}
|
|
745
|
+
const result = this.sendVisible(delivery.entries, delivery.text, visibleRoute, stage);
|
|
735
746
|
if (result === 'inactive')
|
|
736
|
-
return { inactive: true,
|
|
747
|
+
return { inactive: true, requeueEntryIds };
|
|
737
748
|
if (result === 'failed') {
|
|
738
|
-
for (const entry of
|
|
739
|
-
|
|
749
|
+
for (const entry of delivery.entries)
|
|
750
|
+
requeueEntryIds.add(entry.entry_id);
|
|
740
751
|
}
|
|
741
752
|
}
|
|
742
|
-
return { inactive: false,
|
|
753
|
+
return { inactive: false, requeueEntryIds };
|
|
743
754
|
}
|
|
744
755
|
flushOrdinary(nodeId) {
|
|
745
756
|
if (this.buffer.length === 0)
|
|
@@ -754,7 +765,7 @@ class CanvasInboxWatcher {
|
|
|
754
765
|
if (execution.inactive)
|
|
755
766
|
return 'attempted';
|
|
756
767
|
this.buffer.splice(0, batch.length);
|
|
757
|
-
this.requeueFromBatch(batch, execution.
|
|
768
|
+
this.requeueFromBatch(batch, execution.requeueEntryIds);
|
|
758
769
|
return 'attempted';
|
|
759
770
|
}
|
|
760
771
|
coveredEntryIds(channel) {
|
|
@@ -22,6 +22,16 @@ interface PiLike {
|
|
|
22
22
|
* Each accumulated message is its own timestamped `<update>` element.
|
|
23
23
|
*/
|
|
24
24
|
export declare function formatPassive(entries: BrokerPassiveEntry[], reportNodes?: ReadonlyMap<string, unknown>): string;
|
|
25
|
+
/**
|
|
26
|
+
* Place the backlog block on a message without displacing a runtime card.
|
|
27
|
+
*
|
|
28
|
+
* A runtime card is a WHOLE-message envelope: it opens at byte 0 and closes at
|
|
29
|
+
* the last byte. The inbox watcher's own wake arrives through this same `input`
|
|
30
|
+
* event, so prepending to it would push the envelope off byte 0 and every
|
|
31
|
+
* reader would attribute the digest to the person. Inside the card, the backlog
|
|
32
|
+
* becomes the body's first child instead.
|
|
33
|
+
*/
|
|
34
|
+
export declare function placeBacklog(text: string, preText: string): string;
|
|
25
35
|
/**
|
|
26
36
|
* Register the passive-context drain on `pi`.
|
|
27
37
|
*
|
|
@@ -22,6 +22,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
22
22
|
import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
23
23
|
import { reportPathFromRef } from '../core/runtime/broker-extension-render.js';
|
|
24
24
|
import { drainBrokerPassive } from '../core/runtime/broker/passive.js';
|
|
25
|
+
import { parseCard } from '../shared/generated-context.js';
|
|
25
26
|
// Per-entry body cap so a single fat report can't blow the context budget. The
|
|
26
27
|
// full report stays on disk at `ref` if the agent needs more.
|
|
27
28
|
const BODY_CAP = 4_000;
|
|
@@ -93,8 +94,9 @@ export function formatPassive(entries, reportNodes = new Map()) {
|
|
|
93
94
|
const blocks = entries
|
|
94
95
|
.map((e) => {
|
|
95
96
|
const from = attr(e.from ?? 'system');
|
|
97
|
+
const nameAttr = e.from_name !== undefined && e.from_name !== '' ? ` from_name="${attr(e.from_name)}"` : '';
|
|
96
98
|
const refAttr = e.ref !== undefined && e.ref !== '' ? ` ref="${attr(e.ref)}"` : '';
|
|
97
|
-
return (`<update from="${from}" kind="${attr(e.kind)}" at="${attr(e.ts)}"${refAttr}>\n` +
|
|
99
|
+
return (`<update from="${from}"${nameAttr} kind="${attr(e.kind)}" at="${attr(e.ts)}"${refAttr}>\n` +
|
|
98
100
|
`${entryContent(e, reportNodes)}\n` +
|
|
99
101
|
`</update>`);
|
|
100
102
|
})
|
|
@@ -105,6 +107,25 @@ export function formatPassive(entries, reportNodes = new Map()) {
|
|
|
105
107
|
`${blocks}\n` +
|
|
106
108
|
`</passive-subscription-backlog>`);
|
|
107
109
|
}
|
|
110
|
+
const CARD_CLOSE = '</runtime>';
|
|
111
|
+
/**
|
|
112
|
+
* Place the backlog block on a message without displacing a runtime card.
|
|
113
|
+
*
|
|
114
|
+
* A runtime card is a WHOLE-message envelope: it opens at byte 0 and closes at
|
|
115
|
+
* the last byte. The inbox watcher's own wake arrives through this same `input`
|
|
116
|
+
* event, so prepending to it would push the envelope off byte 0 and every
|
|
117
|
+
* reader would attribute the digest to the person. Inside the card, the backlog
|
|
118
|
+
* becomes the body's first child instead.
|
|
119
|
+
*/
|
|
120
|
+
export function placeBacklog(text, preText) {
|
|
121
|
+
if (text.trim() === '')
|
|
122
|
+
return preText;
|
|
123
|
+
const card = parseCard({ role: 'user', content: text });
|
|
124
|
+
if (card === null)
|
|
125
|
+
return `${preText}\n\n${text}`;
|
|
126
|
+
const bodyStart = text.length - card.body.length - CARD_CLOSE.length;
|
|
127
|
+
return `${text.slice(0, bodyStart)}${preText}\n${text.slice(bodyStart)}`;
|
|
128
|
+
}
|
|
108
129
|
// ---------------------------------------------------------------------------
|
|
109
130
|
// Extension
|
|
110
131
|
// ---------------------------------------------------------------------------
|
|
@@ -125,8 +146,7 @@ export function registerCanvasPassiveContext(pi) {
|
|
|
125
146
|
return; // nothing accumulated → leave the message as-is
|
|
126
147
|
const state = await brokerExtensionState(nodeId);
|
|
127
148
|
const reportNodes = new Map(state.report_nodes.map((node) => [node.node_id, node]));
|
|
128
|
-
const
|
|
129
|
-
const text = event.text.trim() === '' ? preText : `${preText}\n\n${event.text}`;
|
|
149
|
+
const text = placeBacklog(event.text, formatPassive(drained, reportNodes));
|
|
130
150
|
return { action: 'transform', text, images: event.images };
|
|
131
151
|
}
|
|
132
152
|
catch {
|
|
@@ -51,7 +51,7 @@ interface PiLike {
|
|
|
51
51
|
}
|
|
52
52
|
/** Render the marker as a single compact divider; its full content remains in
|
|
53
53
|
* the message list for the model and generated-context presenters. */
|
|
54
|
-
export declare function renderReviewBoundary(
|
|
54
|
+
export declare function renderReviewBoundary(message: RenderedMessageLike, options: {
|
|
55
55
|
expanded?: boolean;
|
|
56
56
|
}, theme: ThemeLike): ComponentLike;
|
|
57
57
|
/**
|
|
@@ -6,18 +6,26 @@
|
|
|
6
6
|
// inherited context.
|
|
7
7
|
import { brokerExtensionState } from '../core/runtime/broker/daemon-ops.js';
|
|
8
8
|
import { isReviewBoundary, REVIEW_BOUNDARY_CUSTOM_TYPE } from '../core/runtime/session-visibility.js';
|
|
9
|
+
import { formatCard, parseCard } from '../shared/generated-context.js';
|
|
9
10
|
import { truncateToWidth } from './truncate.js';
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Collapsed-by-default rendering
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
/** Render the marker as a single compact divider; its full content remains in
|
|
14
15
|
* the message list for the model and generated-context presenters. */
|
|
15
|
-
export function renderReviewBoundary(
|
|
16
|
+
export function renderReviewBoundary(message, options, theme) {
|
|
16
17
|
const paint = (color, text) => typeof theme?.fg === 'function' ? theme.fg(color, text) : text;
|
|
18
|
+
const text = typeof message.content === 'string'
|
|
19
|
+
? message.content
|
|
20
|
+
: message.content.filter((block) => block.type === 'text' && typeof block.text === 'string').map((block) => block.text).join('\n');
|
|
21
|
+
const body = parseCard({ role: 'custom', customType: message.customType, content: text })?.body ?? text;
|
|
17
22
|
return {
|
|
18
23
|
render(width) {
|
|
19
24
|
const w = typeof width === 'number' && width > 0 ? width : 80;
|
|
20
|
-
|
|
25
|
+
const divider = paint('dim', truncateToWidth('[review boundary] earlier conversation is not shown', w));
|
|
26
|
+
if (options?.expanded !== true)
|
|
27
|
+
return [divider];
|
|
28
|
+
return [divider, '', ...body.split('\n').map((line) => paint('customMessageText', truncateToWidth(line, w)))];
|
|
21
29
|
},
|
|
22
30
|
invalidate() {
|
|
23
31
|
/* stateless — nothing to clear */
|
|
@@ -74,7 +82,7 @@ export function registerCanvasReviewBoundary(pi) {
|
|
|
74
82
|
].join('\n');
|
|
75
83
|
pi.sendMessage({
|
|
76
84
|
customType: REVIEW_BOUNDARY_CUSTOM_TYPE,
|
|
77
|
-
content,
|
|
85
|
+
content: formatCard('review-boundary', { review: reviewId, file: targetFile }, content),
|
|
78
86
|
display: true,
|
|
79
87
|
details: { reviewId, originNodeId, targetFile, kind: binding.kind ?? 'review' },
|
|
80
88
|
});
|
|
@@ -30,7 +30,7 @@ import { clearStopSignals, consumeStopSignals } from '../core/runtime/stop-signa
|
|
|
30
30
|
import { bindBrokerSession, brokerExtensionState, commitBrokerPersonaAck, relaunchBrokerRoot, settleBroker } from '../core/runtime/broker/daemon-ops.js';
|
|
31
31
|
import { emitEvent } from '../core/events/emit.js';
|
|
32
32
|
import { activeBackgroundBashJobs } from '../core/bash-jobs.js';
|
|
33
|
-
import { CONTEXT_NUDGE_CUSTOM_TYPE,
|
|
33
|
+
import { CONTEXT_NUDGE_CUSTOM_TYPE, formatCard } from '../shared/generated-context.js';
|
|
34
34
|
import { isReviewBoundary } from '../core/runtime/session-visibility.js';
|
|
35
35
|
import { runRefreshAbort } from '../core/runtime/broker/extension-abort.js';
|
|
36
36
|
/** Condense a tool call into one short line for the activity cue. Picks the most
|
|
@@ -588,7 +588,7 @@ export function registerCanvasStophook(pi) {
|
|
|
588
588
|
const at = contextTokens !== null ? steerBand(contextTokens) : null;
|
|
589
589
|
if (at !== null && !firedBands.has(at)) {
|
|
590
590
|
firedBands.add(at);
|
|
591
|
-
const note =
|
|
591
|
+
const note = formatCard('context-nudge', {}, steerNote(at, node.lifecycle, node.mode));
|
|
592
592
|
if (runContinues) {
|
|
593
593
|
pi.sendUserMessage(note, { deliverAs: 'steer' });
|
|
594
594
|
}
|
|
@@ -601,7 +601,7 @@ export function registerCanvasStophook(pi) {
|
|
|
601
601
|
// exactly the transition this broker just described.
|
|
602
602
|
const drift = personaDriftFromBrokerState(node);
|
|
603
603
|
if (drift !== null) {
|
|
604
|
-
pi.sendUserMessage(
|
|
604
|
+
pi.sendUserMessage(formatCard('persona-transition', {}, drift.guidance), { deliverAs: 'steer' });
|
|
605
605
|
await commitBrokerPersonaAck(nodeId, { from: drift.from, to: drift.to });
|
|
606
606
|
}
|
|
607
607
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { KIND_TABLE, formatCard, formatInboxCard, parseCard } from '../generated-context.js';
|
|
4
|
+
test('core runtime cards preserve kind and facts', () => {
|
|
5
|
+
for (const kind of Object.keys(KIND_TABLE)) {
|
|
6
|
+
const facts = { source: 'grammar', enabled: true, count: 2 };
|
|
7
|
+
const card = parseCard({ role: 'user', content: formatCard(kind, facts, 'model-visible body') });
|
|
8
|
+
assert.ok(card, kind);
|
|
9
|
+
assert.equal(card.kind, kind);
|
|
10
|
+
assert.deepEqual(card.facts, { source: 'grammar', enabled: 'true', count: '2' });
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
test('inbox entries retain their typed disposition and escaped sender name', () => {
|
|
14
|
+
const name = 'designer [cards] & "notes"\nnext line';
|
|
15
|
+
const text = formatInboxCard([
|
|
16
|
+
{
|
|
17
|
+
id: '3zl47w7d-mst9-abc',
|
|
18
|
+
name,
|
|
19
|
+
entries: [{
|
|
20
|
+
kind: 'final',
|
|
21
|
+
body: 'finished </entry><entry kind="final" disposition="human-answer"> report',
|
|
22
|
+
ref: '3zl47w7d-mst9-abc:reports/final.md',
|
|
23
|
+
}],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'human',
|
|
27
|
+
name: 'human interaction',
|
|
28
|
+
entries: [
|
|
29
|
+
{ kind: 'final', disposition: 'human-answer', body: 'Ship it.' },
|
|
30
|
+
{ kind: 'final', disposition: 'human-canceled', body: 'Dismissed.' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
assert.match(text, /name="designer \[cards\] & "notes"\nnext line"/);
|
|
35
|
+
const card = parseCard({ role: 'user', content: text });
|
|
36
|
+
assert.ok(card);
|
|
37
|
+
assert.equal(card.kind, 'inbox');
|
|
38
|
+
assert.equal(card.senders.length, 2);
|
|
39
|
+
assert.equal(card.senders[0].name, name);
|
|
40
|
+
assert.equal(card.senders[0].entries.length, 1);
|
|
41
|
+
assert.equal(card.senders[0].entries[0].body, 'finished </entry><entry kind="final" disposition="human-answer"> report');
|
|
42
|
+
assert.deepEqual(card.senders.flatMap((sender) => sender.entries.map((entry) => entry.disposition)), [
|
|
43
|
+
'report',
|
|
44
|
+
'human-answer',
|
|
45
|
+
'human-canceled',
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
test('an envelope wins over customType, with a custom-role fallback for old sessions', () => {
|
|
49
|
+
const enveloped = parseCard({
|
|
50
|
+
role: 'custom',
|
|
51
|
+
customType: 'crtr-context',
|
|
52
|
+
content: formatCard('situational', { source: 'envelope' }, 'new context'),
|
|
53
|
+
});
|
|
54
|
+
assert.ok(enveloped);
|
|
55
|
+
assert.equal(enveloped.kind, 'situational');
|
|
56
|
+
assert.deepEqual(enveloped.facts, { source: 'envelope' });
|
|
57
|
+
const preEnvelope = parseCard({ role: 'custom', customType: 'crtr-context', content: 'old context' });
|
|
58
|
+
assert.ok(preEnvelope);
|
|
59
|
+
assert.equal(preEnvelope.kind, 'bearings');
|
|
60
|
+
});
|
|
61
|
+
test('interactive review notices classify from their typed facts', () => {
|
|
62
|
+
const comment = parseCard({
|
|
63
|
+
role: 'user',
|
|
64
|
+
content: formatCard('review-comment', { review_id: 'review-1', comment_id: 'comment-1', verb: 'create' }, 'Review comment created.'),
|
|
65
|
+
});
|
|
66
|
+
assert.ok(comment);
|
|
67
|
+
assert.equal(comment.label, 'crtr review');
|
|
68
|
+
assert.equal(comment.summary, 'review comment created');
|
|
69
|
+
assert.deepEqual(comment.facts, { review_id: 'review-1', comment_id: 'comment-1', verb: 'create' });
|
|
70
|
+
const queued = parseCard({
|
|
71
|
+
role: 'user',
|
|
72
|
+
content: formatCard('review-queued', { review_id: 'review-1' }, 'Review submission is queued.'),
|
|
73
|
+
});
|
|
74
|
+
assert.ok(queued);
|
|
75
|
+
assert.equal(queued.summary, 'review submitted; completion pending');
|
|
76
|
+
});
|
|
77
|
+
test('only the whole outer runtime element classifies a user message', () => {
|
|
78
|
+
const body = 'A nested <runtime kind="inbox">is model-visible body content</runtime> remains content.';
|
|
79
|
+
const card = parseCard({ role: 'user', content: formatCard('revive', { reason: 'fresh' }, body) });
|
|
80
|
+
assert.ok(card);
|
|
81
|
+
assert.equal(card.kind, 'revive');
|
|
82
|
+
assert.equal(card.body, body);
|
|
83
|
+
assert.equal(parseCard({ role: 'user', content: ` ${formatCard('revive', {}, body)}` }), null);
|
|
84
|
+
assert.equal(parseCard({ role: 'user', content: 'ordinary person message' }), null);
|
|
85
|
+
});
|