@crouton-kit/crouter 0.3.18 → 0.3.19
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/build-root.js +6 -0
- package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
- package/dist/builtin-memory/crouter-development/personas.md +117 -0
- package/dist/builtin-memory/crouter-development/plugins.md +163 -0
- package/dist/builtin-memory/design.md +60 -0
- package/dist/builtin-memory/development.md +116 -0
- package/dist/builtin-memory/planning.md +66 -0
- package/dist/builtin-memory/spec.md +99 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -1
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/lifecycle/resident.md +1 -1
- package/dist/builtin-personas/lifecycle/terminal.md +5 -2
- package/dist/builtin-personas/orchestration-kernel.md +15 -24
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/orchestrator.md +1 -1
- package/dist/builtin-personas/waiting.md +8 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
- package/dist/builtin-views/_lib/states.mjs +161 -0
- package/dist/builtin-views/canvas/client.mjs +303 -0
- package/dist/builtin-views/canvas/view.mjs +576 -0
- package/dist/builtin-views/git-pr/client.mjs +440 -0
- package/dist/builtin-views/git-pr/view.mjs +675 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
- package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
- package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
- package/dist/builtin-views/inbox/view.mjs +889 -0
- package/dist/builtin-views/linkedin/client.mjs +610 -0
- package/dist/builtin-views/linkedin/view.mjs +1171 -0
- package/dist/clients/attach/attach-cmd.d.ts +2 -0
- package/dist/clients/attach/attach-cmd.js +354 -0
- package/dist/clients/attach/chat-view.d.ts +77 -0
- package/dist/clients/attach/chat-view.js +450 -0
- package/dist/clients/attach/clipboard-image.d.ts +16 -0
- package/dist/clients/attach/clipboard-image.js +113 -0
- package/dist/clients/attach/config-load.d.ts +31 -0
- package/dist/clients/attach/config-load.js +113 -0
- package/dist/clients/attach/extension-dialogs.d.ts +29 -0
- package/dist/clients/attach/extension-dialogs.js +101 -0
- package/dist/clients/attach/input-controller.d.ts +54 -0
- package/dist/clients/attach/input-controller.js +204 -0
- package/dist/clients/attach/slash-commands.d.ts +36 -0
- package/dist/clients/attach/slash-commands.js +200 -0
- package/dist/clients/attach/view-socket.d.ts +48 -0
- package/dist/clients/attach/view-socket.js +126 -0
- package/dist/commands/attention.js +3 -3
- package/dist/commands/canvas-prune.js +1 -1
- package/dist/commands/daemon.js +3 -2
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/queue.js +43 -8
- package/dist/commands/human/shared.d.ts +2 -0
- package/dist/commands/memory/find.d.ts +1 -0
- package/dist/commands/memory/find.js +180 -0
- package/dist/commands/memory/lint.d.ts +1 -0
- package/dist/commands/memory/lint.js +140 -0
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +79 -0
- package/dist/commands/memory/read.js +103 -0
- package/dist/commands/memory/shared.d.ts +30 -0
- package/dist/commands/memory/shared.js +122 -0
- package/dist/commands/memory/write.d.ts +1 -0
- package/dist/commands/memory/write.js +85 -0
- package/dist/commands/memory.d.ts +2 -0
- package/dist/commands/memory.js +27 -0
- package/dist/commands/node.d.ts +3 -2
- package/dist/commands/node.js +607 -61
- package/dist/commands/pkg/market-manage.js +1 -1
- package/dist/commands/push.js +6 -6
- package/dist/commands/revive.js +1 -1
- package/dist/commands/skill/author.js +1 -1
- package/dist/commands/skill/shared.d.ts +1 -8
- package/dist/commands/skill/shared.js +2 -55
- package/dist/commands/skill.js +9 -14
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/update.js +1 -1
- package/dist/commands/view-cycle.d.ts +2 -0
- package/dist/commands/view-cycle.js +125 -0
- package/dist/commands/view-list.d.ts +2 -0
- package/dist/commands/view-list.js +66 -0
- package/dist/commands/view-new.d.ts +2 -0
- package/dist/commands/view-new.js +70 -0
- package/dist/commands/view-pick.d.ts +2 -0
- package/dist/commands/view-pick.js +119 -0
- package/dist/commands/view-run.d.ts +2 -0
- package/dist/commands/view-run.js +191 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +29 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
- package/dist/core/__tests__/cascade-close.test.js +12 -2
- package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
- package/dist/core/__tests__/child-death-wake.test.js +245 -0
- package/dist/core/__tests__/context-intro.test.js +76 -62
- package/dist/core/__tests__/daemon-boot.test.js +14 -5
- package/dist/core/__tests__/daemon-liveness.test.js +34 -9
- package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
- package/dist/core/__tests__/detach-focus.test.js +206 -0
- package/dist/core/__tests__/draw-style.test.d.ts +1 -0
- package/dist/core/__tests__/draw-style.test.js +258 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
- package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
- package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
- package/dist/core/__tests__/helpers/harness.d.ts +9 -0
- package/dist/core/__tests__/helpers/harness.js +111 -1
- package/dist/core/__tests__/home-session.test.js +41 -8
- package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
- package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
- package/dist/core/__tests__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation.test.js +50 -33
- package/dist/core/__tests__/memory.test.js +23 -115
- package/dist/core/__tests__/placement-focus.test.js +5 -0
- package/dist/core/__tests__/placement-teardown.test.js +54 -11
- package/dist/core/__tests__/relaunch.test.js +4 -3
- package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
- package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
- package/dist/core/__tests__/spawn-root.test.js +10 -0
- package/dist/core/__tests__/spike-harness.test.js +1 -0
- package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
- package/dist/core/__tests__/wake-bearings.test.js +156 -0
- package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
- package/dist/core/__tests__/wake-origin.test.js +110 -0
- package/dist/core/bootstrap.js +1 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +24 -2
- package/dist/core/canvas/browse/model.d.ts +38 -2
- package/dist/core/canvas/browse/model.js +134 -10
- package/dist/core/canvas/browse/render.d.ts +6 -12
- package/dist/core/canvas/browse/render.js +72 -104
- package/dist/core/canvas/canvas.js +15 -22
- package/dist/core/canvas/db.js +46 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/pid.d.ts +4 -0
- package/dist/core/canvas/pid.js +23 -0
- package/dist/core/canvas/render.d.ts +11 -2
- package/dist/core/canvas/render.js +69 -0
- package/dist/core/canvas/types.d.ts +85 -3
- package/dist/core/canvas/wakeups.d.ts +76 -0
- package/dist/core/canvas/wakeups.js +185 -0
- package/dist/core/config.js +4 -1
- package/dist/core/frontmatter.js +37 -124
- package/dist/core/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -0
- package/dist/core/personas/loader.d.ts +7 -0
- package/dist/core/personas/loader.js +36 -5
- package/dist/core/personas/resolve.js +13 -3
- package/dist/core/predicate.d.ts +63 -0
- package/dist/core/predicate.js +189 -0
- package/dist/core/resolver.js +26 -5
- package/dist/core/runtime/bearings.d.ts +53 -12
- package/dist/core/runtime/bearings.js +132 -59
- package/dist/core/runtime/broker-cli.d.ts +1 -0
- package/dist/core/runtime/broker-cli.js +46 -0
- package/dist/core/runtime/broker-protocol.d.ts +332 -0
- package/dist/core/runtime/broker-protocol.js +153 -0
- package/dist/core/runtime/broker-sdk.d.ts +48 -0
- package/dist/core/runtime/broker-sdk.js +72 -0
- package/dist/core/runtime/broker.d.ts +55 -0
- package/dist/core/runtime/broker.js +1128 -0
- package/dist/core/runtime/close.js +35 -6
- package/dist/core/runtime/host.d.ts +53 -0
- package/dist/core/runtime/host.js +186 -0
- package/dist/core/runtime/kickoff.d.ts +2 -1
- package/dist/core/runtime/kickoff.js +91 -5
- package/dist/core/runtime/launch.d.ts +45 -2
- package/dist/core/runtime/launch.js +65 -2
- package/dist/core/runtime/lifecycle.js +23 -6
- package/dist/core/runtime/memory.d.ts +2 -42
- package/dist/core/runtime/memory.js +11 -162
- package/dist/core/runtime/nodes.d.ts +33 -0
- package/dist/core/runtime/nodes.js +59 -1
- package/dist/core/runtime/persona.js +21 -11
- package/dist/core/runtime/pi-vendored.d.ts +18 -0
- package/dist/core/runtime/pi-vendored.js +49 -0
- package/dist/core/runtime/placement.d.ts +32 -14
- package/dist/core/runtime/placement.js +206 -52
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +1 -12
- package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
- package/dist/core/runtime/{demote.js → recycle.js} +27 -11
- package/dist/core/runtime/reset.js +8 -6
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +19 -0
- package/dist/core/runtime/spawn.js +75 -22
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +6 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +28 -0
- package/dist/core/runtime/tmux.js +80 -6
- package/dist/core/scope.d.ts +11 -0
- package/dist/core/scope.js +39 -0
- package/dist/core/spawn.d.ts +5 -0
- package/dist/core/spawn.js +20 -2
- package/dist/core/substrate/gate.d.ts +13 -0
- package/dist/core/substrate/gate.js +21 -0
- package/dist/core/substrate/index.d.ts +7 -0
- package/dist/core/substrate/index.js +18 -0
- package/dist/core/substrate/on-read.d.ts +14 -0
- package/dist/core/substrate/on-read.js +292 -0
- package/dist/core/substrate/render.d.ts +25 -0
- package/dist/core/substrate/render.js +256 -0
- package/dist/core/substrate/schema.d.ts +76 -0
- package/dist/core/substrate/schema.js +124 -0
- package/dist/core/substrate/session-cache.d.ts +30 -0
- package/dist/core/substrate/session-cache.js +77 -0
- package/dist/core/substrate/subject.d.ts +41 -0
- package/dist/core/substrate/subject.js +54 -0
- package/dist/core/tui/contract.d.ts +83 -0
- package/dist/core/tui/contract.js +8 -0
- package/dist/core/tui/draw.d.ts +96 -0
- package/dist/core/tui/draw.js +339 -0
- package/dist/core/tui/host.d.ts +29 -0
- package/dist/core/tui/host.js +379 -0
- package/dist/core/tui/loader.d.ts +16 -0
- package/dist/core/tui/loader.js +94 -0
- package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
- package/dist/core/wake.d.ts +86 -0
- package/dist/core/wake.js +308 -0
- package/dist/daemon/crtrd.d.ts +29 -4
- package/dist/daemon/crtrd.js +662 -46
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
- package/dist/pi-extensions/canvas-commands.d.ts +3 -0
- package/dist/pi-extensions/canvas-commands.js +10 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
- package/dist/pi-extensions/canvas-context-intro.js +55 -15
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
- package/dist/pi-extensions/canvas-nav.d.ts +3 -0
- package/dist/pi-extensions/canvas-nav.js +87 -23
- package/dist/pi-extensions/canvas-stophook.js +17 -8
- package/dist/pi-extensions/canvas-view.d.ts +21 -0
- package/dist/pi-extensions/canvas-view.js +75 -0
- package/dist/prompts/skill.js +19 -26
- package/dist/prompts/view.d.ts +7 -0
- package/dist/prompts/view.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +1 -0
- package/package.json +9 -4
- package/dist/commands/skill/find.d.ts +0 -4
- package/dist/commands/skill/find.js +0 -257
- package/dist/commands/skill/read.js +0 -91
- /package/dist/commands/{skill → memory}/read.d.ts +0 -0
- /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/pi-extensions/__tests__/canvas-context-intro.test.ts
|
|
2
|
+
//
|
|
3
|
+
// BUG REGRESSION (crouter/CLAUDE.md testing policy):
|
|
4
|
+
// `crtr node new --fork-from <node>` caused AGENT IDENTITY CONFUSION. A fork
|
|
5
|
+
// inherits the SOURCE node's entire first-person conversation (pi COPIES it
|
|
6
|
+
// into the fork's session), so on boot the forked agent impersonated the
|
|
7
|
+
// source — it kept acting AS the source, even "monitoring itself" as a phantom
|
|
8
|
+
// child, until it happened to check CRTR_NODE_ID. Two root causes, both fixed
|
|
9
|
+
// here and pinned by these tests:
|
|
10
|
+
// 1. canvas-context-intro never re-asserted the node's OWN identity, so the
|
|
11
|
+
// copied first-person narrative won.
|
|
12
|
+
// 2. the idempotency guard misfired for a fork: the copied conversation
|
|
13
|
+
// already carries the SOURCE's <crtr-context> entry, so the guard treated
|
|
14
|
+
// it as "already present" and SUPPRESSED the fork's own intro entirely —
|
|
15
|
+
// the fork booted with the source's bearings (source id, source dir).
|
|
16
|
+
// Symptom write-up: nodes/mq4ns376-da6e56c9/reports/20260608T075810-final.md.
|
|
17
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { buildContextIntro, registerCanvasContextIntro, CONTEXT_INTRO_CUSTOM_TYPE, } from '../canvas-context-intro.js';
|
|
23
|
+
import { createNode } from '../../core/canvas/canvas.js';
|
|
24
|
+
import { closeDb } from '../../core/canvas/db.js';
|
|
25
|
+
let home;
|
|
26
|
+
let origNode;
|
|
27
|
+
function node(id, over = {}) {
|
|
28
|
+
return {
|
|
29
|
+
node_id: id,
|
|
30
|
+
name: id,
|
|
31
|
+
created: new Date().toISOString(),
|
|
32
|
+
cwd: '/tmp/work',
|
|
33
|
+
kind: 'general',
|
|
34
|
+
mode: 'base',
|
|
35
|
+
lifecycle: 'terminal',
|
|
36
|
+
status: 'active',
|
|
37
|
+
...over,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** A session entry as `sessionManager.getEntries()` would return it — a custom
|
|
41
|
+
* message carrying a bearings block (string content). */
|
|
42
|
+
function introEntry(content) {
|
|
43
|
+
return { type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE, content };
|
|
44
|
+
}
|
|
45
|
+
function makeFakePi() {
|
|
46
|
+
let handler;
|
|
47
|
+
return {
|
|
48
|
+
sent: [],
|
|
49
|
+
on(event, h) {
|
|
50
|
+
if (event === 'session_start')
|
|
51
|
+
handler = h;
|
|
52
|
+
},
|
|
53
|
+
sendMessage(m) {
|
|
54
|
+
this.sent.push(m);
|
|
55
|
+
},
|
|
56
|
+
registerMessageRenderer() {
|
|
57
|
+
/* display-only; irrelevant here */
|
|
58
|
+
},
|
|
59
|
+
fireSessionStart(entries) {
|
|
60
|
+
handler?.(undefined, { sessionManager: { getEntries: () => entries } });
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
before(() => {
|
|
65
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
66
|
+
});
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
closeDb();
|
|
69
|
+
if (home)
|
|
70
|
+
rmSync(home, { recursive: true, force: true });
|
|
71
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-context-intro-'));
|
|
72
|
+
process.env['CRTR_HOME'] = home;
|
|
73
|
+
delete process.env['CRTR_NODE_ID'];
|
|
74
|
+
});
|
|
75
|
+
after(() => {
|
|
76
|
+
closeDb();
|
|
77
|
+
if (home)
|
|
78
|
+
rmSync(home, { recursive: true, force: true });
|
|
79
|
+
delete process.env['CRTR_HOME'];
|
|
80
|
+
if (origNode === undefined)
|
|
81
|
+
delete process.env['CRTR_NODE_ID'];
|
|
82
|
+
else
|
|
83
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
84
|
+
});
|
|
85
|
+
test('boot intro asserts the node\'s OWN identity up front (regression: forked node impersonated source)', () => {
|
|
86
|
+
createNode(node('alpha', { name: 'fix-auth', kind: 'developer' }));
|
|
87
|
+
const intro = buildContextIntro('alpha');
|
|
88
|
+
assert.match(intro, /<crtr-identity>/, 'opens with an explicit identity block');
|
|
89
|
+
assert.match(intro, /You are node alpha/, 'names the node by its own id');
|
|
90
|
+
assert.match(intro, /kind developer/, 'states the node kind');
|
|
91
|
+
// Gated (Option A): a fresh (non-fork) node's bearings are its FIRST entry, so
|
|
92
|
+
// there is no earlier narrative to disown — the disown framing (the fork callout
|
|
93
|
+
// naming the copied history "inherited reference material") is fork-only and must
|
|
94
|
+
// NOT appear here.
|
|
95
|
+
assert.ok(!/inherited reference material/.test(intro), 'a non-fork node carries no disown-the-earlier-narrative line');
|
|
96
|
+
// The identity block must come BEFORE the <crtr-context> bearings, so it is the
|
|
97
|
+
// first thing the model reads — the orienting frame, not a trailing note.
|
|
98
|
+
assert.ok(intro.indexOf('<crtr-identity>') < intro.indexOf('<crtr-context'), 'identity assertion precedes the context bearings');
|
|
99
|
+
});
|
|
100
|
+
test('a FORK\'s intro names the source and disowns it (regression: fork acted AS the source)', () => {
|
|
101
|
+
// The source node whose conversation pi copied into the fork.
|
|
102
|
+
createNode(node('src7', { name: 'looking-pi-crouter', description: 'monitor-telemetry' }));
|
|
103
|
+
// The fork: fork_from provenance persisted at spawn.
|
|
104
|
+
createNode(node('fork9', { fork_from: 'src7' }));
|
|
105
|
+
const intro = buildContextIntro('fork9');
|
|
106
|
+
assert.match(intro, /You are node fork9/, 'the fork is told its OWN id');
|
|
107
|
+
assert.match(intro, /You are a FORK of src7/, 'names the fork source explicitly');
|
|
108
|
+
assert.match(intro, /You are NOT src7/, '"you are NOT the source" flag is present');
|
|
109
|
+
// Carries the source's human label so the agent recognizes the copied persona.
|
|
110
|
+
assert.match(intro, /looking-pi-crouter/, 'surfaces the source label');
|
|
111
|
+
// The fork DOES carry the disown framing — it has a real referent (copied history).
|
|
112
|
+
assert.match(intro, /inherited reference material/, 'the fork disowns the copied first-person narrative');
|
|
113
|
+
// It must NOT tell the fork it is the source node.
|
|
114
|
+
assert.ok(!/You are node src7/.test(intro), 'fork is never told it is the source node');
|
|
115
|
+
// A non-fork sibling gets the identity assertion but NO fork callout and NO
|
|
116
|
+
// disown line (gated on fork_from).
|
|
117
|
+
createNode(node('plain2'));
|
|
118
|
+
const plain = buildContextIntro('plain2');
|
|
119
|
+
assert.match(plain, /You are node plain2/);
|
|
120
|
+
assert.ok(!/FORK of/.test(plain), 'a fresh (non-fork) node carries no fork callout');
|
|
121
|
+
assert.ok(!/inherited reference material/.test(plain), 'a non-fork node carries no disown line');
|
|
122
|
+
});
|
|
123
|
+
test('session_start INJECTS the fork\'s own bearings despite the inherited source block (the misfiring-idempotency bug)', () => {
|
|
124
|
+
createNode(node('srcA'));
|
|
125
|
+
createNode(node('forkB', { fork_from: 'srcA' }));
|
|
126
|
+
// A fork boots with the SOURCE's whole conversation copied in — including the
|
|
127
|
+
// source's <crtr-context> intro entry (which names srcA, not forkB).
|
|
128
|
+
const inheritedSourceBlock = introEntry(buildContextIntro('srcA'));
|
|
129
|
+
process.env['CRTR_NODE_ID'] = 'forkB';
|
|
130
|
+
const pi = makeFakePi();
|
|
131
|
+
registerCanvasContextIntro(pi);
|
|
132
|
+
pi.fireSessionStart([inheritedSourceBlock]);
|
|
133
|
+
// The fix: the inherited block belongs to srcA, so it must NOT suppress
|
|
134
|
+
// forkB's own intro. forkB's bearings (with its identity assertion) ARE sent.
|
|
135
|
+
assert.equal(pi.sent.length, 1, 'fork injects its OWN bearings over the inherited source block');
|
|
136
|
+
assert.equal(pi.sent[0].customType, CONTEXT_INTRO_CUSTOM_TYPE);
|
|
137
|
+
assert.match(pi.sent[0].content, /You are node forkB/, 'injected bearings name the fork, not the source');
|
|
138
|
+
assert.match(pi.sent[0].content, /You are a FORK of srcA/, 'injected bearings carry the fork callout');
|
|
139
|
+
// The exact, machine-readable resume discriminator rides along (not sent to
|
|
140
|
+
// the LLM): it names THIS node, so a later resume tells our block apart from
|
|
141
|
+
// any inherited one without depending on prose.
|
|
142
|
+
assert.equal(pi.sent[0].details?.nodeId, 'forkB', 'injected block carries the exact node-id stamp');
|
|
143
|
+
});
|
|
144
|
+
test('resume idempotency uses the EXACT details stamp, independent of block text (regression hardening)', () => {
|
|
145
|
+
createNode(node('stampD'));
|
|
146
|
+
// A restored block that carries OUR exact stamp but whose TEXT does not mention
|
|
147
|
+
// our id. The old content-substring-only guard would re-inject (and accumulate)
|
|
148
|
+
// here; the exact `details.nodeId` match skips correctly.
|
|
149
|
+
const stampedNoIdInText = {
|
|
150
|
+
type: 'custom_message',
|
|
151
|
+
customType: CONTEXT_INTRO_CUSTOM_TYPE,
|
|
152
|
+
content: '<crtr-context dir="/elsewhere">bearings with no id in the text</crtr-context>',
|
|
153
|
+
details: { nodeId: 'stampD' },
|
|
154
|
+
};
|
|
155
|
+
process.env['CRTR_NODE_ID'] = 'stampD';
|
|
156
|
+
const pi = makeFakePi();
|
|
157
|
+
registerCanvasContextIntro(pi);
|
|
158
|
+
pi.fireSessionStart([stampedNoIdInText]);
|
|
159
|
+
assert.equal(pi.sent.length, 0, 'exact details-stamp match skips re-injection even when the text omits the id');
|
|
160
|
+
});
|
|
161
|
+
test('session_start stays IDEMPOTENT on a genuine resume (our own bearings already present)', () => {
|
|
162
|
+
createNode(node('resumeC'));
|
|
163
|
+
// A `--session` relaunch restores OUR conversation — our own bearings (naming
|
|
164
|
+
// resumeC) are already in history.
|
|
165
|
+
const ownBlock = introEntry(buildContextIntro('resumeC'));
|
|
166
|
+
process.env['CRTR_NODE_ID'] = 'resumeC';
|
|
167
|
+
const pi = makeFakePi();
|
|
168
|
+
registerCanvasContextIntro(pi);
|
|
169
|
+
pi.fireSessionStart([ownBlock]);
|
|
170
|
+
assert.equal(pi.sent.length, 0, 'our own bearings already present → no duplicate injection');
|
|
171
|
+
});
|
|
@@ -113,9 +113,11 @@ test('natural stop while awaiting a live worker → idle-release with NO push (n
|
|
|
113
113
|
// '%pane' ids below are never read by tmux). status='done' is reached by setting
|
|
114
114
|
// the runtime row directly (the branch reads getNode(nodeId).status).
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
|
-
test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
|
|
116
|
+
test('§5.1.1 truly-done + focused + dormant idle-release manager-not-focused → MANAGER TAKEOVER of the focus row', () => {
|
|
117
117
|
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
118
|
-
|
|
118
|
+
// mgr is dormant + idle-release — the ONLY dormant manager the daemon will
|
|
119
|
+
// revive INTO the frozen pane, so the ONLY dormant case that is a real takeover.
|
|
120
|
+
createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator', status: 'idle', intent: 'idle-release' }));
|
|
119
121
|
// M starts WITH a recorded LOCATION so the MINOR presence-null is observable.
|
|
120
122
|
createNode(node('M', { parent: 'mgr', lifecycle: 'terminal', pane: '%m', tmux_session: 'Suser', window: '@wm' }));
|
|
121
123
|
subscribe('mgr', 'M', true);
|
|
@@ -126,8 +128,9 @@ test('§5.1.1 truly-done + focused + manager-not-focused → MANAGER TAKEOVER of
|
|
|
126
128
|
registerCanvasStophook(pi);
|
|
127
129
|
let shutdown = false;
|
|
128
130
|
pi.fire('agent_end', stopEvent('done — pushed final'), { shutdown: () => { shutdown = true; } });
|
|
129
|
-
// managerId = M.parent = 'mgr' (not focused elsewhere, no live
|
|
130
|
-
// DORMANT-takeover path) → handFocusToManager repoints fM's
|
|
131
|
+
// managerId = M.parent = 'mgr' (idle-release, not focused elsewhere, no live
|
|
132
|
+
// pane here → the DORMANT-takeover path) → handFocusToManager repoints fM's
|
|
133
|
+
// occupant M→mgr (true ONLY because mgr is idle+idle-release). The
|
|
131
134
|
// daemon later revives mgr INTO M's frozen focus pane. Non-vacuous: a no-op (no
|
|
132
135
|
// handoff) impl leaves M as occupant, so getFocusByNode('mgr') is null AND
|
|
133
136
|
// getFocusByNode('M') still names fM — both asserts fail.
|
|
@@ -197,22 +200,26 @@ test('§5.1.4 truly-done + UNFOCUSED → no focus row created/touched, shuts dow
|
|
|
197
200
|
assert.equal(shutdown, true, 'an unfocused done node shuts down');
|
|
198
201
|
assert.equal(listFocuses().length, 0, 'no focus row was created or touched');
|
|
199
202
|
});
|
|
200
|
-
test('§5.1.5 awaiting +
|
|
203
|
+
test('§5.1.5 awaiting + FOCUSED → STAY ALIVE: pi keeps running (no release), focus row untouched (F3)', () => {
|
|
201
204
|
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
202
205
|
createNode(node('mgr', { parent: 'root', lifecycle: 'terminal', mode: 'orchestrator' }));
|
|
203
206
|
createNode(node('worker', { parent: 'mgr', lifecycle: 'terminal', status: 'active' }));
|
|
204
207
|
subscribe('root', 'mgr', true);
|
|
205
|
-
subscribe('mgr', 'worker', true); // mgr awaits a live worker → idle-release
|
|
208
|
+
subscribe('mgr', 'worker', true); // mgr awaits a live worker → would idle-release if UNfocused
|
|
206
209
|
openFocusRow('fMgr', '%g', 'Suser', 'mgr');
|
|
207
210
|
process.env['CRTR_NODE_ID'] = 'mgr';
|
|
208
211
|
const pi = makeFakePi();
|
|
209
212
|
registerCanvasStophook(pi);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
213
|
+
let shutdown = false;
|
|
214
|
+
pi.fire('agent_end', stopEvent('still waiting on the worker'), { shutdown: () => { shutdown = true; } });
|
|
215
|
+
// A FOCUSED awaiting node holds the user's viewport, so the awaiting branch
|
|
216
|
+
// keeps pi LIVE and dormant (the in-process inbox-watcher wakes it) instead of
|
|
217
|
+
// releasing + shutting down. It must NOT release, NOT shut down, and NOT touch
|
|
218
|
+
// the focus row. Non-vacuous: the old freeze impl flipped intent→idle-release
|
|
219
|
+
// and shut pi down; this pins all three as untouched.
|
|
220
|
+
assert.equal(getNode('mgr')?.status, 'active', 'a focused awaiting node stays active (not released)');
|
|
221
|
+
assert.equal(getNode('mgr')?.intent ?? null, null, 'no idle-release intent while focused');
|
|
222
|
+
assert.equal(shutdown, false, 'a focused awaiting node is NOT shut down — pi stays live');
|
|
216
223
|
assert.equal(getFocusByNode('mgr')?.focus_id, 'fMgr', 'the focus row is UNCHANGED — not closed, not handed off');
|
|
217
224
|
});
|
|
218
225
|
test('§5.1.7 resident attended (no live subs) → nothing happens; focus + status survive', () => {
|
|
@@ -8,6 +8,9 @@ interface CommandUI {
|
|
|
8
8
|
}
|
|
9
9
|
interface CommandCtx {
|
|
10
10
|
ui: CommandUI;
|
|
11
|
+
/** Current run mode: "tui" | "rpc" | "json" | "print". Guard "tui" for the
|
|
12
|
+
* interactive-only path (headless brokers bind 'print'). */
|
|
13
|
+
mode: string;
|
|
11
14
|
}
|
|
12
15
|
interface CustomMessage {
|
|
13
16
|
customType: string;
|
|
@@ -54,6 +54,16 @@ export function registerCanvasCommands(pi) {
|
|
|
54
54
|
return items.length > 0 ? items : null;
|
|
55
55
|
},
|
|
56
56
|
handler: async (args, ctx) => {
|
|
57
|
+
// Interactive-only. A headless (print-mode) broker never invokes /promote
|
|
58
|
+
// (promotion there runs `crtr node promote` directly), so the command is
|
|
59
|
+
// inert. Under tmux ctx.mode is always 'tui' — byte-identical there.
|
|
60
|
+
if (ctx.mode !== 'tui') {
|
|
61
|
+
try {
|
|
62
|
+
ctx.ui.notify('/promote needs the interactive TUI', 'warning');
|
|
63
|
+
}
|
|
64
|
+
catch { /* best-effort */ }
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
57
67
|
const kind = args.trim().toLowerCase();
|
|
58
68
|
ctx.ui.setStatus('crtr-promote', kind ? `promoting → ${kind}…` : 'promoting…');
|
|
59
69
|
const argv = ['node', 'promote', '--json'];
|
|
@@ -4,6 +4,18 @@ export declare const CONTEXT_INTRO_CUSTOM_TYPE = "crtr-context";
|
|
|
4
4
|
interface SessionEntryLike {
|
|
5
5
|
type: string;
|
|
6
6
|
customType?: string;
|
|
7
|
+
/** custom_message entries carry their injected content (string or blocks). */
|
|
8
|
+
content?: string | Array<{
|
|
9
|
+
type: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
}>;
|
|
12
|
+
/** Extension metadata we stamp on the block (NOT sent to the LLM). The
|
|
13
|
+
* authoritative idempotency discriminator: `nodeId` is the node the block
|
|
14
|
+
* belongs to, so a fork (whose copied source block carries the SOURCE's id)
|
|
15
|
+
* is told apart from a genuine resume by an EXACT id match. */
|
|
16
|
+
details?: {
|
|
17
|
+
nodeId?: string;
|
|
18
|
+
};
|
|
7
19
|
}
|
|
8
20
|
interface SessionStartCtxLike {
|
|
9
21
|
sessionManager: {
|
|
@@ -14,6 +26,11 @@ interface CustomMessageLike {
|
|
|
14
26
|
customType: string;
|
|
15
27
|
content: string;
|
|
16
28
|
display?: boolean;
|
|
29
|
+
/** Extension-only metadata (not sent to the LLM); used as the exact
|
|
30
|
+
* idempotency discriminator on resume — see SessionEntryLike.details. */
|
|
31
|
+
details?: {
|
|
32
|
+
nodeId: string;
|
|
33
|
+
};
|
|
17
34
|
}
|
|
18
35
|
/** The message handed to a message renderer. `content` is normally the string we
|
|
19
36
|
* sent, but pi types it as string-or-blocks, so we handle both. */
|
|
@@ -14,17 +14,26 @@
|
|
|
14
14
|
// session_start instead.)
|
|
15
15
|
//
|
|
16
16
|
// The block carries: the path to the node's own context dir and the framing for
|
|
17
|
-
// what belongs there (a shared document store for the other nodes).
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
17
|
+
// what belongs there (a shared document store for the other nodes). EVERY node
|
|
18
|
+
// also gets a <memory> block merging the indexes of its three scoped memory
|
|
19
|
+
// stores (user-global, project, node-local), each labeled with its dir — one
|
|
20
|
+
// consistent surface, since every node is born with all three stores. An
|
|
21
|
+
// orchestrator additionally gets the across-refresh-cycles framing (the one
|
|
22
|
+
// thing a terminal worker's bearings drop). The prose lives in
|
|
23
|
+
// core/runtime/bearings.ts (shared with the promotion guidance dump).
|
|
24
24
|
//
|
|
25
|
-
// IDEMPOTENT across resumes: a `--session` relaunch restores
|
|
26
|
-
//
|
|
27
|
-
// `sessionManager.getEntries()` and skips
|
|
25
|
+
// IDEMPOTENT across resumes, but FORK-AWARE: a `--session` relaunch restores OUR
|
|
26
|
+
// conversation (whose bearings name OUR node id), so the session_start handler
|
|
27
|
+
// sees it via `sessionManager.getEntries()` and skips — it never accumulates. A
|
|
28
|
+
// `--fork-from` boot, by contrast, COPIES the source node's whole conversation
|
|
29
|
+
// (whose bearings name the SOURCE's node id), so the handler must NOT treat that
|
|
30
|
+
// inherited block as ours; it only skips when a bearings block belonging to OUR
|
|
31
|
+
// node is already present (exact `details.nodeId` stamp, content-match
|
|
32
|
+
// fallback), otherwise it injects ours — whose <crtr-identity> block reasserts
|
|
33
|
+
// the fork's identity over the inherited persona. This is ONE of two reinforcing
|
|
34
|
+
// channels: spawn.ts ALSO prepends the same identity assertion to a fork's
|
|
35
|
+
// kickoff prompt (the turn-triggering message), so the override does not rest on
|
|
36
|
+
// a single trailing custom_message.
|
|
28
37
|
//
|
|
29
38
|
// COLLAPSED BY DEFAULT: a `registerMessageRenderer` keyed to our customType
|
|
30
39
|
// renders the block as a single one-line stub; the full body only appears when
|
|
@@ -62,6 +71,21 @@ function messageText(message) {
|
|
|
62
71
|
.map((c) => c.text)
|
|
63
72
|
.join('\n');
|
|
64
73
|
}
|
|
74
|
+
/** Plain text of a session entry's content (string or text blocks), '' when
|
|
75
|
+
* absent. Used by the idempotency guard to tell OUR own injected bearings
|
|
76
|
+
* (which name our node id) from a SOURCE's bearings copied in by `--fork-from`. */
|
|
77
|
+
function entryText(e) {
|
|
78
|
+
const c = e.content;
|
|
79
|
+
if (typeof c === 'string')
|
|
80
|
+
return c;
|
|
81
|
+
if (Array.isArray(c)) {
|
|
82
|
+
return c
|
|
83
|
+
.filter((b) => typeof b?.text === 'string')
|
|
84
|
+
.map((b) => b.text)
|
|
85
|
+
.join('\n');
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
65
89
|
/** Hard-wrap a single logical line to `width` columns (content carries no ANSI).
|
|
66
90
|
* Code-point aware so wide-string slicing never splits a surrogate pair; the
|
|
67
91
|
* bearings prose is plain text, so code-point count == visible columns. */
|
|
@@ -140,19 +164,35 @@ export function registerCanvasContextIntro(pi) {
|
|
|
140
164
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
141
165
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
142
166
|
return; // not a canvas node
|
|
143
|
-
// Idempotent
|
|
144
|
-
|
|
167
|
+
// Idempotent on RESUME, but NOT fooled by a FORK. A `--session` relaunch
|
|
168
|
+
// restores OUR conversation, whose bearings name OUR node id — skip then,
|
|
169
|
+
// so the block never accumulates. A `--fork-from` boot instead copies the
|
|
170
|
+
// SOURCE node's whole conversation (its bearings name ITS node id, not
|
|
171
|
+
// ours), so a naive "any crtr-context present?" check would suppress our
|
|
172
|
+
// own intro and let the fork inherit — and impersonate — the source. So we
|
|
173
|
+
// only skip when a bearings block belonging to OUR node is already present.
|
|
174
|
+
// Primary discriminator: the EXACT `details.nodeId` stamp (machine-
|
|
175
|
+
// readable, copied on fork, never sent to the LLM). Fallback: a substring
|
|
176
|
+
// match on the block text (our id appears in the identity line AND the
|
|
177
|
+
// context-dir path `…/nodes/<nodeId>/context`) — covers legacy blocks
|
|
178
|
+
// persisted before the stamp existed. Either match ⇒ this is our own
|
|
179
|
+
// resume, not an inherited fork block, so skip.
|
|
180
|
+
const ours = ctx.sessionManager
|
|
145
181
|
.getEntries()
|
|
146
|
-
.some((e) => e.type === 'custom_message' &&
|
|
147
|
-
|
|
182
|
+
.some((e) => e.type === 'custom_message' &&
|
|
183
|
+
e.customType === CONTEXT_INTRO_CUSTOM_TYPE &&
|
|
184
|
+
(e.details?.nodeId === nodeId || entryText(e).includes(nodeId)));
|
|
185
|
+
if (ours)
|
|
148
186
|
return;
|
|
149
187
|
// No delivery options: at the idle start of a session this is pushed onto
|
|
150
188
|
// the (still empty) message list and persisted immediately, so it precedes
|
|
151
|
-
// the node's first prompt.
|
|
189
|
+
// the node's first prompt. The `details.nodeId` stamp rides along as the
|
|
190
|
+
// exact resume discriminator (not shown to the LLM).
|
|
152
191
|
pi.sendMessage({
|
|
153
192
|
customType: CONTEXT_INTRO_CUSTOM_TYPE,
|
|
154
193
|
content: buildContextIntro(nodeId),
|
|
155
194
|
display: true,
|
|
195
|
+
details: { nodeId },
|
|
156
196
|
});
|
|
157
197
|
}
|
|
158
198
|
catch {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** before_agent_start: pi hands us the assembled system prompt; returning a new
|
|
2
|
+
* `systemPrompt` replaces it for this agent run (pi rebuilds it each run, so
|
|
3
|
+
* splicing fresh every time is idempotent — never cumulative). */
|
|
4
|
+
interface BeforeAgentStartEventLike {
|
|
5
|
+
systemPrompt: string;
|
|
6
|
+
}
|
|
7
|
+
interface BeforeAgentStartResultLike {
|
|
8
|
+
systemPrompt: string;
|
|
9
|
+
}
|
|
10
|
+
/** tool_result: fired after a tool returns. `content` is the tool's output
|
|
11
|
+
* blocks; returning a new `content` replaces what the model sees. */
|
|
12
|
+
interface ContentBlockLike {
|
|
13
|
+
type: string;
|
|
14
|
+
text?: string;
|
|
15
|
+
}
|
|
16
|
+
interface ToolResultEventLike {
|
|
17
|
+
toolName: string;
|
|
18
|
+
isError?: boolean;
|
|
19
|
+
/** The tool's resolved input. For `read` it carries the file path under
|
|
20
|
+
* `path` (or the legacy `file_path`). Typed loosely; narrowed at use. */
|
|
21
|
+
input?: unknown;
|
|
22
|
+
content: ContentBlockLike[];
|
|
23
|
+
}
|
|
24
|
+
interface ToolResultCtxLike {
|
|
25
|
+
/** The session cwd — used to resolve a relative read path. */
|
|
26
|
+
cwd?: string;
|
|
27
|
+
}
|
|
28
|
+
type ToolResultResultLike = {
|
|
29
|
+
content: ContentBlockLike[];
|
|
30
|
+
} | void;
|
|
31
|
+
interface PiLike {
|
|
32
|
+
on(event: 'before_agent_start', handler: (event: BeforeAgentStartEventLike) => BeforeAgentStartResultLike | void | Promise<BeforeAgentStartResultLike | void>): void;
|
|
33
|
+
on(event: 'session_start', handler: (event: unknown, ctx: unknown) => void): void;
|
|
34
|
+
on(event: 'tool_result', handler: (event: ToolResultEventLike, ctx: ToolResultCtxLike) => ToolResultResultLike | Promise<ToolResultResultLike>): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Register the document substrate's two render hooks on `pi`.
|
|
38
|
+
*
|
|
39
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent — a non-canvas pi
|
|
40
|
+
* session gets neither hook. Each handler is independently wrapped so a failure
|
|
41
|
+
* degrades to a no-op (boot proceeds, the read returns) rather than bricking.
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerCanvasDocSubstrate(pi: PiLike): void;
|
|
44
|
+
export default registerCanvasDocSubstrate;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// canvas-doc-substrate.ts — the document substrate's two render hooks, owned by
|
|
2
|
+
// a single self-contained canvas pi extension.
|
|
3
|
+
//
|
|
4
|
+
// Loaded into a canvas node's pi process via the node's launch.extensions list
|
|
5
|
+
// (registered in CANVAS_EXTENSIONS at the flip — see the note at the bottom of
|
|
6
|
+
// this file; AUTHOR-ONLY for now, NOT yet registered). INERT when CRTR_NODE_ID
|
|
7
|
+
// is absent (a plain pi session or legacy job agent loads it as a no-op).
|
|
8
|
+
//
|
|
9
|
+
// It owns the substrate's two new pi hooks:
|
|
10
|
+
//
|
|
11
|
+
// 1. before_agent_start — the BOOT system-prompt half. Splices the rendered
|
|
12
|
+
// `## Skills` + `## Preferences` sections into `event.systemPrompt`, right
|
|
13
|
+
// after pi's native "Available tools" list (before the `\n\nGuidelines:`
|
|
14
|
+
// anchor), so the substrate's skills/preferences sit in the tool-selection
|
|
15
|
+
// frame the agent reads while choosing a capability — mirroring the
|
|
16
|
+
// personal crouter-help.ts anchor logic. Falls back to appending when the
|
|
17
|
+
// anchor is absent.
|
|
18
|
+
//
|
|
19
|
+
// 2. tool_result (gated on `read`) — the ON-READ half. For each non-error
|
|
20
|
+
// `read`, surfaces the substrate docs that should appear alongside the file
|
|
21
|
+
// (positional `.crouter/memory/` ancestors + `applies-to` globs) at their
|
|
22
|
+
// file-read-visibility rung, prepended as a `<auto-loaded-context>` block
|
|
23
|
+
// before the file contents. A per-session realpath set dedups so the same
|
|
24
|
+
// doc is not re-injected on repeat reads.
|
|
25
|
+
//
|
|
26
|
+
// Both handler bodies are wrapped in try/catch and degrade to INERT on error —
|
|
27
|
+
// a substrate bug must never brick node boot or break a `read`, only quietly
|
|
28
|
+
// stop surfacing docs. The pure render lives in core/substrate/ (render.ts +
|
|
29
|
+
// on-read.ts); this file is only the pi-hook plumbing.
|
|
30
|
+
//
|
|
31
|
+
// Plain TS-with-types — NO imports from @earendil-works/* (a local structural
|
|
32
|
+
// PiLike interface stands in), so it compiles inside crouter's own tsc build
|
|
33
|
+
// with no dep on the pi packages. Mirrors canvas-context-intro.ts.
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { join, resolve } from 'node:path';
|
|
36
|
+
import { renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
|
|
37
|
+
import { clearSessionCache } from '../core/substrate/session-cache.js';
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Extension
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/** The stable seam that closes pi's native "Available tools" list. We splice the
|
|
42
|
+
* substrate's boot sections right before it — the agent decides which capability
|
|
43
|
+
* to reach for while reading the tools, so skills/preferences must sit there,
|
|
44
|
+
* not far below. Mirrors crouter-help.ts. Falls back to appending if absent. */
|
|
45
|
+
const TOOLS_ANCHOR = '\n\nGuidelines:';
|
|
46
|
+
/**
|
|
47
|
+
* Register the document substrate's two render hooks on `pi`.
|
|
48
|
+
*
|
|
49
|
+
* Returns immediately (inert) when CRTR_NODE_ID is absent — a non-canvas pi
|
|
50
|
+
* session gets neither hook. Each handler is independently wrapped so a failure
|
|
51
|
+
* degrades to a no-op (boot proceeds, the read returns) rather than bricking.
|
|
52
|
+
*/
|
|
53
|
+
export function registerCanvasDocSubstrate(pi) {
|
|
54
|
+
const nodeId = process.env['CRTR_NODE_ID'];
|
|
55
|
+
if (nodeId === undefined || nodeId.trim() === '')
|
|
56
|
+
return; // not a canvas node — inert
|
|
57
|
+
// Per-session set of injected doc realpaths → a doc surfaces at most once per
|
|
58
|
+
// session across repeated reads. Cleared on every session_start (so a resume
|
|
59
|
+
// starts fresh, matching the on-read precedent).
|
|
60
|
+
// Also clears the per-session substrate parse cache so the corpus is re-scanned
|
|
61
|
+
// fresh for each new session (avoids stale docs after a skill/memory write).
|
|
62
|
+
const injectedDocs = new Set();
|
|
63
|
+
pi.on('session_start', () => {
|
|
64
|
+
injectedDocs.clear();
|
|
65
|
+
clearSessionCache();
|
|
66
|
+
});
|
|
67
|
+
// 1. BOOT system-prompt half — splice `## Skills` + `## Preferences`.
|
|
68
|
+
pi.on('before_agent_start', (event) => {
|
|
69
|
+
try {
|
|
70
|
+
const sections = [renderSkillsSection(nodeId), renderPreferencesSection(nodeId)].filter((s) => s !== '');
|
|
71
|
+
if (sections.length === 0)
|
|
72
|
+
return; // nothing eligible — leave the prompt untouched
|
|
73
|
+
const block = sections.join('\n\n');
|
|
74
|
+
const idx = event.systemPrompt.indexOf(TOOLS_ANCHOR);
|
|
75
|
+
if (idx === -1) {
|
|
76
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
systemPrompt: `${event.systemPrompt.slice(0, idx)}\n\n${block}${event.systemPrompt.slice(idx)}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return; // inert on error — never brick node boot
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// 2. ON-READ half — augment a non-error `read` result with surfacing docs.
|
|
87
|
+
pi.on('tool_result', (event, ctx) => {
|
|
88
|
+
try {
|
|
89
|
+
if (event.toolName !== 'read' || event.isError === true)
|
|
90
|
+
return;
|
|
91
|
+
const input = event.input;
|
|
92
|
+
const rawPath = input?.path ?? input?.file_path;
|
|
93
|
+
if (rawPath === undefined || rawPath === '')
|
|
94
|
+
return;
|
|
95
|
+
// Resolve to an absolute path (renderOnReadDocs realpaths it). pi's read
|
|
96
|
+
// tool expands a leading `~`, but event.input keeps the raw form, so
|
|
97
|
+
// expand it ourselves before resolving against the session cwd.
|
|
98
|
+
const cwd = typeof ctx?.cwd === 'string' && ctx.cwd !== '' ? ctx.cwd : process.cwd();
|
|
99
|
+
const expanded = rawPath.startsWith('~') ? join(homedir(), rawPath.slice(1)) : rawPath;
|
|
100
|
+
const absFile = resolve(cwd, expanded);
|
|
101
|
+
const injected = renderOnReadDocs(nodeId, absFile, injectedDocs);
|
|
102
|
+
if (injected === '')
|
|
103
|
+
return; // nothing surfaced — pass the read through unchanged
|
|
104
|
+
// Prepend the surfacing docs ahead of the file contents.
|
|
105
|
+
return { content: [{ type: 'text', text: injected }, ...event.content] };
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return; // inert on error — a bug must degrade a read, never break it
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export default registerCanvasDocSubstrate;
|
|
@@ -15,6 +15,9 @@ interface UIContext {
|
|
|
15
15
|
}
|
|
16
16
|
interface ExtensionCtx {
|
|
17
17
|
ui: UIContext;
|
|
18
|
+
/** Current run mode: "tui" | "rpc" | "json" | "print". The nav chrome +
|
|
19
|
+
* ask-poll timer are interactive-only; headless brokers bind 'print'. */
|
|
20
|
+
mode: string;
|
|
18
21
|
}
|
|
19
22
|
interface CommandCtx {
|
|
20
23
|
ui: UIContext;
|