@crouton-kit/crouter 0.3.26 → 0.3.28
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/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-personas/runtime-base.md +3 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
- package/dist/clients/attach/attach-cmd.js +187 -19
- package/dist/clients/attach/canvas-panels.d.ts +10 -0
- package/dist/clients/attach/canvas-panels.js +50 -0
- package/dist/clients/attach/graph-overlay.d.ts +34 -0
- package/dist/clients/attach/graph-overlay.js +266 -0
- package/dist/clients/attach/input-controller.d.ts +6 -0
- package/dist/clients/attach/input-controller.js +2 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -1
- package/dist/clients/attach/slash-commands.js +160 -3
- package/dist/clients/attach/view-socket.d.ts +19 -1
- package/dist/clients/attach/view-socket.js +61 -6
- package/dist/commands/human/prompts.js +3 -3
- package/dist/commands/human/queue.d.ts +17 -0
- package/dist/commands/human/queue.js +111 -4
- package/dist/commands/memory/__tests__/lint-schema.test.js +24 -1
- package/dist/commands/memory/lint.d.ts +5 -4
- package/dist/commands/memory/lint.js +9 -5
- package/dist/commands/memory/write.js +19 -2
- package/dist/commands/memory.js +1 -1
- package/dist/commands/sys/feedback.d.ts +1 -0
- package/dist/commands/sys/feedback.js +163 -0
- package/dist/commands/sys.js +3 -2
- package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
- package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +7 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +10 -0
- package/dist/core/__tests__/full/placement-teardown.test.js +76 -0
- package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
- package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
- package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
- package/dist/core/canvas/nav-model.d.ts +162 -0
- package/dist/core/canvas/nav-model.js +486 -0
- package/dist/core/canvas/paths.d.ts +7 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/runtime/broker-sdk.d.ts +0 -12
- package/dist/core/runtime/broker-sdk.js +77 -6
- package/dist/core/runtime/broker.d.ts +2 -1
- package/dist/core/runtime/broker.js +26 -1
- package/dist/core/runtime/front-door.js +23 -8
- package/dist/core/runtime/naming.d.ts +1 -5
- package/dist/core/runtime/naming.js +33 -49
- package/dist/core/runtime/placement.d.ts +7 -6
- package/dist/core/runtime/placement.js +24 -12
- package/dist/core/runtime/revive.js +9 -0
- package/dist/core/runtime/spawn.d.ts +5 -0
- package/dist/core/runtime/spawn.js +69 -11
- package/dist/core/runtime/tmux.d.ts +9 -0
- package/dist/core/runtime/tmux.js +12 -0
- package/dist/core/spawn.d.ts +14 -0
- package/dist/core/spawn.js +29 -9
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +6 -6
- package/dist/core/substrate/injected-store.d.ts +10 -0
- package/dist/core/substrate/injected-store.js +55 -0
- package/dist/core/substrate/schema.d.ts +6 -8
- package/dist/core/substrate/schema.js +26 -28
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -7
- package/dist/pi-extensions/canvas-goal-capture.js +38 -22
- package/dist/pi-extensions/canvas-nav.js +30 -385
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +32 -2
- package/package.json +1 -1
- package/dist/builtin-memory/memory-authoring.md +0 -100
package/dist/core/spawn.js
CHANGED
|
@@ -3,13 +3,30 @@
|
|
|
3
3
|
// A small set of tmux primitives used by the `human` command tree to put the
|
|
4
4
|
// humanloop TUI in a detached pane: spawnAndDetach (open a pane running a given
|
|
5
5
|
// command), countPanesInCurrentWindow (placement decision), plus shellQuote and
|
|
6
|
-
//
|
|
7
|
-
// core/runtime/tmux.ts; this module is only the pane-split path the
|
|
8
|
-
// needs.
|
|
6
|
+
// tmuxServerReachable. The canvas runtime has its own one-window-per-node
|
|
7
|
+
// machinery in core/runtime/tmux.ts; this module is only the pane-split path the
|
|
8
|
+
// human TUI needs.
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
|
+
/** Does THIS process sit inside a tmux client (its own $TMUX is set)? NOT the
|
|
11
|
+
* gate for the human surface — see tmuxServerReachable. A headless-broker child
|
|
12
|
+
* strips TMUX/TMUX_PANE (broker.ts, anti-stophook-hijack) yet can still drive
|
|
13
|
+
* the canvas tmux server, so gating the surface on this wrongly starves broker
|
|
14
|
+
* nodes of human-in-the-loop. */
|
|
10
15
|
export function isInTmux() {
|
|
11
16
|
return Boolean(process.env.TMUX);
|
|
12
17
|
}
|
|
18
|
+
/** Is the canvas tmux SERVER reachable? The canvas runs on the DEFAULT tmux
|
|
19
|
+
* socket (no -L/-S in core/runtime/tmux.ts), so any `crtr` child — including a
|
|
20
|
+
* headless-broker child that has deleted its own $TMUX — can split panes into
|
|
21
|
+
* it. The human surface gates on THIS (can I reach the server to open a pane
|
|
22
|
+
* the user is watching?), not on the caller's $TMUX. `list-clients` exits 0
|
|
23
|
+
* whenever a server is up (even with zero attached clients) and non-zero when
|
|
24
|
+
* no server is running — the true "no tmux to surface into" case that should
|
|
25
|
+
* degrade to the inbox-drain follow-up. */
|
|
26
|
+
export function tmuxServerReachable() {
|
|
27
|
+
const r = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], { encoding: 'utf8' });
|
|
28
|
+
return r.status === 0;
|
|
29
|
+
}
|
|
13
30
|
export function shellQuote(s) {
|
|
14
31
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
15
32
|
}
|
|
@@ -34,7 +51,7 @@ export function countPanesInCurrentWindow() {
|
|
|
34
51
|
* output on an unresolvable pane, so test for non-empty stdout, not just `.ok`.
|
|
35
52
|
* False outside tmux / on error. */
|
|
36
53
|
export function paneAlive(pane) {
|
|
37
|
-
if (
|
|
54
|
+
if (!/^%\d+$/.test(pane))
|
|
38
55
|
return false;
|
|
39
56
|
const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
|
|
40
57
|
encoding: 'utf8',
|
|
@@ -46,7 +63,7 @@ export function paneAlive(pane) {
|
|
|
46
63
|
* specify pane here"); only split-window -t takes a pane. null outside tmux /
|
|
47
64
|
* on a bad pane id / on error / empty. */
|
|
48
65
|
export function paneWindowTarget(pane) {
|
|
49
|
-
if (
|
|
66
|
+
if (!/^%\d+$/.test(pane))
|
|
50
67
|
return null;
|
|
51
68
|
const r = spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{session_name}:#{window_index}'], { encoding: 'utf8' });
|
|
52
69
|
if (r.status !== 0)
|
|
@@ -59,8 +76,6 @@ export function paneWindowTarget(pane) {
|
|
|
59
76
|
* to surface a human prompt in the user's view when nothing in the asking
|
|
60
77
|
* node's graph is focused. null outside tmux / no client / on error. */
|
|
61
78
|
export function attachedClientPane() {
|
|
62
|
-
if (!isInTmux())
|
|
63
|
-
return null;
|
|
64
79
|
const clients = spawnSync('tmux', ['list-clients', '-F', '#{client_name}'], {
|
|
65
80
|
encoding: 'utf8',
|
|
66
81
|
});
|
|
@@ -102,10 +117,15 @@ export function scheduleKillCurrentPane(delaySeconds) {
|
|
|
102
117
|
* as soon as the new pane is up; does NOT wait for the command to finish.
|
|
103
118
|
*/
|
|
104
119
|
export function spawnAndDetach(opts) {
|
|
105
|
-
|
|
120
|
+
// Gate on the CANVAS tmux server's reachability, NOT the caller's $TMUX. A
|
|
121
|
+
// headless-broker child strips its own $TMUX (broker.ts) but the canvas server
|
|
122
|
+
// is on the default socket, so `split-window -t <pane>` against a resolved
|
|
123
|
+
// target pane (resolveHumanTarget) works fine from it. Only a genuine
|
|
124
|
+
// no-server case degrades to the inbox-drain follow-up.
|
|
125
|
+
if (!tmuxServerReachable()) {
|
|
106
126
|
return {
|
|
107
127
|
status: 'not-in-tmux',
|
|
108
|
-
message: 'handoff requires
|
|
128
|
+
message: 'handoff requires a reachable tmux server',
|
|
109
129
|
};
|
|
110
130
|
}
|
|
111
131
|
const splitArgs = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast,
|
|
1
|
+
export { KINDS, isDocKind, RUNGS, rungRank, rungAtLeast, FALLBACK_RUNG, parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
|
|
2
2
|
export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from './schema.js';
|
|
3
3
|
export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
|
|
4
4
|
export type { NodeConfigSubject } from './subject.js';
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// substrate/ — the unified document-substrate keystone: the frontmatter schema,
|
|
2
|
-
// the 4-rung visibility ladder, the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// the 4-rung visibility ladder, the node-config subject assembly, and gate
|
|
3
|
+
// evaluation. Pure + well-typed; the shared base the CLI verbs, boot render,
|
|
4
|
+
// on-read render, and migrator all build on. See design-substrate.md §4 +
|
|
5
|
+
// plan-substrate.md §2.
|
|
6
6
|
export {
|
|
7
7
|
// kinds
|
|
8
8
|
KINDS, isDocKind,
|
|
9
9
|
// ladder
|
|
10
10
|
RUNGS, rungRank, rungAtLeast,
|
|
11
|
-
//
|
|
12
|
-
|
|
11
|
+
// fallback floor
|
|
12
|
+
FALLBACK_RUNG,
|
|
13
13
|
// parse + render-shared helpers
|
|
14
14
|
parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
|
|
15
15
|
export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
|
|
2
|
+
* file is absent, unreadable, or malformed (a fresh transcript, or a node that
|
|
3
|
+
* has not yet surfaced any on-read doc). */
|
|
4
|
+
export declare function loadInjectedDocs(nodeId: string): Set<string>;
|
|
5
|
+
/** Persist a node's on-read dedup set. Called after each read that surfaced a
|
|
6
|
+
* new doc, so the grown set survives a later dormancy. */
|
|
7
|
+
export declare function saveInjectedDocs(nodeId: string, seen: Set<string>): void;
|
|
8
|
+
/** Drop a node's persisted dedup set. Called by the launch paths that start a
|
|
9
|
+
* FRESH transcript, so the new conversation surfaces docs from scratch. */
|
|
10
|
+
export declare function clearInjectedDocs(nodeId: string): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// injected-store.ts — durable per-node dedup set for on-read doc injection.
|
|
2
|
+
//
|
|
3
|
+
// The on-read substrate hook (canvas-doc-substrate.ts → renderOnReadDocs) dedups
|
|
4
|
+
// so a given doc surfaces at most once per conversation. That set USED to live
|
|
5
|
+
// only in the pi process heap, cleared on session_start. But a node's logical
|
|
6
|
+
// session — the .jsonl transcript — spans MULTIPLE pi processes: a dormancy →
|
|
7
|
+
// revive(resume) cycle exits the old process and launches a fresh `pi --session`
|
|
8
|
+
// that REUSES the same transcript. The fresh process started with an empty set,
|
|
9
|
+
// so any doc already injected before dormancy got injected AGAIN on the next
|
|
10
|
+
// read — the "fires a second time per session" bug.
|
|
11
|
+
//
|
|
12
|
+
// This module persists the set to `nodes/<id>/injected-docs.json` so the resumed
|
|
13
|
+
// process rehydrates it and skips docs already present in the transcript. The
|
|
14
|
+
// launch paths that begin a FRESH transcript (reviveNode resume=false,
|
|
15
|
+
// reviveInPlace, relaunchRootInPane — all in runtime/revive.ts) call
|
|
16
|
+
// clearInjectedDocs(), so a new conversation starts with an empty set.
|
|
17
|
+
//
|
|
18
|
+
// All ops are best-effort: a failed read/write degrades to a possible re-inject,
|
|
19
|
+
// never a crash — a dedup miss must never break a read or a revive.
|
|
20
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import { injectedDocsPath } from '../canvas/paths.js';
|
|
22
|
+
/** Rehydrate a node's on-read dedup set from disk. Returns an empty set when the
|
|
23
|
+
* file is absent, unreadable, or malformed (a fresh transcript, or a node that
|
|
24
|
+
* has not yet surfaced any on-read doc). */
|
|
25
|
+
export function loadInjectedDocs(nodeId) {
|
|
26
|
+
try {
|
|
27
|
+
const arr = JSON.parse(readFileSync(injectedDocsPath(nodeId), 'utf8'));
|
|
28
|
+
if (!Array.isArray(arr))
|
|
29
|
+
return new Set();
|
|
30
|
+
return new Set(arr.filter((x) => typeof x === 'string'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return new Set();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Persist a node's on-read dedup set. Called after each read that surfaced a
|
|
37
|
+
* new doc, so the grown set survives a later dormancy. */
|
|
38
|
+
export function saveInjectedDocs(nodeId, seen) {
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(injectedDocsPath(nodeId), JSON.stringify([...seen]));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// best-effort — a failed persist only risks a re-inject, never a crash
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Drop a node's persisted dedup set. Called by the launch paths that start a
|
|
47
|
+
* FRESH transcript, so the new conversation surfaces docs from scratch. */
|
|
48
|
+
export function clearInjectedDocs(nodeId) {
|
|
49
|
+
try {
|
|
50
|
+
rmSync(injectedDocsPath(nodeId), { force: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ignore — absence is the desired state anyway
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -11,10 +11,7 @@ export declare function rungRank(r: Rung): number;
|
|
|
11
11
|
/** Does rung `r` disclose at least as much as `min`? — e.g.
|
|
12
12
|
* `rungAtLeast(doc.systemPromptVisibility, 'name')` ⇒ "shows at boot at all". */
|
|
13
13
|
export declare function rungAtLeast(r: Rung, min: Rung): boolean;
|
|
14
|
-
export declare const
|
|
15
|
-
systemPrompt: Rung;
|
|
16
|
-
fileRead: Rung;
|
|
17
|
-
}>;
|
|
14
|
+
export declare const FALLBACK_RUNG: Rung;
|
|
18
15
|
/** A gate predicate tree, evaluated by predicate.ts (`evalCondition`) against
|
|
19
16
|
* the node-config subject. Typed loosely on purpose — the matcher engine owns
|
|
20
17
|
* validation; structurally it is a field→matcher map with optional
|
|
@@ -63,10 +60,11 @@ export interface SubstrateDoc extends SubstrateSchema {
|
|
|
63
60
|
/** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
|
|
64
61
|
* resolver) into a typed schema with defaults applied. Returns `null` when the
|
|
65
62
|
* record is absent or carries no valid `kind` — i.e. it is not a substrate
|
|
66
|
-
* document and cannot be classified
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* throws. Authoring-time enforcement of
|
|
63
|
+
* document and cannot be classified. Tolerant of every other imperfection (a
|
|
64
|
+
* missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
|
|
65
|
+
* to the neutral floor `none`), so a renderer mapping over many docs never
|
|
66
|
+
* throws. Authoring-time enforcement of these fields lives in `crtr memory
|
|
67
|
+
* write` (on create) and `crtr memory lint`. */
|
|
70
68
|
export declare function parseSubstrateFrontmatter(fm: Record<string, unknown> | null): SubstrateSchema | null;
|
|
71
69
|
/** Parse a resolved MemoryDoc into a fully-typed SubstrateDoc (schema + the
|
|
72
70
|
* resolver's name/scope/path/body). Returns `null` for a non-substrate doc
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
// schema.ts — the typed shape of a substrate document's frontmatter, the
|
|
2
|
-
// visibility ladder,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// visibility ladder, and the parse/validate function that turns a resolved
|
|
3
|
+
// MemoryDoc into a fully-typed SubstrateDoc. This is the keystone every
|
|
4
|
+
// downstream track (CLI verbs, boot render, on-read render, migrator) builds
|
|
5
|
+
// against — pure and side-effect free. See design-substrate.md §4 (schema).
|
|
6
|
+
//
|
|
7
|
+
// There is NO kind-based default for visibility: the right rung is a
|
|
8
|
+
// case-by-case authoring call, so both rungs are required at authoring time
|
|
9
|
+
// (enforced by `crtr memory write` on create and by `crtr memory lint`). The
|
|
10
|
+
// runtime parser is tolerant by contract (it maps over many docs and must
|
|
11
|
+
// never throw), so a doc missing/with an invalid rung falls back to the neutral
|
|
12
|
+
// floor `none` — a malformed doc renders invisible rather than crashing, and
|
|
13
|
+
// lint flags it. Valid docs never hit the fallback.
|
|
7
14
|
// ---------------------------------------------------------------------------
|
|
8
15
|
// Kinds — the three semantic kinds (design §3). `kind` is data, not a fork.
|
|
9
16
|
// ---------------------------------------------------------------------------
|
|
@@ -28,44 +35,35 @@ export function rungAtLeast(r, min) {
|
|
|
28
35
|
return rungRank(r) >= rungRank(min);
|
|
29
36
|
}
|
|
30
37
|
// ---------------------------------------------------------------------------
|
|
31
|
-
// The
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// kind system-prompt-visibility file-read-visibility
|
|
37
|
-
// skill name none
|
|
38
|
-
// preference preview none
|
|
39
|
-
// reference none preview
|
|
38
|
+
// The neutral fallback rung used only when a doc omits a visibility field or
|
|
39
|
+
// carries an invalid one. NOT a default an author may lean on — authoring-time
|
|
40
|
+
// enforcement requires explicit rungs; this is purely the runtime parser's
|
|
41
|
+
// never-throw floor (a malformed doc renders invisible, and lint flags it).
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
41
|
-
export const
|
|
42
|
-
skill: { systemPrompt: 'name', fileRead: 'none' },
|
|
43
|
-
preference: { systemPrompt: 'preview', fileRead: 'none' },
|
|
44
|
-
reference: { systemPrompt: 'none', fileRead: 'preview' },
|
|
45
|
-
};
|
|
43
|
+
export const FALLBACK_RUNG = 'none';
|
|
46
44
|
// ---------------------------------------------------------------------------
|
|
47
45
|
// Parse / validate.
|
|
48
46
|
// ---------------------------------------------------------------------------
|
|
49
47
|
/** Parse a raw frontmatter record (from `parseFrontmatterGeneric`, via the
|
|
50
48
|
* resolver) into a typed schema with defaults applied. Returns `null` when the
|
|
51
49
|
* record is absent or carries no valid `kind` — i.e. it is not a substrate
|
|
52
|
-
* document and cannot be classified
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* throws. Authoring-time enforcement of
|
|
50
|
+
* document and cannot be classified. Tolerant of every other imperfection (a
|
|
51
|
+
* missing `when-and-why-to-read` defaults to '', a missing/bad rung falls back
|
|
52
|
+
* to the neutral floor `none`), so a renderer mapping over many docs never
|
|
53
|
+
* throws. Authoring-time enforcement of these fields lives in `crtr memory
|
|
54
|
+
* write` (on create) and `crtr memory lint`. */
|
|
56
55
|
export function parseSubstrateFrontmatter(fm) {
|
|
57
56
|
if (fm === null)
|
|
58
57
|
return null;
|
|
59
58
|
if (!isDocKind(fm.kind))
|
|
60
59
|
return null;
|
|
61
60
|
const kind = fm.kind;
|
|
62
|
-
const defaults = KIND_DEFAULT_RUNGS[kind];
|
|
63
61
|
return {
|
|
64
62
|
kind,
|
|
65
63
|
whenAndWhyToRead: strField(fm['when-and-why-to-read']),
|
|
66
64
|
shortForm: strField(fm['short-form']),
|
|
67
|
-
systemPromptVisibility: parseRung(fm['system-prompt-visibility'],
|
|
68
|
-
fileReadVisibility: parseRung(fm['file-read-visibility'],
|
|
65
|
+
systemPromptVisibility: parseRung(fm['system-prompt-visibility'], FALLBACK_RUNG),
|
|
66
|
+
fileReadVisibility: parseRung(fm['file-read-visibility'], FALLBACK_RUNG),
|
|
69
67
|
gate: parseGate(fm.gate),
|
|
70
68
|
appliesTo: parseAppliesTo(fm['applies-to']),
|
|
71
69
|
};
|
|
@@ -95,8 +93,8 @@ export function previewLine(doc) {
|
|
|
95
93
|
function strField(v) {
|
|
96
94
|
return typeof v === 'string' ? v : '';
|
|
97
95
|
}
|
|
98
|
-
/** Resolve a visibility field to a ladder rung, falling back to the
|
|
99
|
-
*
|
|
96
|
+
/** Resolve a visibility field to a ladder rung, falling back to the neutral
|
|
97
|
+
* floor when absent/invalid. */
|
|
100
98
|
function parseRung(v, fallback) {
|
|
101
99
|
return typeof v === 'string' && RUNGS.includes(v)
|
|
102
100
|
? v
|
|
@@ -36,6 +36,7 @@ import { homedir } from 'node:os';
|
|
|
36
36
|
import { join, resolve } from 'node:path';
|
|
37
37
|
import { renderMemoryGuidance, renderOnReadDocs, renderPreferencesSection, renderSkillsSection, } from '../core/substrate/index.js';
|
|
38
38
|
import { clearSessionCache } from '../core/substrate/session-cache.js';
|
|
39
|
+
import { loadInjectedDocs, saveInjectedDocs } from '../core/substrate/injected-store.js';
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
// Extension
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
@@ -55,14 +56,19 @@ export function registerCanvasDocSubstrate(pi) {
|
|
|
55
56
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
56
57
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
57
58
|
return; // not a canvas node — inert
|
|
58
|
-
// Per-
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
59
|
+
// Per-TRANSCRIPT set of injected doc realpaths → a doc surfaces at most once
|
|
60
|
+
// across the node's conversation, EVEN across a dormancy → revive(resume)
|
|
61
|
+
// cycle. A resume reuses the same .jsonl transcript in a NEW pi process, so
|
|
62
|
+
// the set is REHYDRATED from disk at process start (not started empty) and is
|
|
63
|
+
// NOT cleared on session_start — a resume continues the transcript, so the
|
|
64
|
+
// dedup must carry forward. The launch paths that begin a FRESH transcript
|
|
65
|
+
// (revive resume=false, reviveInPlace, relaunchRootInPane) delete the file, so
|
|
66
|
+
// a new conversation rehydrates empty here. See core/substrate/injected-store.ts.
|
|
67
|
+
const injectedDocs = loadInjectedDocs(nodeId);
|
|
64
68
|
pi.on('session_start', () => {
|
|
65
|
-
|
|
69
|
+
// Only the per-session substrate PARSE cache resets each session (so the
|
|
70
|
+
// corpus is re-scanned, picking up skill/memory writes). injectedDocs is
|
|
71
|
+
// transcript-scoped, not session-scoped — deliberately NOT cleared here.
|
|
66
72
|
clearSessionCache();
|
|
67
73
|
});
|
|
68
74
|
// 1. BOOT system-prompt half — splice `<skills>` + `<preferences>` +
|
|
@@ -109,6 +115,9 @@ export function registerCanvasDocSubstrate(pi) {
|
|
|
109
115
|
const injected = renderOnReadDocs(nodeId, absFile, injectedDocs);
|
|
110
116
|
if (injected === '')
|
|
111
117
|
return; // nothing surfaced — pass the read through unchanged
|
|
118
|
+
// A doc surfaced → renderOnReadDocs grew injectedDocs; persist the set so
|
|
119
|
+
// the dedup survives a later dormancy → revive(resume).
|
|
120
|
+
saveInjectedDocs(nodeId, injectedDocs);
|
|
112
121
|
// Prepend the surfacing docs ahead of the file contents.
|
|
113
122
|
return { content: [{ type: 'text', text: injected }, ...event.content] };
|
|
114
123
|
}
|
|
@@ -3,13 +3,23 @@
|
|
|
3
3
|
// Loaded into every canvas node's pi process via the node's launch.extensions
|
|
4
4
|
// list. INERT when CRTR_NODE_ID is absent (plain pi session or legacy job agent).
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// Two first-message jobs, both keyed off the node's first real `input` event:
|
|
7
|
+
//
|
|
8
|
+
// 1. Goal capture (bare roots). A node spawned with a prompt has its goal
|
|
9
|
+
// persisted at birth (writeGoal in spawn.ts). A bare root (`crtr` with no
|
|
10
|
+
// prompt) starts goal-less — its mandate only arrives when the human types
|
|
11
|
+
// their first message; this persists that as context/initial-prompt.md.
|
|
12
|
+
// Guarded so later messages never clobber it.
|
|
13
|
+
//
|
|
14
|
+
// 2. Naming (every node). Naming is async + event-driven — it does NOT run on
|
|
15
|
+
// the spawn path (that blocking LLM call froze the caller's terminal for
|
|
16
|
+
// 2-3s on every spawn). On the first real message, if the node has no name
|
|
17
|
+
// yet, ask pi headlessly (async, non-blocking) for a kebab-case name and
|
|
18
|
+
// live-update the editor label. The first message may be a human's line OR
|
|
19
|
+
// a delegated child's kickoff task — naming off the agent prompt is fine.
|
|
20
|
+
//
|
|
21
|
+
// Both skip extension-injected messages (inbox wakes, steering) and the
|
|
22
|
+
// fresh-revive kickoff (its sentinel), so neither is mistaken for a first input.
|
|
13
23
|
//
|
|
14
24
|
// Pure observation — it writes the goal file as a side effect and always lets
|
|
15
25
|
// the message through unchanged (returns nothing ⇒ continue). Registered before
|
|
@@ -20,7 +30,7 @@
|
|
|
20
30
|
// crouter's own tsc build without a dep on the pi packages.
|
|
21
31
|
import { captureGoalIfAbsent, REVIVE_KICKOFF_SENTINEL } from '../core/runtime/kickoff.js';
|
|
22
32
|
import { generateAndPersistName } from '../core/runtime/naming.js';
|
|
23
|
-
import { editorLabel } from '../core/canvas/index.js';
|
|
33
|
+
import { editorLabel, getNode } from '../core/canvas/index.js';
|
|
24
34
|
/**
|
|
25
35
|
* Register the goal-capture handler on `pi`.
|
|
26
36
|
*
|
|
@@ -34,26 +44,32 @@ export function registerCanvasGoalCapture(pi) {
|
|
|
34
44
|
const nodeId = process.env['CRTR_NODE_ID'];
|
|
35
45
|
if (nodeId === undefined || nodeId.trim() === '')
|
|
36
46
|
return; // not a canvas node
|
|
37
|
-
// Only a genuine human-typed prompt seeds the mandate — never an RPC or an
|
|
38
|
-
// extension-injected message (inbox wakes, steering nudges, kickoffs).
|
|
39
|
-
if (event.source !== 'interactive')
|
|
40
|
-
return;
|
|
41
47
|
const text = (event.text ?? '').trim();
|
|
42
48
|
if (text === '')
|
|
43
49
|
return;
|
|
44
|
-
//
|
|
45
|
-
//
|
|
50
|
+
// Never seed a mandate or a name from an extension-injected message (inbox
|
|
51
|
+
// wakes, steering nudges) or a fresh-revive kickoff (the node is already
|
|
52
|
+
// named). Both would otherwise masquerade as the node's first real input.
|
|
53
|
+
if (event.source === 'extension')
|
|
54
|
+
return;
|
|
46
55
|
if (text.startsWith(REVIVE_KICKOFF_SENTINEL))
|
|
47
56
|
return;
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
// Goal capture is bare-root only: a delegated child already had its goal
|
|
58
|
+
// persisted at birth (writeGoal), so only a genuine human-typed prompt
|
|
59
|
+
// seeds a mandate here.
|
|
60
|
+
if (event.source === 'interactive')
|
|
61
|
+
captureGoalIfAbsent(nodeId, text);
|
|
62
|
+
// Naming: name the node from its FIRST real message — a human's first line
|
|
63
|
+
// OR a delegated child's kickoff task (naming off the agent prompt is
|
|
64
|
+
// fine) — whenever it has no name yet. Async headless `pi -p` with no
|
|
65
|
+
// canvas extensions, so it never recurses into another spawn/name. The
|
|
66
|
+
// onNamed callback live-updates THIS session's label instead of waiting
|
|
67
|
+
// for the next cycle. The unnamed-guard keeps it to one call per node.
|
|
68
|
+
const meta = getNode(nodeId);
|
|
69
|
+
if (meta !== null && (meta.description ?? '').trim() === '') {
|
|
70
|
+
generateAndPersistName(nodeId, text, (named) => {
|
|
55
71
|
try {
|
|
56
|
-
pi.setSessionName?.(editorLabel(
|
|
72
|
+
pi.setSessionName?.(editorLabel(named));
|
|
57
73
|
}
|
|
58
74
|
catch { /* best-effort */ }
|
|
59
75
|
});
|