@crouton-kit/crouter 0.3.24 → 0.3.26
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 +21 -0
- package/dist/builtin-memory/internal/examples/INDEX.md +15 -0
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +100 -0
- package/dist/builtin-memory/internal/nodes-and-canvas.md +49 -0
- package/dist/builtin-memory/internal/storage-tiers.md +32 -0
- package/dist/builtin-memory/memory-authoring.md +100 -0
- package/dist/builtin-personas/orchestration-kernel.md +4 -4
- package/dist/clients/attach/attach-cmd.js +26 -0
- package/dist/commands/node.d.ts +15 -6
- package/dist/commands/node.js +33 -9
- package/dist/commands/skill/shared.d.ts +0 -5
- package/dist/commands/skill/shared.js +0 -81
- package/dist/core/__tests__/context-intro.test.js +23 -20
- package/dist/core/__tests__/full/broker-pane-resolution.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +84 -0
- package/dist/core/__tests__/memory-resolver.test.js +21 -2
- package/dist/core/memory-resolver.js +17 -12
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/broker.js +4 -2
- package/dist/core/runtime/launch.d.ts +2 -2
- package/dist/core/runtime/launch.js +2 -2
- package/dist/core/runtime/placement.d.ts +13 -0
- package/dist/core/runtime/placement.js +23 -7
- package/dist/core/runtime/recycle.js +65 -10
- package/dist/core/substrate/ceiling.d.ts +0 -7
- package/dist/core/substrate/ceiling.js +0 -16
- package/dist/core/substrate/index.d.ts +2 -2
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/render.d.ts +19 -20
- package/dist/core/substrate/render.js +271 -171
- package/dist/core/substrate/session-cache.d.ts +0 -13
- package/dist/core/substrate/session-cache.js +0 -12
- package/dist/index.d.ts +19 -1
- package/dist/index.js +26 -1
- package/dist/pi-extensions/canvas-context-intro.js +3 -3
- package/dist/pi-extensions/canvas-doc-substrate.js +13 -5
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
when-and-why-to-read: When you hit a question about how the crtr runtime itself works — how nodes and the canvas behave, where state lives on disk, or how the primitives compose into real systems — this index should be read because it routes you to the runtime's own operational documentation, so you act from the canonical model instead of inferring it from command help.
|
|
4
|
+
short-form: internal/ is the runtime's self-documentation — operational guides to nodes/canvas and the storage tiers, plus worked example compositions. Open it when you need to understand how crtr works.
|
|
5
|
+
system-prompt-visibility: preview
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# internal/ — how the crtr runtime works
|
|
10
|
+
|
|
11
|
+
This directory is the runtime's **self-documentation**: operational references covering how crtr behaves, so any agent operating under it can route to the canonical model rather than reconstruct it from scattered `-h` output. It ships with crtr.
|
|
12
|
+
|
|
13
|
+
Open this dir whenever a task turns on understanding the runtime itself. Contents:
|
|
14
|
+
|
|
15
|
+
- **nodes-and-canvas** — the agent-runtime model: nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle (mode + lifecycle axes), and revive (manual + daemon auto-revive).
|
|
16
|
+
- **storage-tiers** — where every kind of state lives: the three tiers (scope root, per-cwd crouter root, canvas home) and their durability/ownership contracts.
|
|
17
|
+
- **examples/** — worked compositions of the primitives into complete systems (the analogue of pi's `examples/` dir), e.g. the iMessage assistant node.
|
|
18
|
+
|
|
19
|
+
Adjacent, outside this dir: authoring memory documents (frontmatter, rungs, gates, the asked-to-remember workflow) is owned by the builtin **memory-authoring** skill; making a persona (a custom `--kind`) is owned by the builtin **crouter-development/personas** skills.
|
|
20
|
+
|
|
21
|
+
The individual files surface at `name` (their titles route; open the one the situation calls for); this index surfaces at `preview` so the dir announces when to come looking.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
when-and-why-to-read: When you want a worked end-to-end composition of crouter primitives — not what one command does but how several combine into a real standing system — open this dir because it holds complete example builds, each verified against the actual runtime and OS mechanics.
|
|
4
|
+
short-form: Worked examples composing crouter primitives into complete systems (like pi's examples/ dir). Currently — the iMessage assistant node.
|
|
5
|
+
system-prompt-visibility: name
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# internal/examples/ — worked compositions of crouter primitives
|
|
10
|
+
|
|
11
|
+
The other internal/ docs explain the primitives one at a time; this dir shows them composed into complete, buildable systems — the crtr analogue of pi's `examples/` directory. Each example is grounded: its OS-level mechanics and command contracts were verified before being written down.
|
|
12
|
+
|
|
13
|
+
- **imessage-assistant** — an OpenClaw-style always-on assistant: resident root node with its own dir and persona, woken event-style by a launchd watcher via `node msg`, reading `chat.db` (with the attributedBody gotcha), replying via osascript, remembering people through project-scope memory.
|
|
14
|
+
|
|
15
|
+
Add an example here when a composition was non-obvious enough that the next builder shouldn't have to re-derive it.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
when-and-why-to-read: When you are building a standing assistant node bridged to an external channel — iMessage, email, a chat service — this example should be read because it composes the crouter primitives (resident root node, custom persona, event-driven wake via node msg, project-scope memory) into a working OpenClaw-style design, with the macOS chat.db/osascript mechanics verified.
|
|
4
|
+
short-form: Worked example — an always-on iMessage assistant built from crouter primitives. Resident root node + launchd watcher → node msg wakes + chat.db reads + osascript sends + substrate memory.
|
|
5
|
+
system-prompt-visibility: name
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Example: an iMessage assistant node (OpenClaw-style)
|
|
10
|
+
|
|
11
|
+
A standing assistant that lives on the canvas, sleeps for free, wakes when a text arrives, reads the conversation, replies over iMessage, and accumulates memory about the people it talks to. Everything here composes existing primitives — no new runtime machinery.
|
|
12
|
+
|
|
13
|
+
## The shape
|
|
14
|
+
|
|
15
|
+
| Need | Primitive |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Its own home | Dedicated dir (e.g. `~/assistants/imessage/`), node spawned with `--cwd` pinned there |
|
|
18
|
+
| Always-on, never "finishes" | `--root` spawn → resident lifecycle; dormant between messages costs nothing |
|
|
19
|
+
| Hears incoming texts | launchd watcher on `chat.db-wal` → `crtr node msg <id>` (event-driven; the spine delivers, the node never polls) |
|
|
20
|
+
| Reads messages | sqlite against `~/Library/Messages/chat.db`, cursored by ROWID |
|
|
21
|
+
| Sends replies | `osascript` → Messages.app |
|
|
22
|
+
| Memory | Project-scope substrate (`~/assistants/imessage/.crouter/memory/`) for durable knowledge; context dir for the cursor |
|
|
23
|
+
| Identity/behavior | A custom persona kind (see [[crouter-development/personas]]) |
|
|
24
|
+
| Crash recovery | The daemon auto-revives dead nodes; the watcher's `node msg` also revives a dormant target on its own |
|
|
25
|
+
|
|
26
|
+
## 1. Persona
|
|
27
|
+
|
|
28
|
+
Define a kind (e.g. `imessage-assistant`) per [[crouter-development/personas]]. The persona body carries: who it speaks for, which senders it may auto-reply to (allowlist — never reply to arbitrary numbers), voice, the wake loop below, and the memory protocol (when to write a contact note vs. keep it in-thread). Behavior lives in the persona, not in the watcher.
|
|
29
|
+
|
|
30
|
+
## 2. Spawn
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
mkdir -p ~/assistants/imessage
|
|
34
|
+
crtr node new --root --kind imessage-assistant --cwd ~/assistants/imessage --name imessage <<'EOF'
|
|
35
|
+
You are the standing iMessage assistant. Initialize: read your persona's wake loop, record the current chat.db max ROWID as your cursor, then go dormant. From now on a watcher wakes you via inbox message whenever iMessage activity lands.
|
|
36
|
+
EOF
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`--root` makes it independent and resident — top-level on the canvas, no parent to report to, never forced to finish. Add `--headless` if it shouldn't occupy a tmux window. Record the returned node id for the watcher.
|
|
40
|
+
|
|
41
|
+
## 3. Wake: event-driven, not polled
|
|
42
|
+
|
|
43
|
+
A trivial launchd agent watches the Messages write-ahead log and pokes the node's inbox. `node msg` revives a dormant target by itself, so the watcher is the *only* off-canvas piece.
|
|
44
|
+
|
|
45
|
+
```xml
|
|
46
|
+
<!-- ~/Library/LaunchAgents/com.user.imessage-watch.plist (key parts) -->
|
|
47
|
+
<key>WatchPaths</key><array><string>/Users/YOU/Library/Messages/chat.db-wal</string></array>
|
|
48
|
+
<key>ProgramArguments</key><array>
|
|
49
|
+
<string>/bin/zsh</string><string>-c</string>
|
|
50
|
+
<string>crtr node msg NODE_ID "iMessage activity — check chat.db since your cursor"</string>
|
|
51
|
+
</array>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Multiple bursts while the node is mid-turn just append to its inbox and coalesce — no dedupe logic needed. Trust the runtime: do **not** also arm a recurring `node wake at` as a backstop. A self-scheduled wake is only the fallback if you choose not to run a watcher at all (pure polling — workable, but pays a window per tick and adds latency).
|
|
55
|
+
|
|
56
|
+
## 4. Reading chat.db (the verified gotchas)
|
|
57
|
+
|
|
58
|
+
- The process querying needs **Full Disk Access** (the node's shell inherits the terminal/launchd grant). Read-only — never write to chat.db.
|
|
59
|
+
- Cursor on `message.ROWID`, persisted in the node's context dir (e.g. `context/cursor`); filter `is_from_me = 0`.
|
|
60
|
+
- **On modern macOS `message.text` is often NULL** — the body lives in the `attributedBody` blob (NSAttributedString archive). Extract with:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import re
|
|
64
|
+
m = re.search(rb'NSString.{1,10}?\+?(.{1,200}?)\x86', attributed_body, re.S)
|
|
65
|
+
text = m.group(1).decode('utf-8', 'ignore') if m else None
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- Join for sender + thread:
|
|
69
|
+
|
|
70
|
+
```sql
|
|
71
|
+
SELECT m.ROWID, h.id AS sender, c.chat_identifier, m.text, m.attributedBody
|
|
72
|
+
FROM message m
|
|
73
|
+
JOIN handle h ON m.handle_id = h.ROWID
|
|
74
|
+
JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
|
|
75
|
+
JOIN chat c ON c.ROWID = cmj.chat_id
|
|
76
|
+
WHERE m.ROWID > :cursor AND m.is_from_me = 0
|
|
77
|
+
ORDER BY m.ROWID;
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 5. Sending
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
osascript -e 'tell application "Messages" to send "reply text" to buddy "+15551234567" of (service 1 whose service type is iMessage)'
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Group chats target the chat instead: `send "..." to chat id "iMessage;+;chat123..."` (the `chat.guid` column). First send prompts a one-time **Automation** permission grant for Messages.app.
|
|
87
|
+
|
|
88
|
+
## 6. Memory
|
|
89
|
+
|
|
90
|
+
Two tiers, matching [[internal/storage-tiers]]:
|
|
91
|
+
|
|
92
|
+
- **Durable knowledge** → project-scope memory in the node's pinned dir (`~/assistants/imessage/.crouter/memory/`): one reference per contact/thread (who they are, open loops, tone), loaded by the substrate on the node's own boots. The persona instructs when to write/update these.
|
|
93
|
+
- **Working state** → the node's context dir: the ROWID cursor, drafts, a running log.
|
|
94
|
+
|
|
95
|
+
## The wake loop (persona-side)
|
|
96
|
+
|
|
97
|
+
1. Wake on inbox message → read new rows past the cursor.
|
|
98
|
+
2. Decide per message: reply (allowlisted + warranted), note silently, or ignore.
|
|
99
|
+
3. Send via osascript; append anything durable to contact memory.
|
|
100
|
+
4. Advance the cursor, end turn, go dormant. No wake armed — the watcher delivers the next event.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
when-and-why-to-read: When you are operating the canvas — spawning or steering nodes, deciding how work reports up, recovering a dormant or crashed node, or reasoning about the daemon — this reference should be read because it is the operational model of nodes, the spine, lifecycle, and revive, so you drive the runtime correctly instead of inferring it from scattered command help.
|
|
4
|
+
short-form: Operational model of the agent runtime — nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle states, and revive (manual + daemon auto-revive).
|
|
5
|
+
system-prompt-visibility: name
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# How nodes and the canvas work (operational)
|
|
10
|
+
|
|
11
|
+
Every agent is a **node** in one directed graph (the **canvas**). Each node is an independent `pi` process in its own tmux window with its own context dir. The graph's edges are `subscribes_to` — the **spine** — and they decide who-wakes-whom.
|
|
12
|
+
|
|
13
|
+
## Spawn & delegate
|
|
14
|
+
|
|
15
|
+
`crtr node new "<task>" --kind <kind>` spawns a managed child in a background window and returns its id immediately; you **auto-subscribe** to it, so its finish wakes you. Delegating is the default move, not an optimization: a child's reading and tokens land in a fresh window and only the conclusion comes back, keeping your own context (the scarce resource) free for steering.
|
|
16
|
+
|
|
17
|
+
- Match `--kind` to the work (`explore spec design plan developer review general`, plus any custom persona). See `node new -h`.
|
|
18
|
+
- Fan **independent** units out as concurrent children — a wake with idle workers is wasted. Serialize only true dependencies; never let two live children edit the same files.
|
|
19
|
+
- `--root` spawns an independent node you neither manage nor are woken by (e.g. one a human will drive).
|
|
20
|
+
- Once you delegate a unit, don't also run it yourself — you'll be woken when it finishes.
|
|
21
|
+
|
|
22
|
+
Navigate/steer: `node focus` (jump a node into your pane), `node cycle` (DFS-walk neighbors), `node msg` (direct-message any node at a wake tier, reviving a dormant target), `node subscribe`/`unsubscribe` (wire edges spawn didn't create). Survey with `canvas dashboard` (ASCII tree), `canvas browse` (interactive navigator), `node inspect list/show`, `canvas attention` (who's blocked on a human).
|
|
23
|
+
|
|
24
|
+
## The push/feed spine
|
|
25
|
+
|
|
26
|
+
Nothing is reported automatically — the feed contains only what a node **pushes**.
|
|
27
|
+
|
|
28
|
+
- `push update` — routine progress; fans a lightweight pointer to subscribers, no forced wake.
|
|
29
|
+
- `push urgent` — force-wakes every subscriber (you're blocked, scope changed, an error derails the plan).
|
|
30
|
+
- `push final` — the ONLY way any node finishes: writes the canonical result, marks the node done, closes its window. Stopping without it is not finishing. (Guarded on a node working directly with a user — needs `--force` after they confirm.)
|
|
31
|
+
|
|
32
|
+
A push fans a ~30-token **pointer** (a ref path), not the content; subscribers dereference lazily. When a subscriber push wakes you, **the wake message already IS the coalesced digest** — don't re-run `feed read` to "open" it (the cursor already advanced); dereference the refs that matter. `feed read` is for proactively polling *before* a wake; `feed peek` shows live state of the nodes below you without draining (use it to confirm workers are alive before you yield). An empty feed while workers run is normal.
|
|
33
|
+
|
|
34
|
+
## Lifecycle
|
|
35
|
+
|
|
36
|
+
Two orthogonal axes:
|
|
37
|
+
|
|
38
|
+
- **mode**: base (a terminal worker — does one job in one window and ends) ↔ orchestrator (`node promote` — a long-lived, roadmap-holding coordinator that delegates phases and survives context refresh via `node yield`). Don't promote for work that fits one window.
|
|
39
|
+
- **lifecycle**: terminal (owes a final up the spine, reaps when done) ↔ resident (`node lifecycle` / interactable — stays dormant, wakes on inbox/human, never forced to finish). `node demote` is the friendly flip-to-terminal-in-place.
|
|
40
|
+
|
|
41
|
+
A dormant node has two wake triggers: an **inbox** message (push/msg) and the **clock** (`node wake` arms timed/recurring wakeups). To monitor your own children you arm nothing — you auto-subscribe on spawn, so their finish/crash/close wakes you; a deadline to chase a delegate is a belt-and-suspenders the runtime makes redundant.
|
|
42
|
+
|
|
43
|
+
Tear-down: `node close` cascade-cancels a node + its exclusive subtree WITHOUT finishing (revivable, nothing deleted); `node recycle` finishes the agent in your pane and reboots a fresh root in place; `canvas prune` deletes terminal nodes past a TTL.
|
|
44
|
+
|
|
45
|
+
## Revive & the daemon
|
|
46
|
+
|
|
47
|
+
A dormant node (done/idle/dead/canceled) is reopened with `canvas revive` (resumes the saved conversation, or `--fresh` to restart clean). `reviveNode()` is the **only** sanctioned launcher of `pi --session` — it sets `CRTR_NODE_ID` + canvas extensions and runs `transition('revive')`, keeping the db row and pi session in lockstep. Never spawn `pi --session` raw, and never open a node by spawning pi directly — UIs go through `node focus` / `canvas revive`.
|
|
48
|
+
|
|
49
|
+
The **daemon** (`crtrd`, managed via `canvas daemon start/stop/status`) is a thin supervisor: it polls pane + pi liveness ~every 2s and auto-revives nodes whose window exited. It does NOT host agents and does NOT auto-revive *canceled* nodes (reach for `canvas revive` for those). After rebuilding crouter's `dist/`, restart the daemon — it loads compiled code at start and never reloads it. Restarting is safe: it never signals running nodes.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: reference
|
|
3
|
+
when-and-why-to-read: When you need to know where a piece of crtr state lives on disk — or you are adding a new kind of file and must decide where it belongs — this reference should be read because it names the three storage tiers and their durability/ownership contracts, so you put (or find) the file in the right place instead of scattering state.
|
|
4
|
+
short-form: The three crtr storage tiers — scope root (durable user/repo content), per-cwd crouter root (per-project working artifacts), and canvas home (node-graph runtime state).
|
|
5
|
+
system-prompt-visibility: name
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Where everything lives (the three storage tiers)
|
|
10
|
+
|
|
11
|
+
crtr state splits into three tiers, each with a distinct durability and ownership contract. This consolidates the contract for routing; for the precise resolver behavior consult the crouter sources (`src/core/scope.ts`, `src/core/artifact.ts`, `src/core/canvas/paths.ts`) and, in the crouter repo, the "Storage locations" section of its `CLAUDE.md`.
|
|
12
|
+
|
|
13
|
+
## 1. Scope root — durable, user/repo-authored content
|
|
14
|
+
|
|
15
|
+
`~/.crouter/` (user scope) or `<project>/.crouter/` (project scope). Resolved by the scope resolver (`src/core/scope.ts`). Holds: `skills/`, `plugins/`, `marketplaces/`, `memory/`, `config.json`. Persists across project changes; belongs to the user or the repo.
|
|
16
|
+
|
|
17
|
+
## 2. Per-cwd crouter root — per-project working artifacts
|
|
18
|
+
|
|
19
|
+
`~/.crouter/<mangled-cwd>/`, keyed on the originating cwd via `mangleCwd` (`src/core/artifact.ts`). Holds working artifacts like `interactions/` (humanloop decks for the `crtr human` bridge; `interactions/<id>/` holds `deck.json` / `run.json` / `response.json`).
|
|
20
|
+
|
|
21
|
+
## 3. Canvas home — node-graph runtime state
|
|
22
|
+
|
|
23
|
+
`~/.crouter/canvas/` (override with `CRTR_HOME`; see `src/core/canvas/paths.ts`). The agent-runtime state for the node graph:
|
|
24
|
+
|
|
25
|
+
- `canvas.db` — sqlite (WAL) topology store: nodes + edges only. On-screen focus is the `focuses` table here (durable, keyed on tmux `%pane_id`).
|
|
26
|
+
- `nodes/<node_id>/` — each node's durable state: `meta.json` (source of truth for the row), `context/` (roadmap.md, prompts, artifacts), `job/` (`log.jsonl`, `telemetry.json`), `reports/` (append-only `<ts>-<kind>.md` push history), `inbox.jsonl` (messages + coalesced feed), `transcript.jsonl`, `session.ptr` (pi session id).
|
|
27
|
+
|
|
28
|
+
## The contributor rule
|
|
29
|
+
|
|
30
|
+
Durable user content → **scope root**. Per-project working artifacts → **per-cwd crouter root**. Node-graph runtime state (topology, context, reports, inbox, telemetry) → **canvas home**.
|
|
31
|
+
|
|
32
|
+
(The legacy `sessions/` graph and `$XDG_STATE_HOME/crtr/jobs/` tier were removed when the node/canvas runtime replaced the session/job model; node telemetry now lives in each node's `job/telemetry.json` under the canvas home.)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: skill
|
|
3
|
+
when-and-why-to-read: When you are about to write or update a memory document — including any time the user asks you to remember something — or need to debug why a document is (not) surfacing, this skill should be read because it tells you how to choose kind and scope, set the disclosure rungs and gate, size the body, and when to first ask the user a clarifying question.
|
|
4
|
+
short-form: Author excellent memories — kind, scope, visibility rungs, gates, body sizing, and the asked-to-remember workflow.
|
|
5
|
+
system-prompt-visibility: preview
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Writing memories
|
|
10
|
+
|
|
11
|
+
A memory is a markdown document whose frontmatter decides **when, where, and how much** of it loads into future agents. The body is the easy part; the craft is routing — deciding who sees it, at what moment, at what context cost. Every frontmatter choice below is a routing choice.
|
|
12
|
+
|
|
13
|
+
## When asked to remember something
|
|
14
|
+
|
|
15
|
+
1. **Write the routing line first.** Before storing anything, try to complete: *"When <circumstance>, this <kind> should be read because <payoff>."* If you cannot name the concrete situation a future agent will be in when this matters, you do not understand the memory yet — ask the user **one sharp question** instead of improvising. "Remember I like chicken" routes cleanly (food/meal decisions); "remember to be careful with the API" does not (which API? careful how? against what failure?) — that one needs clarifying before it becomes a memory. The routing line is a comprehension test.
|
|
16
|
+
2. **Find before write.** `crtr memory find <topic>` for an existing doc; update it rather than minting a near-duplicate. Prefer growing `food-preferences` over creating `likes-chicken` — one document per recurring *circumstance*, not one per fact. Group related docs with path names (`area/topic`). Delete memories that turn out wrong.
|
|
17
|
+
3. **Capture the why, not just the what.** Especially for corrections: record what was rejected and the reasoning. The why is what lets a future agent apply the rule to cases you never saw.
|
|
18
|
+
4. **Don't store what's already recorded** — code structure, git history, CLAUDE.md content — or what only matters to this conversation. If asked to remember something the repo already records, ask what was non-obvious about it and store that instead.
|
|
19
|
+
|
|
20
|
+
## Choosing kind
|
|
21
|
+
|
|
22
|
+
- **skill** — how to *do* something (a playbook, a procedure you'd repeat).
|
|
23
|
+
- **reference** — what is *true* or how something *works* (a fact about the user, a system's behavior, code docs).
|
|
24
|
+
- **preference** — how to *behave* (a directive, a standing correction).
|
|
25
|
+
|
|
26
|
+
The reference-vs-preference test: does it direct behavior ("always run lint after authoring") or inform the world-model ("Silas likes chicken", "the daemon never reloads dist/")? A correction usually yields a preference; a learned fact yields a reference; a repeatable procedure yields a skill.
|
|
27
|
+
|
|
28
|
+
Kind sets sensible default rungs — the common case needs no visibility fields at all:
|
|
29
|
+
|
|
30
|
+
| kind | at boot | on file-read |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| skill | name | none |
|
|
33
|
+
| preference | preview | none |
|
|
34
|
+
| reference | none | preview |
|
|
35
|
+
|
|
36
|
+
## The two hooks — boot vs file-read
|
|
37
|
+
|
|
38
|
+
There are exactly two moments a doc can surface. `system-prompt-visibility` governs **boot** (the system prompt); `file-read-visibility` governs **on-read** (when a related file is opened).
|
|
39
|
+
|
|
40
|
+
- Behavior and procedure (preferences, skills) are relevant regardless of which file is open → surface at boot, stay out of file-read. That is what the defaults do.
|
|
41
|
+
- Knowledge about code (references) belongs **next to the code**: put the doc in that directory's `.crouter/memory/` and it fires positionally when files under that directory are read — costing nothing at boot.
|
|
42
|
+
- The exception that matters: a reference about a *person or a process* ("Silas's food preferences") has no code directory to anchor to, so positional triggering is meaningless — set `system-prompt-visibility: preview` so its routing line surfaces at boot instead.
|
|
43
|
+
- `applies-to: <glob or list>` extends the on-read trigger beyond position for cross-cutting docs (e.g. a testing reference that should fire for `**/*.test.ts` anywhere).
|
|
44
|
+
|
|
45
|
+
## Choosing the rung
|
|
46
|
+
|
|
47
|
+
`none → name → preview → content`. Each rung up costs more of **every** future agent's context, paid at every boot or read, forever. Default down, not up.
|
|
48
|
+
|
|
49
|
+
- **content** (full body injected): reserved for guidance that is BOTH always relevant AND ~one bullet's worth of text. Fail either test → preview. Situational guidance is preview *no matter how short*; long guidance is preview no matter how universal it feels.
|
|
50
|
+
- **preview** (the one-sentence routing line): the workhorse. Costs one line; the body is read on demand.
|
|
51
|
+
- **name** (title only): catalog-style docs whose name already routes (`api-reference`).
|
|
52
|
+
- **none**: invisible except to `crtr memory find` — archival or narrowly specialized material.
|
|
53
|
+
|
|
54
|
+
`short-form` is NOT a rung and never enters an agent's context (an agent handed a summary satisfices and skips the real read). It is the gist a human sees in `crtr memory list` — write it for them, and don't make it do routing work.
|
|
55
|
+
|
|
56
|
+
## The routing line
|
|
57
|
+
|
|
58
|
+
`when-and-why-to-read` is **read-routing, never content**: it answers when to open the doc and why the read pays — never why the content should be obeyed, and never a paraphrase of the content. A preview that gives away the gist defeats the ladder: the agent feels informed and skips the body. Shape: *"When <circumstance the agent is in>, this <kind> should be read because <what the read buys>."* The test: can a stranger mid-task decide from this single line alone whether to spend the read?
|
|
59
|
+
|
|
60
|
+
## Gates — conditioning on who the agent is
|
|
61
|
+
|
|
62
|
+
An optional `gate:` predicate makes the doc eligible only for nodes whose own config matches. Subject fields: `kind` (node role, free-form), `mode` (base|orchestrator), `lifecycle` (terminal|resident), `hasManager` (bool), `cwd`, `scope` (user|project), `orchestration.depth` (hops to the root orchestrator; root = 0). Matchers: scalar equality, list membership, or operator objects (`{gte: 2}`, `{in: […]}`, `{matches: "…"}`, `exists`, `contains`…), with `all`/`any`/`not` combinators.
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
gate: { mode: orchestrator } # only delegating managers
|
|
66
|
+
gate: { orchestration.depth: { gte: 2 } } # only substantial, scaled-up efforts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Default is no gate — always eligible; most docs want exactly that. An empty `gate: {}` is inert (never matches) — don't write it. A failing gate hides the doc from both hooks, but it stays findable by search.
|
|
70
|
+
|
|
71
|
+
## Scope — where the file lives
|
|
72
|
+
|
|
73
|
+
- **user** (`~/.crouter/memory/`): facts about the user, cross-project behavior. The chicken memory goes here.
|
|
74
|
+
- **project** (`<dir>/.crouter/memory/`): anything about a codebase or workspace — and place it in the *specific directory* it describes (any directory can hold a `.crouter/`), so positional on-read fires precisely.
|
|
75
|
+
- **builtin**: ships with crtr itself (contributed via `src/builtin-memory/` in the crouter repo) — docs every crtr agent should have.
|
|
76
|
+
|
|
77
|
+
Resolution is project > user > builtin with leaf-name fallback (`read <leaf>` finds `area/<leaf>` when unambiguous), so a project doc can shadow a same-named user or builtin doc.
|
|
78
|
+
|
|
79
|
+
## Mechanics worth knowing
|
|
80
|
+
|
|
81
|
+
- **Directory entries**: a directory may carry an `INDEX.md` with the same frontmatter schema as any doc; the dir then renders as one entry at the INDEX's rung, and that rung is a **ceiling for its subtree** (`none` hides the whole dir). When a doc mysteriously isn't surfacing, check its ancestors' INDEX rungs and its gate.
|
|
82
|
+
- **CLI**: `crtr memory list` (human inventory) · `read <name>` (names are path-derived, never file paths) · `find <query>` (search ignores gates and rungs) · `write` (author) · `lint` (validate after authoring). Run `-h` on a leaf before first use.
|
|
83
|
+
|
|
84
|
+
## Body content
|
|
85
|
+
|
|
86
|
+
Write for a stranger: a future session that shares none of this conversation. No "as discussed", no narration of how you learned it — state current truth, not the history of getting there. Keep the reasoning behind rules; cut everything else. Don't pad: a preference can legitimately be two sentences, and a skill should lead with decisions, not mechanism. A body behind `preview` may be long when it earns it, but every line still costs a reader who is mid-task — dense beats complete.
|
|
87
|
+
|
|
88
|
+
## Worked example
|
|
89
|
+
|
|
90
|
+
User says: *"remember that I like chicken."*
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
echo "Silas likes chicken." | crtr memory write food-preferences \
|
|
94
|
+
--kind reference --scope user \
|
|
95
|
+
--when-and-why-to-read "When you are choosing or recommending food, meals, or recipes for Silas, this reference should be read because it records his food preferences." \
|
|
96
|
+
--short-form "Silas's food likes/dislikes" \
|
|
97
|
+
--system-prompt-visibility preview
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Reference (a fact, not a directive) · user scope (about the person, not a repo) · `preview` at boot (no code directory to anchor on-read) · named for the recurring circumstance, so future food facts append to the same doc. After authoring, validate: `crtr memory lint`.
|
|
@@ -50,13 +50,13 @@ Larger artifacts — specs, plans, exploration findings, test recipes — live a
|
|
|
50
50
|
|
|
51
51
|
Separate from the roadmap (your live plan and state) you have a persistent document substrate that outlasts any single roadmap: skills you adopt, preferences about how you work, references to external resources, and facts about the human and the project. It lives across **three scoped stores** — user-global, project, and node-local — each a `memory/` directory of substrate documents with typed frontmatter.
|
|
52
52
|
|
|
53
|
-
**Reading.** At boot, skills and preferences surface in your system prompt automatically (
|
|
53
|
+
**Reading.** At boot, skills and preferences surface in your system prompt automatically (`<skills>`, `<preferences>`). References surface in your `<crtr-context>` block (`<references>`). Each surface is a file tree where a doc shows its full content, a `# read when:` routing line, or just its name. To browse the full inventory: `crtr memory list`. To search by topic: `crtr memory find <query>`. To load a document by name: `crtr memory read <name>`.
|
|
54
54
|
|
|
55
55
|
**Writing.** Use `crtr memory write` to create or update a document. Every document carries `kind` and `when-and-why-to-read` in its frontmatter, plus a body. `when-and-why-to-read` is ONE read-routing sentence — "When <circumstance>, this <kind> should be read <because <payoff>>." — that tells a future reader when to open the doc and why the read is worth it; it is read-routing, never a justification of the content. It becomes the preview line verbatim. The `kind` governs which section it surfaces in at boot and how it loads:
|
|
56
56
|
|
|
57
|
-
- `skill` — a workflow or methodology to adopt. Surfaces by name in
|
|
58
|
-
- `preference` — how you should work. Surfaces
|
|
59
|
-
- `reference` — a fact, pointer, or constraint. Surfaces in
|
|
57
|
+
- `skill` — a workflow or methodology to adopt. Surfaces by name in `<skills>`; load with `crtr memory read`.
|
|
58
|
+
- `preference` — how you should work. Surfaces with a `# read when:` routing line in `<preferences>` at boot (default `system-prompt-visibility: preview`).
|
|
59
|
+
- `reference` — a fact, pointer, or constraint. Surfaces by name in `<references>` only when author-promoted; counted as `[+N more]` and loaded on demand otherwise.
|
|
60
60
|
|
|
61
61
|
The scope decides which nodes see the document. `user` scope loads into every orchestrator everywhere. `project` scope loads into orchestrators working in this repo. `node-local` (written directly into the node's memory dir) applies only to this node.
|
|
62
62
|
|
|
@@ -20,6 +20,9 @@ import { CustomEditor, getSelectListTheme, } from '@earendil-works/pi-coding-age
|
|
|
20
20
|
import { defineBranch, defineLeaf } from '../../core/command.js';
|
|
21
21
|
import { InputError } from '../../core/io.js';
|
|
22
22
|
import { getNode } from '../../core/canvas/index.js';
|
|
23
|
+
// tmux driver verbs only through placement (the §5.1 model-over-driver seam) —
|
|
24
|
+
// never `core/runtime/tmux.js` directly.
|
|
25
|
+
import { setPaneOption, currentTmux } from '../../core/runtime/placement.js';
|
|
23
26
|
import { ChatView } from './chat-view.js';
|
|
24
27
|
import { InputController } from './input-controller.js';
|
|
25
28
|
import { applyTheme, createKeybindingsManager } from './config-load.js';
|
|
@@ -72,6 +75,28 @@ async function runAttach(nodeId, observer) {
|
|
|
72
75
|
next: 'Check the node has a running headless broker.',
|
|
73
76
|
});
|
|
74
77
|
});
|
|
78
|
+
// Self-tag this pane with the node it now views so the host-agnostic
|
|
79
|
+
// `nodeInPane()` (node cycle/recycle/close/demote/lifecycle) resolves a
|
|
80
|
+
// broker-hosted node from its VIEWER pane — the broker engine runs detached
|
|
81
|
+
// (window=null), so a window→node lookup can't find it; this pane tag is the
|
|
82
|
+
// handle. Set AFTER the connect proves a broker is here, mirrors view-run's
|
|
83
|
+
// `@crtr_view` self-tag. Cleared on teardown so a stray tag can't outlive the
|
|
84
|
+
// viewer (best-effort; tmux-only).
|
|
85
|
+
const tagPane = process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
86
|
+
if (tagPane !== undefined && tagPane !== '') {
|
|
87
|
+
try {
|
|
88
|
+
setPaneOption(tagPane, '@crtr_node', nodeId);
|
|
89
|
+
}
|
|
90
|
+
catch { /* best-effort */ }
|
|
91
|
+
}
|
|
92
|
+
const clearPaneTag = () => {
|
|
93
|
+
if (tagPane !== undefined && tagPane !== '') {
|
|
94
|
+
try {
|
|
95
|
+
setPaneOption(tagPane, '@crtr_node', '');
|
|
96
|
+
}
|
|
97
|
+
catch { /* best-effort */ }
|
|
98
|
+
}
|
|
99
|
+
};
|
|
75
100
|
// 1. Theme + keybindings FIRST — pi's components throw "Theme not initialized"
|
|
76
101
|
// otherwise (T5 contract). One KeybindingsManager feeds BOTH editor + input.
|
|
77
102
|
applyTheme({ cwd: meta.cwd });
|
|
@@ -218,6 +243,7 @@ async function runAttach(nodeId, observer) {
|
|
|
218
243
|
if (tornDown)
|
|
219
244
|
return;
|
|
220
245
|
tornDown = true;
|
|
246
|
+
clearPaneTag();
|
|
221
247
|
removeKeyListener();
|
|
222
248
|
// A clean detach tells the broker to drop this viewer (the engine runs on);
|
|
223
249
|
// on broker-gone the socket is already dead, so skip `bye`.
|
package/dist/commands/node.d.ts
CHANGED
|
@@ -4,11 +4,20 @@ import { type BranchDef } from '../core/command.js';
|
|
|
4
4
|
* YIELD_NUDGE_THRESHOLD, steer it to yield now and let its fresh revive handle
|
|
5
5
|
* the child's result. */
|
|
6
6
|
export declare function childFollowUp(spawnerId: string | undefined): string;
|
|
7
|
-
/** The live node
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
/** The live node "in front of you" in a tmux pane, HOST-AGNOSTIC. Defaults to
|
|
8
|
+
* $TMUX_PANE / the caller's current pane when `pane` is omitted — shared by
|
|
9
|
+
* `node recycle` / `node demote` / `node lifecycle` / `node close` / `node
|
|
10
|
+
* cycle`, and the `canvas chord` / `canvas tmux-spread` leaves.
|
|
11
|
+
*
|
|
12
|
+
* Two resolutions, tried in order:
|
|
13
|
+
* 1. VIEWER pane (broker host): a `crtr attach` viewer self-tags its pane with
|
|
14
|
+
* the node it views (`@crtr_node`). A broker engine runs DETACHED with
|
|
15
|
+
* window=null, so the window→node lookup never finds it — the pane tag is
|
|
16
|
+
* the only handle. Checked FIRST because the tag is pane-precise, whereas a
|
|
17
|
+
* window is shared: a viewer SPLIT beside a tmux engine pane sits in that
|
|
18
|
+
* engine's window, so window→node would mis-resolve to the engine.
|
|
19
|
+
* 2. ENGINE pane (tmux host): the node owns this pane's window.
|
|
20
|
+
* A stale tag (attach SIGKILLed without clearing) is ignored — the tagged node
|
|
21
|
+
* must still be live (active/idle); recycle clears it on the tmux respawn path. */
|
|
13
22
|
export declare function nodeInPane(pane?: string): string | undefined;
|
|
14
23
|
export declare function registerNode(): BranchDef;
|
package/dist/commands/node.js
CHANGED
|
@@ -15,7 +15,7 @@ import { newNodeId } from '../core/runtime/nodes.js';
|
|
|
15
15
|
import { readRoadmap } from '../core/runtime/roadmap.js';
|
|
16
16
|
import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
|
|
17
17
|
import { recycleNode } from '../core/runtime/recycle.js';
|
|
18
|
-
import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux } from '../core/runtime/placement.js';
|
|
18
|
+
import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane, currentTmux, getPaneOption } from '../core/runtime/placement.js';
|
|
19
19
|
import { buildLaunchSpec } from '../core/runtime/launch.js';
|
|
20
20
|
import { closeNode } from '../core/runtime/close.js';
|
|
21
21
|
import { appendInbox } from '../core/feed/inbox.js';
|
|
@@ -311,15 +311,32 @@ function nodeByWindow(win) {
|
|
|
311
311
|
}
|
|
312
312
|
return undefined;
|
|
313
313
|
}
|
|
314
|
-
/** The live node
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
314
|
+
/** The live node "in front of you" in a tmux pane, HOST-AGNOSTIC. Defaults to
|
|
315
|
+
* $TMUX_PANE / the caller's current pane when `pane` is omitted — shared by
|
|
316
|
+
* `node recycle` / `node demote` / `node lifecycle` / `node close` / `node
|
|
317
|
+
* cycle`, and the `canvas chord` / `canvas tmux-spread` leaves.
|
|
318
|
+
*
|
|
319
|
+
* Two resolutions, tried in order:
|
|
320
|
+
* 1. VIEWER pane (broker host): a `crtr attach` viewer self-tags its pane with
|
|
321
|
+
* the node it views (`@crtr_node`). A broker engine runs DETACHED with
|
|
322
|
+
* window=null, so the window→node lookup never finds it — the pane tag is
|
|
323
|
+
* the only handle. Checked FIRST because the tag is pane-precise, whereas a
|
|
324
|
+
* window is shared: a viewer SPLIT beside a tmux engine pane sits in that
|
|
325
|
+
* engine's window, so window→node would mis-resolve to the engine.
|
|
326
|
+
* 2. ENGINE pane (tmux host): the node owns this pane's window.
|
|
327
|
+
* A stale tag (attach SIGKILLed without clearing) is ignored — the tagged node
|
|
328
|
+
* must still be live (active/idle); recycle clears it on the tmux respawn path. */
|
|
320
329
|
export function nodeInPane(pane) {
|
|
321
330
|
const resolvePane = pane ?? process.env['TMUX_PANE'] ?? currentTmux()?.pane;
|
|
322
|
-
|
|
331
|
+
if (resolvePane === undefined || resolvePane === '')
|
|
332
|
+
return undefined;
|
|
333
|
+
const tagged = getPaneOption(resolvePane, '@crtr_node');
|
|
334
|
+
if (tagged !== undefined && tagged !== '') {
|
|
335
|
+
const m = getNode(tagged);
|
|
336
|
+
if (m !== null && (m.status === 'active' || m.status === 'idle'))
|
|
337
|
+
return tagged;
|
|
338
|
+
}
|
|
339
|
+
const win = windowOfPane(resolvePane);
|
|
323
340
|
return win !== null ? nodeByWindow(win) : undefined;
|
|
324
341
|
}
|
|
325
342
|
const nodeRecycle = defineLeaf({
|
|
@@ -360,8 +377,15 @@ const nodeRecycle = defineLeaf({
|
|
|
360
377
|
return { recycled: res.recycled, node_id: id, finalized: res.finalized, delivered: res.delivered.length, new_root: res.newRoot ?? undefined };
|
|
361
378
|
},
|
|
362
379
|
render: (r) => {
|
|
363
|
-
if (r['recycled'] !== true)
|
|
380
|
+
if (r['recycled'] !== true) {
|
|
381
|
+
// A broker recycle that finalized + spawned a fresh root but whose new
|
|
382
|
+
// broker never served its socket: the state DID change (node finished, root
|
|
383
|
+
// born), only the viewer re-attach failed — say so, don't call it a no-op.
|
|
384
|
+
if (r['finalized'] === true && r['new_root']) {
|
|
385
|
+
return `Finished ${r['node_id']} and spawned a fresh broker root (${r['new_root']}), but its viewer did not attach — the new broker did not come up. Re-focus the root to view it.`;
|
|
386
|
+
}
|
|
364
387
|
return `Recycle failed for ${r['node_id'] ?? '?'} — not in tmux, or no agent in this pane.`;
|
|
388
|
+
}
|
|
365
389
|
const lines = [`Recycled the pane — finished ${r['node_id']}.`, `- finalized: ${r['finalized']}`];
|
|
366
390
|
if (r['finalized'] === true)
|
|
367
391
|
lines.push(`- delivered: ${r['delivered']}`);
|
|
@@ -1,8 +1,3 @@
|
|
|
1
1
|
import type { Scope } from '../../types.js';
|
|
2
2
|
export declare function resolveWriteScope(scopeStr: string | undefined): Scope;
|
|
3
3
|
export declare const VALID_TYPES: readonly ["playbook", "primer", "reference", "runbook", "freeform"];
|
|
4
|
-
export type CatalogSource = {
|
|
5
|
-
plugin: string;
|
|
6
|
-
roots: string[];
|
|
7
|
-
};
|
|
8
|
-
export declare function renderCatalogSection(label: string, sources: CatalogSource[], descriptions: Map<string, string>, out: string[]): void;
|
|
@@ -17,84 +17,3 @@ export function resolveWriteScope(scopeStr) {
|
|
|
17
17
|
// Valid skill types (used by author sub-branch)
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
export const VALID_TYPES = ['playbook', 'primer', 'reference', 'runbook', 'freeform'];
|
|
20
|
-
const CATALOG_T = 5;
|
|
21
|
-
export function renderCatalogSection(label, sources, descriptions, out) {
|
|
22
|
-
if (sources.length === 0)
|
|
23
|
-
return;
|
|
24
|
-
const count = sources.reduce((n, s) => n + s.roots.length, 0);
|
|
25
|
-
out.push('');
|
|
26
|
-
out.push(`${label} (${count})`);
|
|
27
|
-
const named = [...sources].sort((a, b) => a.plugin.localeCompare(b.plugin));
|
|
28
|
-
if (named.length === 0)
|
|
29
|
-
return;
|
|
30
|
-
const classified = named.map((s) => {
|
|
31
|
-
const subcats = new Map();
|
|
32
|
-
const bare = [];
|
|
33
|
-
for (const n of s.roots) {
|
|
34
|
-
const slash = n.indexOf('/');
|
|
35
|
-
if (slash === -1) {
|
|
36
|
-
bare.push(n);
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
const sub = n.slice(0, slash);
|
|
40
|
-
const rest = n.slice(slash + 1);
|
|
41
|
-
const arr = subcats.get(sub);
|
|
42
|
-
if (arr)
|
|
43
|
-
arr.push(rest);
|
|
44
|
-
else
|
|
45
|
-
subcats.set(sub, [rest]);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return { plugin: s.plugin, roots: s.roots, subcats, bare };
|
|
49
|
-
});
|
|
50
|
-
const descSuffix = (plugin) => {
|
|
51
|
-
const d = descriptions.get(plugin);
|
|
52
|
-
if (!d)
|
|
53
|
-
return '';
|
|
54
|
-
return ` — ${d.length > 80 ? d.slice(0, 77) + '…' : d}`;
|
|
55
|
-
};
|
|
56
|
-
// inlineW aligns the count column for collapsed + inline-enumerated plugins
|
|
57
|
-
// (nested plugins render their own header line, don't use this width)
|
|
58
|
-
const inlineW = classified
|
|
59
|
-
.filter((p) => {
|
|
60
|
-
const direct = p.subcats.size + p.bare.length;
|
|
61
|
-
return direct > CATALOG_T || p.subcats.size < 2;
|
|
62
|
-
})
|
|
63
|
-
.reduce((m, p) => Math.max(m, p.plugin.length + 1), 0);
|
|
64
|
-
for (const p of classified) {
|
|
65
|
-
// Bare native/builtin skills (no plugin namespace) carry sourceKey ''.
|
|
66
|
-
// Render them as flat lines, never under a stray '/' header.
|
|
67
|
-
if (p.plugin === '') {
|
|
68
|
-
for (const r of [...p.roots].sort())
|
|
69
|
-
out.push(` ${r}`);
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const direct = p.subcats.size + p.bare.length;
|
|
73
|
-
if (direct > CATALOG_T) {
|
|
74
|
-
out.push(` ${(p.plugin + '/').padEnd(inlineW)} ${p.roots.length} skills${descSuffix(p.plugin)}`);
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
if (p.subcats.size >= 2) {
|
|
78
|
-
out.push(` ${p.plugin}/`);
|
|
79
|
-
if (p.bare.length > 0) {
|
|
80
|
-
out.push(` ${[...p.bare].sort().join(', ')}`);
|
|
81
|
-
}
|
|
82
|
-
const subKeys = [...p.subcats.keys()].sort();
|
|
83
|
-
const subW = subKeys
|
|
84
|
-
.map((k) => `${k}/`)
|
|
85
|
-
.reduce((m, l) => (l.length > m ? l.length : m), 0);
|
|
86
|
-
for (const subKey of subKeys) {
|
|
87
|
-
const children = p.subcats.get(subKey).sort();
|
|
88
|
-
if (children.length > CATALOG_T) {
|
|
89
|
-
out.push(` ${(subKey + '/').padEnd(subW)} ${children.length} skills`);
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
out.push(` ${(subKey + '/').padEnd(subW)} ${children.join(', ')}`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
else {
|
|
97
|
-
out.push(` ${(p.plugin + '/').padEnd(inlineW)} ${[...p.roots].sort().join(', ')}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|