@north-light/crouter 0.3.180 → 0.3.182
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/client.d.ts +10 -1
- package/dist/api/client.js +13 -0
- package/dist/api/dto/broker.d.ts +32 -0
- package/dist/api/dto/crons.d.ts +17 -0
- package/dist/api/dto/memory.d.ts +17 -0
- package/dist/api/dto/memory.js +6 -0
- package/dist/api/dto/messages.d.ts +5 -0
- package/dist/api/dto/reviews.d.ts +8 -4
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/routes.d.ts +2 -0
- package/dist/api/routes.js +4 -0
- package/dist/build-root.d.ts +7 -0
- package/dist/build-root.js +21 -0
- package/dist/builtin-memory/insights/init.md +48 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/__tests__/insights-active-init.test.ts +98 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +7 -50
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +16 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/pi-shell-runner.ts +34 -0
- package/dist/cli.js +1 -2
- package/dist/clients/attach/__tests__/context-message.test.js +5 -2
- package/dist/clients/attach/assets/README.md +7 -0
- package/dist/clients/attach/assets/whip-06.mp3 +0 -0
- package/dist/clients/attach/assets/whip-crack.mp3 +0 -0
- package/dist/clients/attach/assets/whip-snap.mp3 +0 -0
- package/dist/clients/attach/chrome/canvas-panels.d.ts +7 -1
- package/dist/clients/attach/chrome/canvas-panels.js +20 -3
- package/dist/clients/attach/chrome/review-wait.d.ts +6 -0
- package/dist/clients/attach/chrome/review-wait.js +22 -0
- package/dist/clients/attach/chrome/roster.js +23 -2
- package/dist/clients/attach/chrome/widgets.js +1 -1
- package/dist/clients/attach/input/controller.js +4 -3
- package/dist/clients/attach/overlays/mcp.js +3 -1
- package/dist/clients/attach/render/chat-view.js +1 -1
- package/dist/clients/attach/session/whip.d.ts +1 -0
- package/dist/clients/attach/session/whip.js +26 -0
- package/dist/clients/attach/slash/dispatch.js +2 -0
- package/dist/clients/attach/viewer.js +578 -573
- package/dist/clients/inbox/review/document-surface.d.ts +1 -1
- package/dist/clients/inbox/review/document-surface.js +4 -4
- package/dist/clients/inbox/review/launch.js +16 -4
- package/dist/clients/inbox/review/review-client.d.ts +9 -4
- package/dist/clients/inbox/review/review-client.js +3 -0
- package/dist/commands/cron.js +30 -8
- package/dist/commands/human/prompts.d.ts +7 -2
- package/dist/commands/human/prompts.js +15 -10
- package/dist/commands/human.js +1 -2
- package/dist/commands/memory/find.js +11 -8
- package/dist/commands/memory/read.js +111 -11
- package/dist/commands/memory/write.js +1 -1
- package/dist/commands/memory.js +1 -1
- package/dist/commands/pkg/market-manage.d.ts +13 -0
- package/dist/commands/pkg/market-manage.js +39 -33
- package/dist/commands/pkg/plugin-inspect.js +4 -3
- package/dist/commands/pkg/plugin-manage.js +12 -11
- package/dist/commands/surface/node/focus.js +1 -2
- package/dist/commands/sys/doctor.js +4 -4
- package/dist/commands/sys/setup-core.d.ts +14 -7
- package/dist/commands/sys/setup-core.js +66 -11
- package/dist/commands/sys/setup-wizard.js +2 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/core/__tests__/cron-held-settlement.test.d.ts +1 -0
- package/dist/core/__tests__/cron-held-settlement.test.js +222 -0
- package/dist/core/__tests__/helpers/harness.js +1 -2
- package/dist/core/__tests__/phase4-review-store.test.js +1 -0
- package/dist/core/__tests__/serial/command-plugins.test.js +88 -1
- package/dist/core/__tests__/session-model.test.js +5 -3
- package/dist/core/bootstrap.d.ts +0 -4
- package/dist/core/bootstrap.js +1 -55
- package/dist/core/canvas/crons.d.ts +54 -2
- package/dist/core/canvas/crons.js +48 -4
- package/dist/core/canvas/db.js +23 -0
- package/dist/core/command-manifests/manifest.d.ts +11 -0
- package/dist/core/command-manifests/manifest.js +45 -4
- package/dist/core/command-manifests/schema.d.ts +1 -1
- package/dist/core/command-plugins/bundle.d.ts +1 -0
- package/dist/core/command-plugins/bundle.js +3 -3
- package/dist/core/command-plugins/discovery.d.ts +5 -2
- package/dist/core/command-plugins/discovery.js +5 -5
- package/dist/core/command-plugins/help-addenda.d.ts +12 -0
- package/dist/core/command-plugins/help-addenda.js +30 -0
- package/dist/core/command.js +25 -2
- package/dist/core/config.js +0 -1
- package/dist/core/human/convention.d.ts +0 -1
- package/dist/core/human/convention.js +0 -6
- package/dist/core/keybindings/inbox.d.ts +6 -8
- package/dist/core/keybindings/inbox.js +6 -15
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/index.js +1 -1
- package/dist/core/memory/doc-link-grammar.js +4 -1
- package/dist/core/memory-resolver.d.ts +28 -4
- package/dist/core/memory-resolver.js +51 -39
- package/dist/core/review/stage.js +1 -0
- package/dist/core/review/store.d.ts +5 -0
- package/dist/core/review/store.js +10 -0
- package/dist/core/review/types.d.ts +4 -0
- package/dist/core/runtime/broker/event-projection.d.ts +8 -1
- package/dist/core/runtime/broker/event-projection.js +25 -1
- package/dist/core/runtime/broker/frame-dispatch.d.ts +2 -0
- package/dist/core/runtime/broker/frame-dispatch.js +50 -8
- package/dist/core/runtime/broker/inbox.d.ts +4 -0
- package/dist/core/runtime/broker/inbox.js +17 -8
- package/dist/core/runtime/broker/message-ledger.d.ts +53 -0
- package/dist/core/runtime/broker/message-ledger.js +143 -0
- package/dist/core/runtime/broker/rebind.js +14 -0
- package/dist/core/runtime/broker-protocol.d.ts +46 -1
- package/dist/core/runtime/broker.js +11 -2
- package/dist/core/runtime/interactive-deliver.d.ts +5 -2
- package/dist/core/runtime/interactive-deliver.js +6 -3
- package/dist/core/runtime/shell-expansion.d.ts +32 -0
- package/dist/core/runtime/shell-expansion.js +102 -0
- package/dist/core/session-model/session-state.d.ts +9 -4
- package/dist/core/session-model/session-state.js +5 -1
- package/dist/daemon/api/handlers/broker-ops.js +8 -0
- package/dist/daemon/api/handlers/crons.js +14 -1
- package/dist/daemon/api/handlers/inbox.js +5 -0
- package/dist/daemon/api/handlers/memory.d.ts +2 -0
- package/dist/daemon/api/handlers/memory.js +48 -0
- package/dist/daemon/api/handlers/messages.js +7 -1
- package/dist/daemon/api/handlers/reviews.js +7 -5
- package/dist/daemon/api/map.js +3 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/cron-run.js +71 -3
- package/dist/daemon/crtrd.js +3 -0
- package/dist/daemon/reconcilers/pending-review-submit.d.ts +7 -0
- package/dist/daemon/reconcilers/pending-review-submit.js +35 -0
- package/dist/daemon/review/companion.d.ts +8 -0
- package/dist/daemon/review/companion.js +35 -0
- package/dist/daemon/review/deliver.js +2 -1
- package/dist/daemon/review/finish.d.ts +29 -2
- package/dist/daemon/review/finish.js +75 -2
- package/dist/pi-extensions/canvas-inbox-watcher.js +34 -1
- package/dist/shared/generated-context.d.ts +3 -4
- package/dist/shared/generated-context.js +24 -6
- package/dist/types.d.ts +0 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// message-ledger.ts — broker-owned message identity across pi's id-less queues.
|
|
2
|
+
//
|
|
3
|
+
// pi's steering/follow-up queues and user messages carry TEXT only: `steer()`
|
|
4
|
+
// pushes bare strings, `queue_update` relays bare string arrays, and retirement
|
|
5
|
+
// (on user `message_start`) is a first-index-of-text splice. Every downstream
|
|
6
|
+
// consumer was therefore forced into order/text heuristics to match its own
|
|
7
|
+
// optimistic send to the wake that echoed it — wrong under duplicates,
|
|
8
|
+
// cross-surface sends, and reconnect replay.
|
|
9
|
+
//
|
|
10
|
+
// The broker is the ONLY writer to the engine (every prompt/steer/follow_up/
|
|
11
|
+
// deliver funnels through driveEngine) and observes pi's event stream
|
|
12
|
+
// synchronously in-process, so it can own identity at the wire boundary: mint
|
|
13
|
+
// (or adopt the sender's) id per accepted frame, mirror pi's queue state by
|
|
14
|
+
// reconciling pi's own `queue_update` events, and attach the retired entry's id
|
|
15
|
+
// to the relayed user `message_start`. Same deterministic algorithm over the
|
|
16
|
+
// same data in the same process as pi's own bookkeeping — it cannot drift.
|
|
17
|
+
//
|
|
18
|
+
// pi ordering facts this relies on (verified at pi 0.83.0, agent-session.js):
|
|
19
|
+
// - `_queueSteer`/`_queueFollowUp` push the text and emit `queue_update`
|
|
20
|
+
// synchronously inside the steer/followUp call.
|
|
21
|
+
// - On a queued message's user `message_start`, `_handleAgentEvent` splices
|
|
22
|
+
// the queue and emits `queue_update` BEFORE emitting the `message_start`,
|
|
23
|
+
// so a retirement's queue_update always precedes its message and two
|
|
24
|
+
// removals never interleave.
|
|
25
|
+
// - An idle `prompt()` never touches a queue — its user message matches a
|
|
26
|
+
// pending dispatch intent by text instead.
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
/** Dispatch intents not yet observed anywhere: bounded FIFO so an intent whose
|
|
29
|
+
* text pi transformed (and so never matches) cannot accumulate forever. */
|
|
30
|
+
const MAX_PENDING_INTENTS = 32;
|
|
31
|
+
/** Recent id-bearing user-message dispatches carried in `welcome` so a
|
|
32
|
+
* reattaching follower can key its snapshot's trailing user wakes. */
|
|
33
|
+
const MAX_RECENT = 8;
|
|
34
|
+
export class MessageIdLedger {
|
|
35
|
+
steering = [];
|
|
36
|
+
followUp = [];
|
|
37
|
+
pendingIntents = [];
|
|
38
|
+
stagedRetirementId = null;
|
|
39
|
+
recent = [];
|
|
40
|
+
/** Record an accepted client frame's identity before its engine call runs.
|
|
41
|
+
* `id` is the sender's `message_id` when it minted one, else broker-minted. */
|
|
42
|
+
noteDispatch(id, text) {
|
|
43
|
+
this.pendingIntents.push({ id, text });
|
|
44
|
+
if (this.pendingIntents.length > MAX_PENDING_INTENTS)
|
|
45
|
+
this.pendingIntents.shift();
|
|
46
|
+
}
|
|
47
|
+
/** Remove a noted intent whose engine call rejected — the message will never
|
|
48
|
+
* appear in a queue or as a user message. No-op if already adopted. */
|
|
49
|
+
dropIntent(id) {
|
|
50
|
+
const idx = this.pendingIntents.findIndex((e) => e.id === id);
|
|
51
|
+
if (idx !== -1)
|
|
52
|
+
this.pendingIntents.splice(idx, 1);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fold one pi `queue_update` into the ledger and return the id arrays
|
|
56
|
+
* parallel to its text arrays (total: every position gets an id).
|
|
57
|
+
*
|
|
58
|
+
* Per-queue positional diff against the mirrored entries: a text matching an
|
|
59
|
+
* old entry at-or-after the cursor keeps its id (everything skipped is
|
|
60
|
+
* removed); an unmatched text ADOPTS the first pending intent with equal
|
|
61
|
+
* text, else mints a fresh id. Exactly ONE removal across both queues is
|
|
62
|
+
* pi's per-message retirement (its queue_update precedes the user
|
|
63
|
+
* `message_start` it belongs to) and is staged for `takeUserMessageId`;
|
|
64
|
+
* 0 or >1 removals (an enqueue, clearQueue/dequeue, abort) stage nothing.
|
|
65
|
+
*/
|
|
66
|
+
reconcileQueueUpdate(steering, followUp) {
|
|
67
|
+
const removed = [];
|
|
68
|
+
const next = (old, texts) => {
|
|
69
|
+
const out = [];
|
|
70
|
+
let cursor = 0;
|
|
71
|
+
for (const text of texts) {
|
|
72
|
+
let match = -1;
|
|
73
|
+
for (let j = cursor; j < old.length; j++) {
|
|
74
|
+
if (old[j].text === text) {
|
|
75
|
+
match = j;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (match !== -1) {
|
|
80
|
+
for (let j = cursor; j < match; j++)
|
|
81
|
+
removed.push(old[j].id);
|
|
82
|
+
out.push(old[match]);
|
|
83
|
+
cursor = match + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const intentIdx = this.pendingIntents.findIndex((e) => e.text === text);
|
|
87
|
+
const id = intentIdx !== -1 ? this.pendingIntents.splice(intentIdx, 1)[0].id : randomUUID();
|
|
88
|
+
out.push({ id, text });
|
|
89
|
+
}
|
|
90
|
+
for (let j = cursor; j < old.length; j++)
|
|
91
|
+
removed.push(old[j].id);
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
this.steering = next(this.steering, steering);
|
|
95
|
+
this.followUp = next(this.followUp, followUp);
|
|
96
|
+
this.stagedRetirementId = removed.length === 1 ? removed[0] : null;
|
|
97
|
+
return {
|
|
98
|
+
steeringIds: this.steering.map((e) => e.id),
|
|
99
|
+
followUpIds: this.followUp.map((e) => e.id),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The id for a relayed user `message_start`, or undefined when this message
|
|
104
|
+
* carries none (an engine-command expansion, a text pi transformed, or a
|
|
105
|
+
* pre-ledger replay). A staged retirement wins — pi emitted its queue_update
|
|
106
|
+
* synchronously right before this message; otherwise the first pending
|
|
107
|
+
* intent with equal text (the idle-prompt case, which never touches a
|
|
108
|
+
* queue). Id-bearing results are remembered for `recentUserMessages`.
|
|
109
|
+
*/
|
|
110
|
+
takeUserMessageId(messageText) {
|
|
111
|
+
const staged = this.stagedRetirementId;
|
|
112
|
+
if (staged !== null) {
|
|
113
|
+
this.stagedRetirementId = null;
|
|
114
|
+
this.remember(staged, messageText);
|
|
115
|
+
return staged;
|
|
116
|
+
}
|
|
117
|
+
const idx = this.pendingIntents.findIndex((e) => e.text === messageText);
|
|
118
|
+
if (idx === -1)
|
|
119
|
+
return undefined;
|
|
120
|
+
const intent = this.pendingIntents.splice(idx, 1)[0];
|
|
121
|
+
this.remember(intent.id, messageText);
|
|
122
|
+
return intent.id;
|
|
123
|
+
}
|
|
124
|
+
/** The last {@link MAX_RECENT} id-bearing user-message dispatches, oldest
|
|
125
|
+
* first — the `welcome.recentUserMessages` payload. */
|
|
126
|
+
recentUserMessages() {
|
|
127
|
+
return this.recent.map((e) => ({ ...e }));
|
|
128
|
+
}
|
|
129
|
+
/** Session rebind/replacement: the mirrored queues, staged retirement, and
|
|
130
|
+
* recent list all described the OLD session — drop everything. */
|
|
131
|
+
reset() {
|
|
132
|
+
this.steering = [];
|
|
133
|
+
this.followUp = [];
|
|
134
|
+
this.pendingIntents = [];
|
|
135
|
+
this.stagedRetirementId = null;
|
|
136
|
+
this.recent = [];
|
|
137
|
+
}
|
|
138
|
+
remember(id, text) {
|
|
139
|
+
this.recent.push({ id, text });
|
|
140
|
+
if (this.recent.length > MAX_RECENT)
|
|
141
|
+
this.recent.shift();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -58,6 +58,20 @@ export class RebindDriver {
|
|
|
58
58
|
this.removeInstalledSubscription();
|
|
59
59
|
this.sessionValue = candidateSession;
|
|
60
60
|
this.servicesValue = candidateServices;
|
|
61
|
+
// Queue modes 'all': at each drain point pi injects EVERY queued steering/
|
|
62
|
+
// follow-up message in one drain, instead of the default one-at-a-time
|
|
63
|
+
// (which held a second queued message back a whole assistant response).
|
|
64
|
+
// Set directly on the public `session.agent` fields — exactly what pi's
|
|
65
|
+
// own syncQueueModesFromSettings() does minus the shared settings.json
|
|
66
|
+
// write. Applied here so boot AND every rebind/replacement get it; pi
|
|
67
|
+
// resets the modes from settings only inside reload(), which re-applies
|
|
68
|
+
// them at its call site (frame-dispatch). Structurally guarded: the
|
|
69
|
+
// fake-engine fixture has no `.agent`.
|
|
70
|
+
const agent = candidateSession.agent;
|
|
71
|
+
if (agent) {
|
|
72
|
+
agent.steeringMode = 'all';
|
|
73
|
+
agent.followUpMode = 'all';
|
|
74
|
+
}
|
|
61
75
|
if (this.deps.cfg.editorName !== undefined && this.deps.cfg.editorName !== '') {
|
|
62
76
|
try {
|
|
63
77
|
candidateSession.setSessionName(this.deps.cfg.editorName);
|
|
@@ -78,16 +78,27 @@ export interface PromptFrame {
|
|
|
78
78
|
images?: ImageContent[];
|
|
79
79
|
/** Abort the active turn before starting this prompt. Used by the attach viewer's whip action so its command replaces, rather than joins, in-flight work. */
|
|
80
80
|
interrupt?: true;
|
|
81
|
+
/** Sender-minted message identity (see MessageIdLedger). The broker mirrors
|
|
82
|
+
* it through pi's id-less queues and echoes it back on the relayed user
|
|
83
|
+
* `message_start` (`crtrMessageId`) and enriched `queue_update`
|
|
84
|
+
* (`steeringIds`/`followUpIds`), so an optimistic sender retires its
|
|
85
|
+
* pending entry by exact id instead of text/order guessing. Absent (a
|
|
86
|
+
* legacy client, kickoff, inbox): the broker mints one itself. */
|
|
87
|
+
message_id?: string;
|
|
81
88
|
}
|
|
82
89
|
export interface SteerFrame {
|
|
83
90
|
type: 'steer';
|
|
84
91
|
text: string;
|
|
85
92
|
images?: ImageContent[];
|
|
93
|
+
/** Sender-minted message identity — see {@link PromptFrame.message_id}. */
|
|
94
|
+
message_id?: string;
|
|
86
95
|
}
|
|
87
96
|
export interface FollowUpFrame {
|
|
88
97
|
type: 'follow_up';
|
|
89
98
|
text: string;
|
|
90
99
|
images?: ImageContent[];
|
|
100
|
+
/** Sender-minted message identity — see {@link PromptFrame.message_id}. */
|
|
101
|
+
message_id?: string;
|
|
91
102
|
}
|
|
92
103
|
export interface AbortFrame {
|
|
93
104
|
type: 'abort';
|
|
@@ -111,6 +122,9 @@ export interface DeliverFrame {
|
|
|
111
122
|
id: string;
|
|
112
123
|
text: string;
|
|
113
124
|
images?: ImageContent[];
|
|
125
|
+
/** Sender-minted message identity — see {@link PromptFrame.message_id}.
|
|
126
|
+
* Distinct from `id`, the one-shot ack-correlation token. */
|
|
127
|
+
message_id?: string;
|
|
114
128
|
}
|
|
115
129
|
/** Run a `!` bash command — writable clients only. Maps to `session.executeBash()`,
|
|
116
130
|
* which runs the command, records a `bashExecution` message in context, and
|
|
@@ -351,7 +365,38 @@ export interface WelcomeFrame {
|
|
|
351
365
|
* can construct an AuthStorage/ModelRegistry pointing at the SAME auth.json.
|
|
352
366
|
* crtr surface attach is tmux-local only: broker + viewer share a filesystem. */
|
|
353
367
|
agentDir?: string;
|
|
354
|
-
|
|
368
|
+
/** The last few id-bearing user-message dispatches from the broker's message
|
|
369
|
+
* ledger, oldest first — so a reattaching follower (Core's snapshot wake
|
|
370
|
+
* replay) can key the snapshot's trailing user wakes by id instead of
|
|
371
|
+
* discarding them heuristically. Live-process memory only: a broker restart
|
|
372
|
+
* loses it (accepted — optimistic pending sends don't survive that either).
|
|
373
|
+
* Absent on a broker pinned to an older runtime generation. */
|
|
374
|
+
recentUserMessages?: Array<{
|
|
375
|
+
id: string;
|
|
376
|
+
text: string;
|
|
377
|
+
}>;
|
|
378
|
+
}
|
|
379
|
+
/** A relayed pi `queue_update`, enriched by the broker with id arrays PARALLEL
|
|
380
|
+
* to pi's text arrays (`steeringIds[i]` identifies `steering[i]`). The ids are
|
|
381
|
+
* the broker's message ledger mirror of pi's queues — sender-minted when the
|
|
382
|
+
* frame carried `message_id`, broker-minted otherwise. Absent on a broker
|
|
383
|
+
* pinned to an older runtime generation; consumers fall back to bare texts. */
|
|
384
|
+
export type RelayedQueueUpdate = Extract<AgentSessionEvent, {
|
|
385
|
+
type: 'queue_update';
|
|
386
|
+
}> & {
|
|
387
|
+
steeringIds?: string[];
|
|
388
|
+
followUpIds?: string[];
|
|
389
|
+
};
|
|
390
|
+
/** A relayed user `message_start`, enriched by the broker with the message's
|
|
391
|
+
* crouter identity — the id staged by the retirement `queue_update` pi emits
|
|
392
|
+
* synchronously before it, or the matching dispatch intent for an idle
|
|
393
|
+
* prompt. Absent when the message carries none (an engine-command expansion,
|
|
394
|
+
* a pre-ledger replay, an older broker). */
|
|
395
|
+
export type RelayedUserMessageStart = Extract<AgentSessionEvent, {
|
|
396
|
+
type: 'message_start';
|
|
397
|
+
}> & {
|
|
398
|
+
crtrMessageId?: string;
|
|
399
|
+
};
|
|
355
400
|
/** Broadcast to EVERY client after a successful `set_model`/`cycle_model`. pi
|
|
356
401
|
* emits no AgentSessionEvent for a model switch, so without this the new model
|
|
357
402
|
* reaches no viewer at all (the requester gets only a bare ack) and footers
|
|
@@ -44,6 +44,7 @@ import { BrokerClientRegistry } from './broker/client-registry.js';
|
|
|
44
44
|
import { ToolGroupTracker } from './broker/tool-groups.js';
|
|
45
45
|
import { FaultRetry } from './broker/fault-retry.js';
|
|
46
46
|
import { EventProjection } from './broker/event-projection.js';
|
|
47
|
+
import { MessageIdLedger } from './broker/message-ledger.js';
|
|
47
48
|
import { createFrameDispatchContext, handleFrame } from './broker/frame-dispatch.js';
|
|
48
49
|
import { RebindDriver } from './broker/rebind.js';
|
|
49
50
|
import { brokerExtensionState, commitBrokerModel } from './broker/daemon-ops.js';
|
|
@@ -284,6 +285,9 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
284
285
|
let projection;
|
|
285
286
|
let notifyTurnAccepted = () => { };
|
|
286
287
|
let resetFrameState = () => { };
|
|
288
|
+
// Broker-owned message identity (see MessageIdLedger): mirrors pi's id-less
|
|
289
|
+
// queues so relayed queue_update/user message_start frames carry ids.
|
|
290
|
+
const messageLedger = new MessageIdLedger();
|
|
287
291
|
rebind = new RebindDriver({
|
|
288
292
|
nodeId, cfg, session, services, runtime, registry, toolGroups,
|
|
289
293
|
uiContext: () => uiContext,
|
|
@@ -291,7 +295,10 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
291
295
|
projection: () => projection,
|
|
292
296
|
setActiveSession: (candidate) => { activeSession = candidate; },
|
|
293
297
|
disposeAndExit: (reason, status) => disposeAndExit(reason, status),
|
|
294
|
-
onSessionRebind: () =>
|
|
298
|
+
onSessionRebind: () => {
|
|
299
|
+
resetFrameState();
|
|
300
|
+
messageLedger.reset();
|
|
301
|
+
},
|
|
295
302
|
reWelcomeAll: () => reWelcomeAll(),
|
|
296
303
|
});
|
|
297
304
|
// Pi's model controls mutate only the live engine. Commit the concrete model
|
|
@@ -332,6 +339,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
332
339
|
projection = new EventProjection({
|
|
333
340
|
registry,
|
|
334
341
|
faultRetry,
|
|
342
|
+
ledger: messageLedger,
|
|
335
343
|
installedGeneration: () => rebind.installedGeneration(),
|
|
336
344
|
currentSession: () => rebind.session(),
|
|
337
345
|
notifyTurnAccepted: () => notifyTurnAccepted(),
|
|
@@ -388,6 +396,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
388
396
|
role: client.role,
|
|
389
397
|
pending_dialog: first !== undefined ? first.request : null,
|
|
390
398
|
agentDir: getAgentDir(),
|
|
399
|
+
recentUserMessages: messageLedger.recentUserMessages(),
|
|
391
400
|
}, true);
|
|
392
401
|
// No display_* replay: the retained extension state rides IN the snapshot
|
|
393
402
|
// (`snapshot.display`), so catch-up is one atomic frame. A viewer attaching
|
|
@@ -500,7 +509,7 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
|
|
|
500
509
|
rebind.setRebindSession();
|
|
501
510
|
await rebind.enqueueRebind(session, services);
|
|
502
511
|
const frameDispatch = createFrameDispatchContext({
|
|
503
|
-
nodeId, cfg, registry, toolGroups, projection, rebind, reviewCompanion,
|
|
512
|
+
nodeId, cfg, registry, toolGroups, projection, ledger: messageLedger, rebind, reviewCompanion,
|
|
504
513
|
pendingDialogs, sendWelcome, replayExtraPendingDialogsTo, reWelcomeAll, disposeAndExit,
|
|
505
514
|
persistModelChoice, broadcastModelChanged, registryOf, formatModelSpec,
|
|
506
515
|
formatExactModelSpec, resolveLaunchModel,
|
|
@@ -6,8 +6,11 @@ export type LiveDeliverRoute = 'prompt' | 'steer';
|
|
|
6
6
|
export type LiveInterruptOutcome = 'turn' | 'bash' | 'idle';
|
|
7
7
|
/** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
|
|
8
8
|
* broker routes it itself (idle → prompt, streaming → steer) and acks once
|
|
9
|
-
* routed; resolves with the route taken.
|
|
10
|
-
|
|
9
|
+
* routed; resolves with the route taken. `messageId` is the sender-minted
|
|
10
|
+
* message identity (`SendMessageRequest.message_id`), carried on the deliver
|
|
11
|
+
* frame so the broker's ledger echoes it back on the relayed user
|
|
12
|
+
* `message_start` — distinct from the one-shot ack-correlation id. */
|
|
13
|
+
export declare function deliverLive(nodeId: string, text: string, messageId?: string): Promise<LiveDeliverRoute>;
|
|
11
14
|
/** Abort whatever a LIVE node's engine is doing (turn or `!` bash). Resolves
|
|
12
15
|
* once the broker has PROCESSED the abort — which, on the single frame loop,
|
|
13
16
|
* also implies every earlier-accepted deliver was already routed (so a
|
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
import { oneShotControllerRequest } from './broker-request.js';
|
|
15
15
|
/** Deliver `text` to a LIVE node's engine on its serialized frame loop. The
|
|
16
16
|
* broker routes it itself (idle → prompt, streaming → steer) and acks once
|
|
17
|
-
* routed; resolves with the route taken.
|
|
18
|
-
|
|
17
|
+
* routed; resolves with the route taken. `messageId` is the sender-minted
|
|
18
|
+
* message identity (`SendMessageRequest.message_id`), carried on the deliver
|
|
19
|
+
* frame so the broker's ledger echoes it back on the relayed user
|
|
20
|
+
* `message_start` — distinct from the one-shot ack-correlation id. */
|
|
21
|
+
export function deliverLive(nodeId, text, messageId) {
|
|
19
22
|
return oneShotControllerRequest({
|
|
20
23
|
nodeId,
|
|
21
24
|
what: 'the interactive delivery',
|
|
22
25
|
ackFor: 'deliver',
|
|
23
26
|
correlated: true,
|
|
24
|
-
frame: (id) => ({ type: 'deliver', id, text }),
|
|
27
|
+
frame: (id) => ({ type: 'deliver', id, text, ...(messageId !== undefined ? { message_id: messageId } : {}) }),
|
|
25
28
|
result: (detail) => (detail === 'steer' ? 'steer' : 'prompt'),
|
|
26
29
|
});
|
|
27
30
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const DEFAULT_SHELL_TIMEOUT_MS = 120000;
|
|
2
|
+
/** Runs one command and returns the text that replaces it in the body. A runner
|
|
3
|
+
* never throws: a failure is reported inline so the surrounding document still
|
|
4
|
+
* reaches the model. */
|
|
5
|
+
export type ShellRunner = (cmd: string) => Promise<string>;
|
|
6
|
+
/** True when `body` carries at least one shell construct. Lets a caller skip an
|
|
7
|
+
* async execution pass (and its cwd/timeout plumbing) for the common inert
|
|
8
|
+
* document. */
|
|
9
|
+
export declare function hasShellBlocks(body: string): boolean;
|
|
10
|
+
/** Cap runaway command output before it becomes model context. */
|
|
11
|
+
export declare function truncateShellOutput(content: string): string;
|
|
12
|
+
/** Merge a completed command's streams the way the model should read them. */
|
|
13
|
+
export declare function formatShellResult(result: {
|
|
14
|
+
code: number | null;
|
|
15
|
+
killed?: boolean;
|
|
16
|
+
stdout?: string;
|
|
17
|
+
stderr?: string;
|
|
18
|
+
timeoutMs: number;
|
|
19
|
+
}): string;
|
|
20
|
+
/** The runner for processes with no pi engine — every CLI leaf. */
|
|
21
|
+
export declare function makeNodeShellRunner(opts: {
|
|
22
|
+
cwd: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}): ShellRunner;
|
|
25
|
+
/** Replace every shell construct in `body` with its output.
|
|
26
|
+
*
|
|
27
|
+
* Fenced blocks resolve first behind sentinels so an inline pattern inside a
|
|
28
|
+
* block's own text is never executed a second time; inline forms resolve
|
|
29
|
+
* against the masked text; sentinels are then restored. Commands run in
|
|
30
|
+
* document order, exactly once per call — a document read twice runs its
|
|
31
|
+
* commands twice, which is the intended "once per invocation" contract. */
|
|
32
|
+
export declare function expandShellBlocks(body: string, run: ShellRunner): Promise<string>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Shell expansion inside Markdown bodies: `!`cmd`` inline and ```! fenced
|
|
4
|
+
// blocks are replaced by the command's output.
|
|
5
|
+
//
|
|
6
|
+
// The grammar and substitution ORDER live here; the execution STRATEGY is
|
|
7
|
+
// injected. Two callers need different strategies:
|
|
8
|
+
// • Broker-side extensions run through `pi.exec`, so the engine sees the
|
|
9
|
+
// subprocess (claude-plugin-commands, memory-slash-commands).
|
|
10
|
+
// • CLI leaves have no pi engine and must not import one — `crtr memory read`
|
|
11
|
+
// uses `makeNodeShellRunner` over node:child_process.
|
|
12
|
+
//
|
|
13
|
+
// Node built-ins ONLY. This module is reachable from `src/commands/**`, where a
|
|
14
|
+
// `@earendil-works/pi-coding-agent` import would load the whole pi runtime just
|
|
15
|
+
// to read a document.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Built fresh per use, never shared. A module-level /g regex carries mutable
|
|
18
|
+
// `lastIndex`, and both `.test()` and `matchAll` (which copies it into its
|
|
19
|
+
// internal clone) would make one call silently skip matches another consumed.
|
|
20
|
+
const SHELL_INLINE_SOURCE = '!`([^`\\n]+)`';
|
|
21
|
+
const SHELL_BLOCK_SOURCE = '```!\\n([\\s\\S]*?)\\n```';
|
|
22
|
+
const inlineRe = () => new RegExp(SHELL_INLINE_SOURCE, 'g');
|
|
23
|
+
const blockRe = () => new RegExp(SHELL_BLOCK_SOURCE, 'g');
|
|
24
|
+
export const DEFAULT_SHELL_TIMEOUT_MS = 120_000;
|
|
25
|
+
const MAX_OUTPUT_LINES = 2000;
|
|
26
|
+
const MAX_OUTPUT_BYTES = 50_000;
|
|
27
|
+
/** True when `body` carries at least one shell construct. Lets a caller skip an
|
|
28
|
+
* async execution pass (and its cwd/timeout plumbing) for the common inert
|
|
29
|
+
* document. */
|
|
30
|
+
export function hasShellBlocks(body) {
|
|
31
|
+
return new RegExp(SHELL_INLINE_SOURCE).test(body) || new RegExp(SHELL_BLOCK_SOURCE).test(body);
|
|
32
|
+
}
|
|
33
|
+
/** Cap runaway command output before it becomes model context. */
|
|
34
|
+
export function truncateShellOutput(content) {
|
|
35
|
+
const lines = content.split('\n');
|
|
36
|
+
if (lines.length > MAX_OUTPUT_LINES) {
|
|
37
|
+
return `${lines.slice(0, MAX_OUTPUT_LINES).join('\n')}\n[truncated: hit ${MAX_OUTPUT_LINES} lines]`;
|
|
38
|
+
}
|
|
39
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_OUTPUT_BYTES) {
|
|
40
|
+
return `${Buffer.from(content, 'utf8').subarray(0, MAX_OUTPUT_BYTES).toString('utf8')}\n[truncated: hit ${MAX_OUTPUT_BYTES} bytes]`;
|
|
41
|
+
}
|
|
42
|
+
return content;
|
|
43
|
+
}
|
|
44
|
+
/** Merge a completed command's streams the way the model should read them. */
|
|
45
|
+
export function formatShellResult(result) {
|
|
46
|
+
if (result.killed === true) {
|
|
47
|
+
return `[Shell error: timed out after ${Math.max(1, Math.round(result.timeoutMs / 1000))}s]`;
|
|
48
|
+
}
|
|
49
|
+
if (result.code !== 0) {
|
|
50
|
+
return `[Shell error: exit code ${result.code}]\n${truncateShellOutput(result.stderr ?? '')}`;
|
|
51
|
+
}
|
|
52
|
+
let combined = result.stdout ?? '';
|
|
53
|
+
if (result.stderr !== undefined && result.stderr !== '') {
|
|
54
|
+
const sep = combined.length === 0 || combined.endsWith('\n') ? '' : '\n';
|
|
55
|
+
combined = `${combined}${sep}[stderr]\n${result.stderr}`;
|
|
56
|
+
}
|
|
57
|
+
return truncateShellOutput(combined);
|
|
58
|
+
}
|
|
59
|
+
/** The runner for processes with no pi engine — every CLI leaf. */
|
|
60
|
+
export function makeNodeShellRunner(opts) {
|
|
61
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_SHELL_TIMEOUT_MS;
|
|
62
|
+
const [sh, flag] = process.platform === 'win32' ? ['powershell.exe', '-Command'] : ['sh', '-c'];
|
|
63
|
+
return (cmd) => new Promise((resolve) => {
|
|
64
|
+
execFile(sh, [flag, cmd], { cwd: opts.cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT_BYTES * 4, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
65
|
+
// On timeout execFile kills the child and reports `killed: true` with
|
|
66
|
+
// a null `code` and a signal — never a numeric exit status. Check that
|
|
67
|
+
// first, or a timeout is misreported as a generic failure.
|
|
68
|
+
const e = err;
|
|
69
|
+
const killed = e !== null && (e.killed === true || (e.signal !== undefined && e.signal !== null));
|
|
70
|
+
const code = e === null ? 0 : typeof e.code === 'number' ? e.code : 1;
|
|
71
|
+
resolve(formatShellResult({ code, killed, stdout, stderr, timeoutMs }));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Replace every shell construct in `body` with its output.
|
|
76
|
+
*
|
|
77
|
+
* Fenced blocks resolve first behind sentinels so an inline pattern inside a
|
|
78
|
+
* block's own text is never executed a second time; inline forms resolve
|
|
79
|
+
* against the masked text; sentinels are then restored. Commands run in
|
|
80
|
+
* document order, exactly once per call — a document read twice runs its
|
|
81
|
+
* commands twice, which is the intended "once per invocation" contract. */
|
|
82
|
+
export async function expandShellBlocks(body, run) {
|
|
83
|
+
const blockOut = [];
|
|
84
|
+
let masked = '';
|
|
85
|
+
let last = 0;
|
|
86
|
+
for (const m of body.matchAll(blockRe())) {
|
|
87
|
+
const idx = m.index ?? 0;
|
|
88
|
+
masked += body.slice(last, idx) + `\x00B${blockOut.length}\x00`;
|
|
89
|
+
blockOut.push(await run(m[1] ?? ''));
|
|
90
|
+
last = idx + m[0].length;
|
|
91
|
+
}
|
|
92
|
+
masked += body.slice(last);
|
|
93
|
+
let inlined = '';
|
|
94
|
+
last = 0;
|
|
95
|
+
for (const m of masked.matchAll(inlineRe())) {
|
|
96
|
+
const idx = m.index ?? 0;
|
|
97
|
+
inlined += masked.slice(last, idx) + (await run(m[1] ?? ''));
|
|
98
|
+
last = idx + m[0].length;
|
|
99
|
+
}
|
|
100
|
+
inlined += masked.slice(last);
|
|
101
|
+
return inlined.replace(/\x00B(\d+)\x00/g, (_, n) => blockOut[parseInt(n, 10)] ?? '');
|
|
102
|
+
}
|
|
@@ -24,10 +24,15 @@ export interface SessionState {
|
|
|
24
24
|
/** Live context occupancy in prompt tokens: seeded from the snapshot's
|
|
25
25
|
* contextUsage, then tracked off each assistant `message_end`. */
|
|
26
26
|
contextTokens: number | undefined;
|
|
27
|
-
/** Queued steering + follow-up
|
|
28
|
-
* broker
|
|
29
|
-
*
|
|
30
|
-
|
|
27
|
+
/** Queued steering + follow-up messages, from `queue_update`. `id` is the
|
|
28
|
+
* broker's message-ledger identity for the entry (`steeringIds`/
|
|
29
|
+
* `followUpIds`), absent on a broker pinned to an older runtime generation.
|
|
30
|
+
* A fresh broker with an empty queue emits no `queue_update`, so every
|
|
31
|
+
* welcome resets this rather than leaving a dead broker's rows on screen. */
|
|
32
|
+
queued: Array<{
|
|
33
|
+
id?: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}>;
|
|
31
36
|
display: DisplayState;
|
|
32
37
|
/** The blocking dialog awaiting an answer, or null. Only a writable client is
|
|
33
38
|
* ever sent one, and every writable client is sent the SAME one — the first
|
|
@@ -132,7 +132,11 @@ export function reduce(state, frame) {
|
|
|
132
132
|
case 'thinking_level_changed':
|
|
133
133
|
return patchEngine(state, { thinkingLevel: frame.level });
|
|
134
134
|
case 'queue_update': {
|
|
135
|
-
|
|
135
|
+
// The broker enriches relayed queue_updates with id arrays parallel to
|
|
136
|
+
// pi's text arrays; zip them, tolerating their absence (older broker).
|
|
137
|
+
const { steeringIds, followUpIds } = frame;
|
|
138
|
+
const zip = (texts, ids) => texts.map((text, i) => (ids?.[i] !== undefined ? { id: ids[i], text } : { text }));
|
|
139
|
+
const queued = [...zip(frame.steering, steeringIds), ...zip(frame.followUp, followUpIds)];
|
|
136
140
|
const next = patchEngine(state, { pendingMessageCount: queued.length });
|
|
137
141
|
return { ...next, queued };
|
|
138
142
|
}
|
|
@@ -15,6 +15,7 @@ import { handleNewSession } from '../../../core/runtime/reset.js';
|
|
|
15
15
|
import { evaluateStop } from '../../../core/runtime/stop-guard.js';
|
|
16
16
|
import { clearFault } from '../../../core/runtime/fault.js';
|
|
17
17
|
import { updateModelRecipe } from '../../../core/runtime/model-swap.js';
|
|
18
|
+
import { completeCompanionRequestedSubmit } from '../../review/finish.js';
|
|
18
19
|
function objectBody(body, operation) {
|
|
19
20
|
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
|
20
21
|
throw usage(`${operation} requires a JSON body`);
|
|
@@ -289,6 +290,13 @@ function handleSettle(ctx) {
|
|
|
289
290
|
const nodeId = ctx.params['id'];
|
|
290
291
|
const request = parseSettleBody(ctx.body);
|
|
291
292
|
const directive = withCanvasWrite(() => settleBroker(nodeId, request));
|
|
293
|
+
// A review companion that just went quiet releases any submit the human left
|
|
294
|
+
// waiting on it. A reprompt is not going quiet, so it waits for the turn that
|
|
295
|
+
// reprompt starts. This never blocks the directive: the completion delivers
|
|
296
|
+
// an approval and retires this very broker, and the broker is owed its answer
|
|
297
|
+
// first.
|
|
298
|
+
if (directive.action !== 'reprompt')
|
|
299
|
+
void completeCompanionRequestedSubmit(nodeId);
|
|
292
300
|
return { status: 200, body: directive };
|
|
293
301
|
}
|
|
294
302
|
/** Commit a watcher cursor only while this broker generation remains current.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// POST /v1/crons/:cronId/pause stop firing, keep config + history
|
|
7
7
|
// POST /v1/crons/:cronId/resume re-arm a paused cron
|
|
8
8
|
// POST /v1/crons/:cronId/run fire NOW, out of band (synchronous)
|
|
9
|
+
// POST /v1/crons/poke eligibility poke: re-due every held cron
|
|
9
10
|
// DELETE /v1/crons/:cronId cancel one cron (idempotent, kills in-flight run)
|
|
10
11
|
//
|
|
11
12
|
// The CLI resolves timing client-side (parseWhen/parseCadence) and sends the
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
// display spec.
|
|
22
23
|
import { randomUUID } from 'node:crypto';
|
|
23
24
|
import { ApiError } from '../../../api/errors.js';
|
|
24
|
-
import { armCron, cancelCron, getCron, listCrons, listCronRuns, setCronState, } from '../../../core/canvas/crons.js';
|
|
25
|
+
import { armCron, cancelCron, getCron, listCrons, listCronRuns, pokeHeldCrons, setCronState, } from '../../../core/canvas/crons.js';
|
|
25
26
|
import { getNode } from '../../../core/canvas/canvas.js';
|
|
26
27
|
import { InputError } from '../../../core/io.js';
|
|
27
28
|
import { nextSlotAfter } from '../../../core/wake.js';
|
|
@@ -329,6 +330,17 @@ async function handleRun(ctx) {
|
|
|
329
330
|
}
|
|
330
331
|
return { status: 200, body: toCronRunDTO(record) };
|
|
331
332
|
}
|
|
333
|
+
/** `POST /v1/crons/poke` — the bare daemon-level eligibility poke: "something
|
|
334
|
+
* changed; re-check now". Canvas-wide and label-free by design — the caller
|
|
335
|
+
* (a host platform, an operator's curl) asserts only that eligibility
|
|
336
|
+
* changed; every held row re-checks its own gate in bash and re-parks if
|
|
337
|
+
* still blocked. Paused rows are not unparked (pause is user-owned).
|
|
338
|
+
* Idempotent and free when nothing is held. The count is for the caller's
|
|
339
|
+
* log line, nothing else. */
|
|
340
|
+
function handlePoke() {
|
|
341
|
+
const at = new Date().toISOString();
|
|
342
|
+
return { status: 200, body: { unparked: pokeHeldCrons(at), at } };
|
|
343
|
+
}
|
|
332
344
|
/** `DELETE /v1/crons/:cronId` — cancel one cron. Idempotent: an already-absent
|
|
333
345
|
* cron is a no-op 204 (`cancelCron` kills any in-flight run process group
|
|
334
346
|
* before deleting). */
|
|
@@ -354,6 +366,7 @@ export const cronRoutes = [
|
|
|
354
366
|
{ method: 'GET', pattern: '/v1/crons/:cronId', handler: handleShow },
|
|
355
367
|
{ method: 'POST', pattern: '/v1/crons/:cronId/pause', handler: handlePause },
|
|
356
368
|
{ method: 'POST', pattern: '/v1/crons/:cronId/resume', handler: handleResume },
|
|
369
|
+
{ method: 'POST', pattern: '/v1/crons/poke', handler: handlePoke },
|
|
357
370
|
{ method: 'POST', pattern: '/v1/crons/:cronId/run', handler: handleRun },
|
|
358
371
|
{ method: 'DELETE', pattern: '/v1/crons/:cronId', handler: handleCancel },
|
|
359
372
|
];
|
|
@@ -89,6 +89,11 @@ function toDeckSourceDTO(source) {
|
|
|
89
89
|
dto.askedBy = source.askedBy;
|
|
90
90
|
if (source.blockedSince !== undefined)
|
|
91
91
|
dto.blockedSince = source.blockedSince;
|
|
92
|
+
// The originating canvas node id — machine attribution a consumer uses to
|
|
93
|
+
// relate a ticket to the node that raised it. A node id is opaque canvas
|
|
94
|
+
// identity, not a host path, so it is safe on this wire.
|
|
95
|
+
if (source.nodeId !== undefined)
|
|
96
|
+
dto.nodeId = source.nodeId;
|
|
92
97
|
return dto;
|
|
93
98
|
}
|
|
94
99
|
function toOptionDTO(option) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Memory-document resolution handler — `GET /v1/memory/resolve?name=&node=`.
|
|
2
|
+
// Turns a `[[canonical/name]]` link written in a node's transcript into the
|
|
3
|
+
// absolute path of the document THAT node would read, so a client can peek it
|
|
4
|
+
// (`GET /v1/files/peek`) and render it.
|
|
5
|
+
//
|
|
6
|
+
// The node is required, not optional: crtrd serves every node from one process,
|
|
7
|
+
// so its own cwd and env name no node, and the same name legitimately resolves
|
|
8
|
+
// to different documents for two nodes (each has its own context store, project
|
|
9
|
+
// stack, and profile). Resolution therefore runs target-addressed —
|
|
10
|
+
// `resolveMemoryDocForTarget` with the node's cwd/profile/id off canvas.
|
|
11
|
+
import { getNode } from '../../../index.js';
|
|
12
|
+
import { notFound, usage } from '../../../core/errors.js';
|
|
13
|
+
import { resolveMemoryDocForTarget } from '../../../core/memory-resolver.js';
|
|
14
|
+
import { isDocLinkName } from '../../../core/memory/doc-link-grammar.js';
|
|
15
|
+
function handleResolve(ctx) {
|
|
16
|
+
const name = ctx.query.get('name');
|
|
17
|
+
if (name === null || name === '') {
|
|
18
|
+
throw usage('a memory resolve requires a `name` query param');
|
|
19
|
+
}
|
|
20
|
+
// The link grammar is the same one that produced the link (browser-safe, one
|
|
21
|
+
// source of truth): anything outside it is not a document name, and rejecting
|
|
22
|
+
// it here keeps a stray `[[…]]` from reaching the resolver's scope parsing.
|
|
23
|
+
if (!isDocLinkName(name)) {
|
|
24
|
+
throw usage('memory document name must be `/`-joined [A-Za-z0-9_-] segments', { received: name });
|
|
25
|
+
}
|
|
26
|
+
const nodeId = ctx.query.get('node');
|
|
27
|
+
if (nodeId === null || nodeId === '') {
|
|
28
|
+
throw usage('a memory resolve requires a `node` query param — resolution is per-node');
|
|
29
|
+
}
|
|
30
|
+
const meta = getNode(nodeId);
|
|
31
|
+
if (meta === null)
|
|
32
|
+
throw notFound(`unknown node: ${nodeId}`, { received: nodeId });
|
|
33
|
+
const doc = resolveMemoryDocForTarget(name, {
|
|
34
|
+
cwd: meta.cwd,
|
|
35
|
+
profileId: meta.profile_id ?? null,
|
|
36
|
+
nodeId: meta.node_id,
|
|
37
|
+
});
|
|
38
|
+
const body = {
|
|
39
|
+
name: doc.name,
|
|
40
|
+
scope: doc.scope,
|
|
41
|
+
path: doc.path,
|
|
42
|
+
...(doc.plugin === undefined ? {} : { plugin: doc.plugin }),
|
|
43
|
+
};
|
|
44
|
+
return { status: 200, body };
|
|
45
|
+
}
|
|
46
|
+
export const memoryRoutes = [
|
|
47
|
+
{ method: 'GET', pattern: '/v1/memory/resolve', handler: handleResolve },
|
|
48
|
+
];
|