@tinoy/pi-canon 0.1.0

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/canon.ts +1751 -0
  4. package/package.json +41 -0
package/canon.ts ADDED
@@ -0,0 +1,1751 @@
1
+ /**
2
+ * Canon Extension
3
+ *
4
+ * Tool-managed binding system-prompt lines (rules + learned facts), scoped by
5
+ * model (global | <model-id>) and audience (all | parent | foreman | subagent).
6
+ * An entry's audience is a MEMBERSHIP test against the audience set its session
7
+ * belongs to, not a single session kind: a foreman session is {parent, foreman}.
8
+ * Injected into every matching session's system prompt at session start via
9
+ * before_agent_start AND guaranteed on every provider request by the
10
+ * normalization in before_provider_request (see the block comment there). The
11
+ * injected block is a session-start snapshot — the system prompt is
12
+ * prompt-cache-frozen, so runtime edits NEVER change a running session's
13
+ * block; new sessions get the current full list, running sessions learn of
14
+ * changes via canon notices (and the user's /canon-dump).
15
+ * Edited at runtime via canon_add / canon_remove / canon_edit (plus
16
+ * the /canon and /canon-dump commands). Changes are broadcast to peer sessions
17
+ * over the pi-intercom extension bus (namespace "canon"); each receiver matches
18
+ * the entry scope against its own model + audience before showing a notice.
19
+ *
20
+ * Replaces APPEND_SYSTEM.md (global/parent) and FLASH.md (global/subagent).
21
+ * Audience detection is one per-process set, read once at load (sessionAudiences).
22
+ * A child session is recognised by EITHER of two markers, because there are two
23
+ * launch paths: PI_SUBAGENT=1, exported by ~/.local/bin/pi-subagent and inherited
24
+ * by the pi processes it starts, and PI_SUBAGENT_CHILD=1, set in process.env by
25
+ * the pi-subagents async runner at module load, before the child session it hosts
26
+ * loads any extension. Either marker means {subagent}; otherwise {parent}, plus
27
+ * {foreman} when PI_FOREMAN=1, the value the pi-foreman launcher exports for the
28
+ * session it execs. The environment is the only signal present before the block
29
+ * renders, and it cannot change within a session, so the block is fixed for that
30
+ * session's life.
31
+ *
32
+ * Store: ~/.pi/agent/canon/canon.json — { entries: [{id, text, model, audience, reason?, category?}], categories: [{id, title, description?}] }
33
+ * Categories are un-ordered; entries reference them by id. They render as sub-headings
34
+ * inside scope groups (store insertion order), with uncategorized entries last.
35
+ * canon_add REQUIRES a category and canon_edit can only CHANGE one: every refusal
36
+ * lists the valid ids with their titles, so a retry costs no lookup call. The
37
+ * injected block renders titles only, so the refusal is also the only place an id
38
+ * reaches the model. The store validator counts entries that are uncategorized or
39
+ * carry a category id the store no longer holds (both render as Uncategorized) and
40
+ * reports the count to the hook log once per distinct state.
41
+ * Both the injected block and /canon-dump group entries under scope headers (model ×
42
+ * audience) — scope is NEVER repeated per line. Lines keep their [id] handle (the
43
+ * model needs it for canon_remove/canon_edit and to correlate notices/dump).
44
+ * Handles: opaque 6-char base36 ids generated by this extension, never reused.
45
+ */
46
+ import * as crypto from "node:crypto";
47
+ import * as fs from "node:fs";
48
+ import * as os from "node:os";
49
+ import * as path from "node:path";
50
+ import { Type } from "@earendil-works/pi-ai";
51
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
52
+ import {
53
+ argText,
54
+ canonicalSystemPrompt,
55
+ clip,
56
+ type HeaderPart,
57
+ hookLog,
58
+ PROMPT_APPEND_SEP,
59
+ safeToolHeader,
60
+ systemPromptSlot,
61
+ } from "@tinoy/pi-ext-lib";
62
+
63
+ const AGENT_DIR =
64
+ process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
65
+ const DATA_DIR = path.join(AGENT_DIR, "canon");
66
+ const STORE_PATH = path.join(DATA_DIR, "canon.json");
67
+ const NAMESPACE = "canon";
68
+ const HANDLE_LEN = 6;
69
+ const REASON_MAX = 120;
70
+
71
+ type Audience = "all" | "parent" | "foreman" | "subagent";
72
+
73
+ /** Every audience value, in group-render order (also the label order). ONE list:
74
+ * the store validator, the scope-group rank and the tool validators all read it,
75
+ * so a new audience is added here and nowhere else. */
76
+ const AUDIENCE_ORDER: readonly Audience[] = [
77
+ "all",
78
+ "parent",
79
+ "foreman",
80
+ "subagent",
81
+ ];
82
+
83
+ /** Is this string one of the audience values? */
84
+ function isAudience(value: string): value is Audience {
85
+ return (AUDIENCE_ORDER as readonly string[]).includes(value);
86
+ }
87
+
88
+ interface CanonScope {
89
+ model: string; // "global" | bare model id (provider prefix stripped)
90
+ /** Matched against the session's audience set; "all" matches every set. */
91
+ audience: Audience;
92
+ }
93
+
94
+ interface CanonEntry extends CanonScope {
95
+ id: string;
96
+ text: string;
97
+ reason?: string;
98
+ category?: string; // id of a category in store.categories; dangling -> Uncategorized
99
+ }
100
+
101
+ interface CanonCategory {
102
+ id: string;
103
+ title: string;
104
+ description?: string;
105
+ }
106
+
107
+ interface Store {
108
+ entries: CanonEntry[];
109
+ categories: CanonCategory[];
110
+ }
111
+
112
+ interface CanonNotice {
113
+ type: "canon";
114
+ op:
115
+ | "add"
116
+ | "remove"
117
+ | "edit"
118
+ | "category_add"
119
+ | "category_edit"
120
+ | "category_remove";
121
+ scope: CanonScope;
122
+ id: string;
123
+ text: string;
124
+ reason?: string;
125
+ sender?: string;
126
+ }
127
+
128
+ interface CanonChannel {
129
+ publish(
130
+ payload: unknown,
131
+ options?: { audience?: "owner" | "capable" },
132
+ ): Promise<void>;
133
+ listSessions(): Promise<Array<{ id: string; name?: string }>>;
134
+ }
135
+
136
+ interface CanonRegistration {
137
+ namespace: string;
138
+ ownerEligible: boolean;
139
+ onEvent(event: {
140
+ type: string;
141
+ fromSessionId?: string;
142
+ payload?: unknown;
143
+ }): void;
144
+ onReady(channel: CanonChannel): void;
145
+ }
146
+
147
+ // ---------- pure store helpers ----------
148
+
149
+ function normalizeModel(model: string): string {
150
+ const bare = model.includes("/")
151
+ ? model.slice(model.lastIndexOf("/") + 1)
152
+ : model;
153
+ return bare.trim().toLowerCase();
154
+ }
155
+
156
+ /** Entry ids with no category, and entry ids whose category id the store does not
157
+ * hold. Both render as Uncategorized, so the counts are the only signal that the
158
+ * store has drifted away from its own categories. */
159
+ function categoryDrift(store: Store): {
160
+ uncategorized: string[];
161
+ dangling: string[];
162
+ } {
163
+ const known = new Set(store.categories.map((c) => c.id));
164
+ const uncategorized: string[] = [];
165
+ const dangling: string[] = [];
166
+ for (const e of store.entries) {
167
+ if (!e.category) uncategorized.push(e.id);
168
+ else if (!known.has(e.category)) dangling.push(e.id);
169
+ }
170
+ return { uncategorized, dangling };
171
+ }
172
+
173
+ /** Ids logged per drift kind. The count is the signal; the ids are where a repair
174
+ * starts, and the store holds entries by the hundred. */
175
+ const DRIFT_SAMPLE = 20;
176
+
177
+ /** Drift signature last logged by this process: loadStore runs on every canon call
178
+ * and on every block render, so the line is written once per distinct state rather
179
+ * than once per load — one line, never one per entry. */
180
+ let driftLogged: string | null = null;
181
+
182
+ /** Report categorisation drift to the hook log. Reaches no session's prompt: a
183
+ * dynamic line in the injected block would rewrite the cached prefix on every
184
+ * load, so the count belongs in the log and in /canon-dump. */
185
+ function logCategoryDrift(store: Store): void {
186
+ const drift = categoryDrift(store);
187
+ const signature = `${drift.uncategorized.length}/${drift.dangling.length}/${store.entries.length}`;
188
+ if (signature === driftLogged) return;
189
+ driftLogged = signature;
190
+ hookLog("canon", "category-drift", {
191
+ entries: store.entries.length,
192
+ categories: store.categories.length,
193
+ uncategorized: drift.uncategorized.length,
194
+ dangling: drift.dangling.length,
195
+ uncategorizedSample: drift.uncategorized.slice(0, DRIFT_SAMPLE),
196
+ danglingSample: drift.dangling.slice(0, DRIFT_SAMPLE),
197
+ truncated:
198
+ drift.uncategorized.length + drift.dangling.length > DRIFT_SAMPLE,
199
+ });
200
+ }
201
+
202
+ function loadStore(): Store {
203
+ try {
204
+ const raw = JSON.parse(fs.readFileSync(STORE_PATH, "utf8")) as Store;
205
+ if (Array.isArray(raw.entries)) {
206
+ raw.entries = raw.entries.filter(
207
+ (e): e is CanonEntry =>
208
+ typeof e?.id === "string" &&
209
+ typeof e?.text === "string" &&
210
+ typeof e?.model === "string" &&
211
+ isAudience(e.audience) &&
212
+ (e?.reason === undefined || typeof e?.reason === "string") &&
213
+ (e?.category === undefined || typeof e?.category === "string"),
214
+ );
215
+ raw.categories = Array.isArray(raw.categories)
216
+ ? raw.categories.filter(
217
+ (c): c is CanonCategory =>
218
+ typeof c?.id === "string" &&
219
+ typeof c?.title === "string" &&
220
+ (c?.description === undefined || typeof c?.description === "string"),
221
+ )
222
+ : [];
223
+ logCategoryDrift(raw);
224
+ return raw;
225
+ }
226
+ } catch {
227
+ // missing or corrupt store -> start empty
228
+ }
229
+ return { entries: [], categories: [] };
230
+ }
231
+
232
+ function saveStore(store: Store): void {
233
+ fs.mkdirSync(DATA_DIR, { recursive: true });
234
+ const tmp = `${STORE_PATH}.tmp`;
235
+ fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", "utf8");
236
+ fs.renameSync(tmp, STORE_PATH);
237
+ }
238
+
239
+ function genId(store: Store): string {
240
+ let id: string;
241
+ do {
242
+ id = crypto
243
+ .randomBytes(4)
244
+ .readUInt32BE(0)
245
+ .toString(36)
246
+ .padStart(HANDLE_LEN, "0")
247
+ .slice(0, HANDLE_LEN);
248
+ } while (
249
+ store.entries.some((e) => e.id === id) ||
250
+ store.categories.some((c) => c.id === id)
251
+ );
252
+ return id;
253
+ }
254
+
255
+ /**
256
+ * The audiences a session belongs to, fixed at process start: PI_SUBAGENT=1 (the
257
+ * pi-subagent wrapper's marker) OR PI_SUBAGENT_CHILD=1 (the pi-subagents async
258
+ * runner's marker) -> {subagent}; otherwise {parent}, plus {foreman} when
259
+ * PI_FOREMAN=1 (the value the pi-foreman launcher exports for the session it
260
+ * execs). PI_FOREMAN is inherited by child processes, so a subagent is reduced to
261
+ * {subagent} — a foreman's worker is not a foreman. Called once per process and
262
+ * never re-read: the rendered block sits in the system-prompt prefix, so a set
263
+ * that varied mid-session would re-bill the tools array and the whole
264
+ * conversation.
265
+ */
266
+ function sessionAudiences(isSubagent: boolean): Set<Audience> {
267
+ if (isSubagent) return new Set<Audience>(["subagent"]);
268
+ const audiences = new Set<Audience>(["parent"]);
269
+ if (process.env.PI_FOREMAN === "1") audiences.add("foreman");
270
+ return audiences;
271
+ }
272
+
273
+ /** This session's audience set as a label: "parent", "parent, foreman", "subagent". */
274
+ function sessionLabel(audiences: ReadonlySet<Audience>): string {
275
+ return AUDIENCE_ORDER.filter((a) => a !== "all" && audiences.has(a)).join(
276
+ ", ",
277
+ );
278
+ }
279
+
280
+ /** Does an entry apply to a session with the given active model + audiences? */
281
+ function matches(
282
+ scope: CanonScope,
283
+ model: string,
284
+ audiences: ReadonlySet<Audience>,
285
+ ): boolean {
286
+ if (scope.model !== "global" && scope.model !== model) return false;
287
+ if (scope.audience === "all") return true;
288
+ return audiences.has(scope.audience);
289
+ }
290
+
291
+ /** Audience label appended to scope headings (all is implied by the bare model part). */
292
+ const AUDIENCE_HEADING: Record<Audience, string> = {
293
+ all: "all sessions",
294
+ parent: "parent only",
295
+ foreman: "foreman only",
296
+ subagent: "subagent only",
297
+ };
298
+
299
+ /** Scope heading: model part, plus audience part only when it narrows. */
300
+ function groupHeading(entryScope: CanonScope): string {
301
+ const modelPart =
302
+ entryScope.model === "global"
303
+ ? "All models"
304
+ : `This model (${entryScope.model})`;
305
+ if (entryScope.audience === "all") return modelPart;
306
+ return `${modelPart} — ${AUDIENCE_HEADING[entryScope.audience]}`;
307
+ }
308
+
309
+ interface ScopeGroup {
310
+ scope: CanonScope;
311
+ rank: [number, number, string];
312
+ entries: CanonEntry[];
313
+ }
314
+
315
+ /** Bucket entries into scope groups: global first, then models; audiences in
316
+ * AUDIENCE_ORDER (all → parent → foreman → subagent). */
317
+ function scopeGroups(entries: CanonEntry[]): ScopeGroup[] {
318
+ const modelRank = (m: string) => (m === "global" ? 0 : 1);
319
+ const audienceRank = (a: Audience) => AUDIENCE_ORDER.indexOf(a);
320
+ const groups = new Map<string, ScopeGroup>();
321
+ for (const e of entries) {
322
+ const key = `${e.model} / ${e.audience}`;
323
+ let g = groups.get(key);
324
+ if (!g) {
325
+ g = {
326
+ scope: { model: e.model, audience: e.audience },
327
+ rank: [modelRank(e.model), audienceRank(e.audience), e.model],
328
+ entries: [],
329
+ };
330
+ groups.set(key, g);
331
+ }
332
+ g.entries.push(e);
333
+ }
334
+ return [...groups.values()].sort(
335
+ (a, b) =>
336
+ a.rank[0] - b.rank[0] ||
337
+ a.rank[1] - b.rank[1] ||
338
+ a.rank[2].localeCompare(b.rank[2]),
339
+ );
340
+ }
341
+
342
+ /** Render the injected block: header, scope groups, category sub-headings inside. */
343
+ function render(
344
+ store: Store,
345
+ model: string,
346
+ audiences: ReadonlySet<Audience>,
347
+ ): string {
348
+ const matching = store.entries.filter((e) => matches(e, model, audiences));
349
+ const audienceLabel = sessionLabel(audiences);
350
+ let block = `## Canon — binding system-prompt rules\n\nThe rules below are authoritative standing instructions. They are part of your system prompt, persist for the entire session, and apply to every turn without exception. They override conflicting guidance from tool descriptions, examples, and any lower-precedence text. Follow them strictly.\n\nRuntime canon edits (canon_add / canon_edit / canon_remove) and peer canon notices reach this session as \`canon_notice\` messages; each notice is a live update to these rules and must be followed from the turn it is applied. A notice does not wake an idle session — it is applied on the next turn and remains in force.\n\nActive model: ${model} (this session: ${audienceLabel})`;
351
+ if (matching.length === 0) return block;
352
+
353
+ const catById = new Map(store.categories.map((c) => [c.id, c.title]));
354
+ for (const g of scopeGroups(matching)) {
355
+ block += `\n\n### ${groupHeading(g.scope)}`;
356
+ // bucket lines by category: store insertion order, uncategorized last
357
+ const buckets = new Map<string, CanonEntry[]>();
358
+ const uncat: CanonEntry[] = [];
359
+ for (const e of g.entries) {
360
+ if (e.category && catById.has(e.category)) {
361
+ const b = buckets.get(e.category);
362
+ if (b) b.push(e);
363
+ else buckets.set(e.category, [e]);
364
+ } else uncat.push(e);
365
+ }
366
+ const orderedCats = store.categories.filter((c) => buckets.has(c.id));
367
+ // only show category sub-headings when a scope group actually splits
368
+ const showCatHeadings = orderedCats.length + (uncat.length ? 1 : 0) >= 2;
369
+ for (const c of orderedCats) {
370
+ if (showCatHeadings) block += `\n\n#### ${c.title}`;
371
+ block += `\n${buckets
372
+ .get(c.id)!
373
+ .map((e) => `[${e.id}] ${e.text}`)
374
+ .join("\n")}`;
375
+ }
376
+ if (uncat.length) {
377
+ if (showCatHeadings) block += `\n\n#### Uncategorized`;
378
+ block += `\n${uncat.map((e) => `[${e.id}] ${e.text}`).join("\n")}`;
379
+ }
380
+ }
381
+ return block;
382
+ }
383
+
384
+ /** Render /canon-dump body: scope groups (model × audience), category sub-headings inside. */
385
+ function renderDump(
386
+ entries: CanonEntry[],
387
+ categories: CanonCategory[],
388
+ ): string {
389
+ const lines: string[] = [];
390
+ for (const g of scopeGroups(entries)) {
391
+ lines.push(`## ${groupHeading(g.scope)}`);
392
+ const buckets = new Map<string, CanonEntry[]>();
393
+ const uncat: CanonEntry[] = [];
394
+ for (const e of g.entries) {
395
+ if (e.category && categories.some((c) => c.id === e.category)) {
396
+ const b = buckets.get(e.category!);
397
+ if (b) b.push(e);
398
+ else buckets.set(e.category!, [e]);
399
+ } else uncat.push(e);
400
+ }
401
+ for (const c of categories.filter((x) => buckets.has(x.id))) {
402
+ lines.push(`### ${c.title}${c.description ? ` — ${c.description}` : ""}`);
403
+ for (const e of buckets.get(c.id)!) {
404
+ lines.push(`[${e.id}] ${e.text}${e.reason ? ` — ${e.reason}` : ""}`);
405
+ }
406
+ }
407
+ if (uncat.length) {
408
+ lines.push("### Uncategorized");
409
+ for (const e of uncat) {
410
+ lines.push(`[${e.id}] ${e.text}${e.reason ? ` — ${e.reason}` : ""}`);
411
+ }
412
+ }
413
+ }
414
+ return lines.join("\n");
415
+ }
416
+
417
+ /**
418
+ * Resolve a category reference to its id.
419
+ *
420
+ * The injected canon block shows category TITLES as sub-headings (`#### System
421
+ * Knowledge`) and never shows an id, so a model naturally passes the title back to
422
+ * canon_add — refusing that costs a call and teaches nothing. An exact id wins; a
423
+ * title matches case-insensitively; anything else fails with a short reason that
424
+ * the caller turns into a refusal.
425
+ */
426
+ function lookupCategory(
427
+ store: Store,
428
+ input: string,
429
+ ): { ok: true; id: string } | { ok: false; detail: string } {
430
+ const want = input.trim();
431
+ if (!want) return { ok: false, detail: "empty value" };
432
+ const byId = store.categories.find((c) => c.id === want);
433
+ if (byId) return { ok: true, id: byId.id };
434
+ const lower = want.toLowerCase();
435
+ const byTitle = store.categories.filter(
436
+ (c) => c.title.trim().toLowerCase() === lower,
437
+ );
438
+ if (byTitle.length === 1) return { ok: true, id: byTitle[0].id };
439
+ if (byTitle.length > 1) {
440
+ return {
441
+ ok: false,
442
+ detail: `title "${want}" matches ${byTitle.map((c) => c.id).join(", ")}`,
443
+ };
444
+ }
445
+ return { ok: false, detail: `no category "${want}"` };
446
+ }
447
+
448
+ /**
449
+ * The refusal for every rejected category value: what failed, then every valid id
450
+ * with its title so a retry needs no lookup call of its own, then the one
451
+ * instruction that answers it. Terse by construction — it lands in a model's
452
+ * context, and the listing is the only place a category id is ever shown (the
453
+ * injected block renders titles).
454
+ */
455
+ function categoryRefusal(
456
+ store: Store,
457
+ tool: string,
458
+ reason: string,
459
+ detail?: string,
460
+ ): string {
461
+ const listing = store.categories.length
462
+ ? store.categories.map((c) => `${c.id} = ${c.title}`).join("; ")
463
+ : "(none defined — create one with canon_category op add)";
464
+ return `${tool}: ${reason}${detail ? ` (${detail})` : ""}. Valid categories: ${listing}. Pick the closest existing category.`;
465
+ }
466
+
467
+ function modelExists(
468
+ model: string,
469
+ ctx: { modelRegistry?: { getAvailable?: () => unknown } },
470
+ ): boolean {
471
+ if (model === "global") return true;
472
+ try {
473
+ const available = ctx.modelRegistry?.getAvailable?.() as
474
+ | Array<{ id?: string; modelId?: string }>
475
+ | undefined;
476
+ if (!Array.isArray(available)) return true; // registry unavailable -> don't block
477
+ const ids = new Set(
478
+ available.flatMap((m) => [m.id, m.modelId]).filter(Boolean),
479
+ );
480
+ return (
481
+ ids.has(model) ||
482
+ ids.has(`/${model}`) ||
483
+ [...ids].some((i) => i?.endsWith(`/${model}`))
484
+ );
485
+ } catch {
486
+ return true;
487
+ }
488
+ }
489
+
490
+ // ---------- tail sections ----------
491
+
492
+ /**
493
+ * Registered tail sections, id -> text. Filled over the extension EVENT BUS, never
494
+ * by module import: pi evaluates every extension through its own jiti instance with
495
+ * `moduleCache: false` (dist/core/extensions/loader.js, `loadExtensionModule`), so
496
+ * another extension importing this file gets a DIFFERENT module instance and its
497
+ * registration would land in a map nothing renders from — silently, with no error
498
+ * and no log. The bus is created once per runtime and handed to every extension,
499
+ * and a handler's synchronous part runs before `emit` returns, so a section set at
500
+ * activation is composed into the very next request.
501
+ */
502
+ const sections = new Map<string, string>();
503
+
504
+ /** Section text cap. The tail sits in the prefix of EVERY request, so an
505
+ * accidental megabyte would be paid for on every call for the session's life. */
506
+ const SECTION_MAX_CHARS = 16_384;
507
+
508
+ /**
509
+ * Set — or, with an empty string, clear — a tail section. Not an extension-facing
510
+ * API: another extension reaches this through the `canon:section` event, because
511
+ * an exported function is unreachable across the loader's module isolation.
512
+ */
513
+ export function setTailSection(id: string, text: string): void {
514
+ const key = id.trim();
515
+ if (!key) return;
516
+ if (!text) {
517
+ if (sections.delete(key)) hookLog("canon", "section-cleared", { id: key });
518
+ return;
519
+ }
520
+ if (text.length > SECTION_MAX_CHARS) {
521
+ hookLog("canon", "section-too-long", { id: key, chars: text.length, cap: SECTION_MAX_CHARS });
522
+ return;
523
+ }
524
+ if (sections.get(key) === text) return;
525
+ sections.set(key, text);
526
+ // A section change rewrites the frozen prefix, so it is always attributable.
527
+ hookLog("canon", "section-set", {
528
+ id: key,
529
+ chars: text.length,
530
+ ids: registeredSectionIds().join(","),
531
+ });
532
+ }
533
+
534
+ /** Ids of every registered section, in the fixed order they compose in. */
535
+ export function registeredSectionIds(): string[] {
536
+ return [...sections.keys()].sort();
537
+ }
538
+
539
+ // ---------- extension ----------
540
+
541
+ export default function (pi: ExtensionAPI) {
542
+ let channel: CanonChannel | null = null;
543
+ let mySessionId: string | null = null;
544
+ let activeModel: string | null = null;
545
+ // Session-start snapshot: rendered once, reused verbatim every turn so the
546
+ // system prompt never changes mid-session (keeps the prompt cache intact).
547
+ // Runtime canon edits reach running sessions only via intercom notices.
548
+ let canonBlock: string | null = null;
549
+ /** Did before_agent_start fire since the previous provider request? Read (and
550
+ * cleared) by the payload repair to attribute a repair: false = the request
551
+ * came from a run-start path that never fires the hook (wake), true = the
552
+ * block was present in state but missing from the payload (anomaly). */
553
+ let hookFiredSinceLastRequest = false;
554
+ // Both child markers are read here, once, and never re-read: a crew worker
555
+ // arrives through the pi-subagents async runner (PI_SUBAGENT_CHILD, set by that
556
+ // runner before this module loads) while a pi-subagent wrapper launch carries
557
+ // PI_SUBAGENT. A worker whose foreman exported PI_FOREMAN is still a child.
558
+ const isSubagent =
559
+ process.env.PI_SUBAGENT === "1" || process.env.PI_SUBAGENT_CHILD === "1";
560
+ // Fixed for the process, like isSubagent: the block is a system-prompt prefix.
561
+ const audiences = sessionAudiences(isSubagent);
562
+
563
+ function currentModel(): string {
564
+ if (activeModel) return activeModel;
565
+ const env = process.env.PI_MODEL;
566
+ return env ? normalizeModel(env) : "unknown";
567
+ }
568
+
569
+ /** The block for this session, rendered at most once per process: a mid-session
570
+ * canon edit must never churn the prefix. Seeding the model from the hook ctx
571
+ * keeps the per-model filtering right when the FIRST request to need the block
572
+ * is routed by a path that never fired before_agent_start. */
573
+ function canonText(ctx?: { model?: unknown }): string {
574
+ const m =
575
+ (ctx?.model as { id?: string } | undefined)?.id ??
576
+ (ctx?.model as string | undefined);
577
+ if (m) activeModel = normalizeModel(String(m));
578
+ if (canonBlock === null) {
579
+ canonBlock = render(loadStore(), currentModel(), audiences);
580
+ }
581
+ return canonBlock;
582
+ }
583
+
584
+ /** The whole tail: the canon block, then every registered section. Composed in
585
+ * one place so the strip marker stays the canon block's first line and the
586
+ * canonicalizer needs no knowledge of what registered. Sections are plain
587
+ * strings, so composing cannot throw. */
588
+ function tailText(ctx?: { model?: unknown }): string {
589
+ let text = canonText(ctx);
590
+ for (const id of registeredSectionIds()) {
591
+ const section = sections.get(id);
592
+ if (section) text += `${PROMPT_APPEND_SEP}${section}`;
593
+ }
594
+ return text;
595
+ }
596
+
597
+ // The one channel another extension can reach the registry through. Kept
598
+ // synchronous deliberately: the bus wraps handlers in an async function, but the
599
+ // synchronous part runs before `emit` returns, so a section set during the
600
+ // activation command is already composed into the request that follows it.
601
+ pi.events.on("canon:section", (payload: unknown) => {
602
+ const p = payload as { id?: unknown; text?: unknown } | null;
603
+ if (!p || typeof p.id !== "string" || typeof p.text !== "string") return;
604
+ setTailSection(p.id, p.text);
605
+ // Announce the EFFECTIVE set, never the requested one. A section canon refused
606
+ // (an over-long text) must not be reported as present by anything that watches
607
+ // this channel — the cache log uses exactly that to prove a section reached the
608
+ // prompt, and a false positive there is indistinguishable from a success.
609
+ pi.events.emit("canon:sections", { ids: registeredSectionIds() });
610
+ });
611
+
612
+ // keep the cached model fresh for notification filtering
613
+ pi.on("before_agent_start", async (event, ctx) => {
614
+ hookFiredSinceLastRequest = true;
615
+ return { systemPrompt: event.systemPrompt + PROMPT_APPEND_SEP + tailText(ctx) };
616
+ });
617
+
618
+ // ── prompt-cache invariance: the block must not depend on the run-start path ──
619
+ //
620
+ // pi emits before_agent_start ONLY from the interactive prompt() path (pi
621
+ // 0.85.1: emitBeforeAgentStart has a single call site). A run started by an
622
+ // injected message — pi.sendMessage(msg, { triggerTurn: true }) while idle,
623
+ // which is how an async subagent completion, an intercom delivery or a
624
+ // delivered answer wakes a session — calls agent.prompt() directly, so it
625
+ // carries the UNMODIFIED base prompt and the block above never lands. The
626
+ // provider prefix is [system, tools, messages], so a block that moves inside
627
+ // the system prompt re-bills every token after it (the whole tools array plus
628
+ // the whole conversation) as a cache miss. Measured 2026-09-12: sys
629
+ // 134,308 -> 71,949 chars with the tools hash unchanged and 28,694 re-billed
630
+ // input tokens, reported as "Cache miss after 9m idle" (rows in
631
+ // ~/.local/state/pi/cache-prefix-log.jsonl).
632
+ //
633
+ // before_provider_request fires for every agent provider request on every
634
+ // run-start path (the miss rows above were written from this hook), so the
635
+ // payload is normalized here instead: every request leaves with
636
+ // canonicalSystemPrompt()'s bytes, appended exactly once at the same position.
637
+ // Compaction and branch summaries never reach this hook — they call the model
638
+ // runtime directly (completeSimple) with their own summarization prompt — so
639
+ // their prefix is untouched. The payload is mutated IN PLACE and no value is
640
+ // returned, so the bytes sent cannot depend on the handler order (the
641
+ // extension directory is read unsorted).
642
+ pi.on("before_provider_request", (event, ctx) => {
643
+ try {
644
+ const slot = systemPromptSlot((event as { payload?: unknown })?.payload);
645
+ if (!slot) return undefined;
646
+ const fired = hookFiredSinceLastRequest;
647
+ hookFiredSinceLastRequest = false;
648
+ const before = slot.get();
649
+ const { text, hadBlock } = canonicalSystemPrompt(before, tailText(ctx));
650
+ if (text === before) {
651
+ wakeRepairLogged = false; // canonical request: the next repair is a new event
652
+ return undefined; // already canonical: no write, no log
653
+ }
654
+ slot.set(text);
655
+ // A repair means the payload lacked the block. The HONEST attribution
656
+ // matters more than the repair itself: `wake` is the expected path (and
657
+ // the fix working, logged once per repaired run); `prompt` means
658
+ // before_agent_start DID fire since the last request and the block was
659
+ // stripped or replaced in between — a regression by another layer, which
660
+ // must never be silent, so it is logged every time.
661
+ if (fired || !wakeRepairLogged) {
662
+ wakeRepairLogged = true;
663
+ hookLog(
664
+ "canon",
665
+ fired ? "prompt-repair-after-hook" : "prompt-normalized",
666
+ {
667
+ model: currentModel(),
668
+ audience: isSubagent ? "subagent" : "parent",
669
+ staleBlock: hadBlock,
670
+ baseChars: before.length,
671
+ canonicalChars: text.length,
672
+ },
673
+ );
674
+ }
675
+ } catch {
676
+ /* a canonicalization fault must never break a provider request */
677
+ }
678
+ return undefined;
679
+ });
680
+
681
+ pi.on("model_select", (event) => {
682
+ const m =
683
+ (event.model as { id?: string } | undefined)?.id ??
684
+ (event.model as unknown as string | undefined);
685
+ if (m) activeModel = normalizeModel(String(m));
686
+ });
687
+
688
+ // Guard, not repair: a payload whose system prompt has a shape we cannot
689
+ // rewrite would silently keep the path-dependent asymmetry. One line per
690
+ // process is enough to make that visible; the request itself is untouched.
691
+ let unrepairableLogged = false;
692
+ /** Suppresses repeats of the expected wake-path repair line: a wake run's
693
+ * every request arrives base-only and is normalized, but one line names the
694
+ * run. An anomaly (fired === true) is never suppressed. */
695
+ let wakeRepairLogged = false;
696
+ pi.on("before_provider_request", (event) => {
697
+ try {
698
+ if (unrepairableLogged) return undefined;
699
+ const payload = (event as { payload?: unknown })?.payload as
700
+ | Record<string, unknown>
701
+ | undefined;
702
+ if (!payload || typeof payload !== "object") return undefined;
703
+ if (systemPromptSlot(payload)) return undefined;
704
+ unrepairableLogged = true;
705
+ hookLog("canon", "prompt-unrepairable", {
706
+ keys: Object.keys(payload).slice(0, 24),
707
+ });
708
+ } catch {
709
+ /* diagnostics must never break a provider request */
710
+ }
711
+ return undefined;
712
+ });
713
+
714
+ // Best-effort peer notice: the store write has already landed when this runs,
715
+ // so EVERY failure mode of the channel call is swallowed here and logged:
716
+ // - pi-intercom 0.12.1's channel.publish is SYNCHRONOUS and throws
717
+ // "Intercom is not connected" when the broker client is down;
718
+ // - it returns void, so .catch must only ever be reached through the
719
+ // optional call (chaining it directly was the TypeError that escaped);
720
+ // - a promise-returning build rejects instead of throwing — the guarded
721
+ // handler logs that too, so it can not become an unhandled rejection.
722
+ // A lost notice is NOT surfaced in the tool result: the result reports the
723
+ // STORE outcome (see the call sites), while the notice is ancillary — a
724
+ // failed announcement must never turn a successful edit into a failure.
725
+ function publishNotice(notice: Omit<CanonNotice, "type" | "sender">): void {
726
+ if (!channel) return;
727
+ try {
728
+ const payload: CanonNotice = {
729
+ ...notice,
730
+ type: "canon",
731
+ sender: pi.getSessionName() || undefined,
732
+ };
733
+ const result = channel.publish(payload, { audience: "capable" });
734
+ result?.catch?.(() => {
735
+ hookLog("canon", "notice-failed", {
736
+ op: notice.op,
737
+ id: notice.id,
738
+ reason: "publish rejected",
739
+ });
740
+ });
741
+ } catch (err) {
742
+ hookLog("canon", "notice-failed", {
743
+ op: notice.op,
744
+ id: notice.id,
745
+ reason: err instanceof Error ? err.message : String(err),
746
+ });
747
+ }
748
+ }
749
+
750
+ // ---------- intercom channel (mirrors intercom-broadcast.ts) ----------
751
+
752
+ const registration: CanonRegistration = {
753
+ namespace: NAMESPACE,
754
+ ownerEligible: false,
755
+ onEvent(event: {
756
+ type: string;
757
+ fromSessionId?: string;
758
+ payload?: unknown;
759
+ }): void {
760
+ if (event.type !== "message" || typeof event.fromSessionId !== "string")
761
+ return;
762
+ if (mySessionId !== null && event.fromSessionId === mySessionId) return;
763
+ const payload = event.payload as Partial<CanonNotice> | null;
764
+ if (!payload || payload.type !== "canon" || !payload.scope || !payload.id)
765
+ return;
766
+ if (!matches(payload.scope, currentModel(), audiences)) return;
767
+ const sender = payload.sender?.trim() || event.fromSessionId.slice(0, 8);
768
+ const verb =
769
+ payload.op === "remove" || payload.op === "category_remove"
770
+ ? "removed"
771
+ : payload.op === "edit" || payload.op === "category_edit"
772
+ ? "edited"
773
+ : "added";
774
+ pi.sendMessage(
775
+ {
776
+ customType: "canon_notice",
777
+ content: `**Canon ${verb} (${payload.scope.model} / ${payload.scope.audience})**\n[${payload.id}] ${payload.text}${payload.reason ? `\nreason: ${payload.reason}` : ""}\n— from ${sender}`,
778
+ display: true,
779
+ details: { fromSessionId: event.fromSessionId, canonId: payload.id },
780
+ },
781
+ // PASSIVE (user 2026-09-01): "steer" wakes idle sessions like a
782
+ // prompt; "nextTurn" queues for the next user turn, no wake-up.
783
+ { deliverAs: "nextTurn" },
784
+ );
785
+ },
786
+ onReady(readyChannel: CanonChannel): void {
787
+ channel = readyChannel;
788
+ },
789
+ };
790
+
791
+ function register() {
792
+ pi.events.emit("intercom:extension-register", registration);
793
+ }
794
+
795
+ // pi-intercom may load after this extension; re-emit once its registry is
796
+ // reported ready. First successful registration wins (duplicate namespace
797
+ // is rejected, not thrown).
798
+ pi.events.on("intercom:extension-registry-ready", () => {
799
+ if (!channel) register();
800
+ });
801
+ register();
802
+
803
+ // ---------- tools ----------
804
+
805
+ const canonAddTool = defineTool({
806
+ name: "canon_add",
807
+ label: "Add canon line",
808
+ description:
809
+ 'Add a binding system-prompt line (rule or learned fact) to the canon store. ALWAYS pass BOTH model AND audience explicitly — never omit them. model scopes to one model ("global" = every model, or a bare id like "glm-5.3-flash"); audience scopes to "parent" (interactive sessions), "foreman" (foreman-mode sessions — a foreman also receives every "parent" entry), "subagent" (child sessions only), or "all". Passing both keeps each rule OUT of sessions where it is false/noise — a line left at "global"/"all" lands in every model\'s system prompt, and one at "global"/"parent" in every interactive session\'s, bloating and confusing unrelated sessions. category is MANDATORY and must be an id that already exists: a call that omits it, or names one the store does not hold, is REFUSED with the list of valid ids — that refusal is the discovery channel, so no lookup call is needed, and a category is NEVER created implicitly. Pick the closest existing category, or create one first with canon_category op add when none fits. Injected into matching sessions\' system prompts next turn; peers are notified. Optionally pass a short reason why the line exists — shown in /canon-dump and peer notices. Write ONE rule per entry, and cross-reference another entry by its id instead of restating it.',
810
+ promptSnippet: "Add a durable rule/fact to the canon (system-prompt lines)",
811
+ promptGuidelines: [
812
+ "Use canon_add when the user states a durable behavioural preference, corrects the model about behaviour, or the session learns a durable system fact. This is the replacement for editing APPEND_SYSTEM.md/FLASH.md: update canon FIRST, before any other action.",
813
+ "ALWAYS pass BOTH model AND audience explicitly on every canon_add (and canon_edit when re-scoping) — do NOT omit them. Decide scope before writing: which model does this concern (one bare id, or \"global\" for every model)? which audience (\"parent\" = interactive sessions, \"foreman\" = foreman-mode sessions, \"subagent\" = child sessions only, \"all\")?",
814
+ "Foreman/fleet workflow wisdom (spawn timeouts, crew survival, wake behaviour, foreman discipline) takes audience \"foreman\", never \"global\"/\"parent\": it is true only while the foreman mode is on, and filed as \"parent\" it lands in every interactive session's system prompt.",
815
+ "Categorize by TRUTH-SCOPE, not convenience (user 2026-09-01 — common failure): a rule that references or only applies to one model (a model-specific capability/quirk, e.g. a vision rule for deepseek-flash) MUST be model-scoped, never global. Only genuinely every-session rules (cross-cutting tool preferences, machine-wide facts) are global/all. Over-broad scope lands unrelated instructions in unrelated sessions' system prompts → bloat + confusion.",
816
+ "If you are about to write a canon line and have not picked model + audience, STOP and pick them explicitly first. Omitting them is the failure to avoid.",
817
+ "Reason is optional on canon_add/canon_edit/canon_remove: omit it by default, and never let one exceed 120 chars (that fails the whole call).",
818
+ ],
819
+ parameters: Type.Object({
820
+ text: Type.String({ description: "The line text (rule or fact) to add" }),
821
+ model: Type.String({
822
+ description:
823
+ 'Scope model: "global" for every model, or a bare model id like "glm-5.3-flash". REQUIRED — pick explicitly. Scope follows what the entry IS, never coverage: a model/provider quirk stays on its model, and a general rule filed under one belongs on "global".',
824
+ }),
825
+ audience: Type.String({
826
+ description:
827
+ 'Audience: "parent" (interactive sessions), "foreman" (foreman-mode sessions only), "subagent" (child sessions only), or "all". REQUIRED — pick explicitly.',
828
+ }),
829
+ category: Type.Optional(
830
+ Type.String({
831
+ description:
832
+ "Category to file this entry under — an id (or exact title) that already exists. REQUIRED: omitting it, or naming a category the store does not hold, is refused with the list of valid ids (canon_category op list also shows them).",
833
+ }),
834
+ ),
835
+ reason: Type.Optional(
836
+ Type.String({
837
+ description:
838
+ "Optional — omit it by default, and pass one only when it genuinely helps. Max 120 chars: a longer reason fails the whole call. Shown in /canon-dump and peer notices, never in the injected prompt.",
839
+ }),
840
+ ),
841
+ }),
842
+ renderCall(args, theme) {
843
+ return safeToolHeader(theme, "canon_add", () => {
844
+ const parts: HeaderPart[] = [["accent", ` ${argText(args, "category") ?? "category?"}`]];
845
+ const text = argText(args, "text");
846
+ if (text) parts.push(["muted", " — "], ["dim", clip(text)]);
847
+ return parts;
848
+ });
849
+ },
850
+ async execute(
851
+ _toolCallId,
852
+ params: {
853
+ text: string;
854
+ model: string;
855
+ audience: string;
856
+ category?: string;
857
+ reason?: string;
858
+ },
859
+ _signal,
860
+ _onUpdate,
861
+ ctx,
862
+ ) {
863
+ const model = normalizeModel(params.model ?? "global");
864
+ const audience = params.audience ?? "all";
865
+ if (!isAudience(audience)) {
866
+ return {
867
+ content: [
868
+ {
869
+ type: "text",
870
+ text: `Invalid audience "${audience}": use one of ${AUDIENCE_ORDER.join(", ")}.`,
871
+ },
872
+ ],
873
+ details: { ok: false },
874
+ };
875
+ }
876
+ if (!modelExists(model, ctx)) {
877
+ return {
878
+ content: [
879
+ {
880
+ type: "text",
881
+ text: `Unknown model "${model}". Omit model for global scope, or use a model id from the registry.`,
882
+ },
883
+ ],
884
+ details: { ok: false },
885
+ };
886
+ }
887
+ let categoryId: string;
888
+ const store = loadStore();
889
+ if (params.category === undefined) {
890
+ return {
891
+ content: [
892
+ {
893
+ type: "text",
894
+ text: categoryRefusal(store, "canon_add", "category is required"),
895
+ },
896
+ ],
897
+ details: { ok: false },
898
+ };
899
+ }
900
+ const category = lookupCategory(store, params.category);
901
+ if (!category.ok) {
902
+ return {
903
+ content: [
904
+ {
905
+ type: "text",
906
+ text: categoryRefusal(
907
+ store,
908
+ "canon_add",
909
+ "unknown category",
910
+ category.detail,
911
+ ),
912
+ },
913
+ ],
914
+ details: { ok: false },
915
+ };
916
+ }
917
+ categoryId = category.id;
918
+ if (
919
+ params.reason !== undefined &&
920
+ (typeof params.reason !== "string" || params.reason.length > REASON_MAX)
921
+ ) {
922
+ return {
923
+ content: [
924
+ {
925
+ type: "text",
926
+ text: `Reason too long: max ${REASON_MAX} chars.`,
927
+ },
928
+ ],
929
+ details: { ok: false },
930
+ };
931
+ }
932
+ const id = genId(store);
933
+ const reason = params.reason?.trim() || undefined;
934
+ store.entries.push({
935
+ id,
936
+ text: params.text,
937
+ model,
938
+ audience,
939
+ category: categoryId,
940
+ ...(reason ? { reason } : {}),
941
+ });
942
+ saveStore(store);
943
+ mySessionId = ctx.sessionManager?.getSessionId() ?? mySessionId;
944
+ publishNotice({
945
+ op: "add",
946
+ scope: { model, audience },
947
+ id,
948
+ text: params.text,
949
+ ...(reason ? { reason } : {}),
950
+ });
951
+ return {
952
+ content: [
953
+ {
954
+ type: "text",
955
+ text: `Added canon [${id}] (${model} / ${audience}): ${params.text}${reason ? ` — ${reason}` : ""}`,
956
+ },
957
+ ],
958
+ details: {
959
+ ok: true,
960
+ id,
961
+ scope: { model, audience },
962
+ ...(reason ? { reason } : {}),
963
+ },
964
+ };
965
+ },
966
+ });
967
+
968
+ const canonRemoveTool = defineTool({
969
+ name: "canon_remove",
970
+ label: "Remove canon line",
971
+ description:
972
+ "Remove a canon line by its handle id (see /canon-dump for ids). Peers are notified. When testing a removal, remove ONLY ids your own canon_add calls created in this session — an id picked from a listing can be a real rule.",
973
+ promptSnippet: "Remove a canon line by id",
974
+ parameters: Type.Object({
975
+ id: Type.String({
976
+ description: "Handle id of the canon line to remove (see /canon-dump)",
977
+ }),
978
+ reason: Type.Optional(
979
+ Type.String({
980
+ description:
981
+ "Optional — omit it by default. Max 120 chars: a longer reason fails the whole call. Shown in peer notices.",
982
+ }),
983
+ ),
984
+ }),
985
+ renderCall(args, theme) {
986
+ return safeToolHeader(theme, "canon_remove", () => {
987
+ const parts: HeaderPart[] = [["accent", ` ${argText(args, "id") ?? "id?"}`]];
988
+ const reason = argText(args, "reason");
989
+ if (reason) parts.push(["muted", " — "], ["dim", clip(reason)]);
990
+ return parts;
991
+ });
992
+ },
993
+ async execute(
994
+ _toolCallId,
995
+ params: { id: string; reason?: string },
996
+ _signal,
997
+ _onUpdate,
998
+ ctx,
999
+ ) {
1000
+ if (
1001
+ params.reason !== undefined &&
1002
+ (typeof params.reason !== "string" || params.reason.length > REASON_MAX)
1003
+ ) {
1004
+ return {
1005
+ content: [
1006
+ {
1007
+ type: "text",
1008
+ text: `Reason too long: max ${REASON_MAX} chars.`,
1009
+ },
1010
+ ],
1011
+ details: { ok: false },
1012
+ };
1013
+ }
1014
+ const store = loadStore();
1015
+ const idx = store.entries.findIndex((e) => e.id === params.id);
1016
+ if (idx === -1) {
1017
+ return {
1018
+ content: [
1019
+ {
1020
+ type: "text",
1021
+ text: `No canon line with id "${params.id}". Use /canon-dump to see current ids.`,
1022
+ },
1023
+ ],
1024
+ details: { ok: false },
1025
+ };
1026
+ }
1027
+ const [removed] = store.entries.splice(idx, 1);
1028
+ const reason = params.reason?.trim() || undefined;
1029
+ saveStore(store);
1030
+ mySessionId = ctx.sessionManager?.getSessionId() ?? mySessionId;
1031
+ publishNotice({
1032
+ op: "remove",
1033
+ scope: { model: removed.model, audience: removed.audience },
1034
+ id: removed.id,
1035
+ text: removed.text,
1036
+ ...(reason ? { reason } : {}),
1037
+ });
1038
+ return {
1039
+ content: [
1040
+ {
1041
+ type: "text",
1042
+ text: `Removed canon [${removed.id}] (${removed.model} / ${removed.audience}): ${removed.text}`,
1043
+ },
1044
+ ],
1045
+ details: { ok: true, id: removed.id },
1046
+ };
1047
+ },
1048
+ });
1049
+
1050
+ const canonEditTool = defineTool({
1051
+ name: "canon_edit",
1052
+ label: "Edit canon line",
1053
+ description:
1054
+ "Replace the text, category, or scope of an existing canon line by its handle id — the id is kept. Peers are notified. The category may be CHANGED but never CLEARED: every edit has to leave the entry filed under a category that exists in the store. An empty category, or an omitted one on an entry that is uncategorised or carries a category the store no longer holds, is REFUSED with the list of valid ids. Omitting category on an entry that already has one keeps it — that is the way to edit text only. When re-scoping, set BOTH model AND audience explicitly — an over-broad scope pushes the rule into sessions where it does not apply (bloat/confusion); see canon_add.",
1055
+ promptSnippet: "Edit a canon line (text, category, scope) by id",
1056
+ parameters: Type.Object({
1057
+ id: Type.String({
1058
+ description: "Handle id of the canon line to edit (see /canon-dump)",
1059
+ }),
1060
+ text: Type.String({ description: "New line text" }),
1061
+ model: Type.Optional(
1062
+ Type.String({
1063
+ description:
1064
+ 'New scope model: "global" or a bare model id, e.g. "glm-5.3-flash". Omit to keep unchanged. Scope follows what the entry IS, never coverage: a quirk stays on its model, a general rule belongs on "global".',
1065
+ }),
1066
+ ),
1067
+ audience: Type.Optional(
1068
+ Type.String({
1069
+ description:
1070
+ 'New audience: "parent", "foreman", "subagent", or "all". Omit to keep unchanged.',
1071
+ }),
1072
+ ),
1073
+ category: Type.Optional(
1074
+ Type.String({
1075
+ description:
1076
+ "Category to re-file this entry under — an id (or exact title) that exists. Omit to keep the entry's current category; omitting is NOT a clear, but it is refused when the entry has no valid category to keep. An empty string (a clear) is always refused with the list of valid ids.",
1077
+ }),
1078
+ ),
1079
+ reason: Type.Optional(
1080
+ Type.String({
1081
+ description:
1082
+ "Optional — omit it by default. Replaces the stored reason; an empty string clears it. Max 120 chars: a longer reason fails the whole call. Shown in /canon-dump and peer notices.",
1083
+ }),
1084
+ ),
1085
+ }),
1086
+ renderCall(args, theme) {
1087
+ return safeToolHeader(theme, "canon_edit", () => {
1088
+ const parts: HeaderPart[] = [["accent", ` ${argText(args, "id") ?? "id?"}`]];
1089
+ const text = argText(args, "text");
1090
+ if (text) parts.push(["muted", " — "], ["dim", clip(text)]);
1091
+ return parts;
1092
+ });
1093
+ },
1094
+ async execute(
1095
+ _toolCallId,
1096
+ params: {
1097
+ id: string;
1098
+ text: string;
1099
+ model?: string;
1100
+ audience?: Audience;
1101
+ category?: string;
1102
+ reason?: string;
1103
+ },
1104
+ _signal,
1105
+ _onUpdate,
1106
+ ctx,
1107
+ ) {
1108
+ const store = loadStore();
1109
+ const entry = store.entries.find((e) => e.id === params.id);
1110
+ if (!entry) {
1111
+ return {
1112
+ content: [
1113
+ {
1114
+ type: "text",
1115
+ text: `No canon line with id "${params.id}". Use /canon-dump to see current ids.`,
1116
+ },
1117
+ ],
1118
+ details: { ok: false },
1119
+ };
1120
+ }
1121
+ const old = entry.text;
1122
+ // The category fence: an edit may CHANGE the category, never remove it, and
1123
+ // the entry must end the call filed under an id the store holds. The three
1124
+ // cases share one refusal shape so the caller always learns the valid ids:
1125
+ // - omitted on an entry that has a valid category -> kept, unchanged;
1126
+ // - omitted on an uncategorised (or dangling) entry -> refused, because
1127
+ // that call is the one that would leave the entry uncategorised;
1128
+ // - empty, unknown or ambiguous value -> refused.
1129
+ // Refusal comes first, before any other field is touched.
1130
+ if (params.category === undefined) {
1131
+ const current = entry.category;
1132
+ const kept = current ? lookupCategory(store, current) : null;
1133
+ if (!kept?.ok) {
1134
+ return {
1135
+ content: [
1136
+ {
1137
+ type: "text",
1138
+ text: categoryRefusal(
1139
+ store,
1140
+ "canon_edit",
1141
+ current
1142
+ ? `entry [${entry.id}] carries category "${current}", which the store does not hold`
1143
+ : `entry [${entry.id}] is uncategorised and this edit would leave it so`,
1144
+ ),
1145
+ },
1146
+ ],
1147
+ details: { ok: false },
1148
+ };
1149
+ }
1150
+ } else {
1151
+ const next = lookupCategory(store, params.category);
1152
+ if (!next.ok) {
1153
+ return {
1154
+ content: [
1155
+ {
1156
+ type: "text",
1157
+ text: categoryRefusal(
1158
+ store,
1159
+ "canon_edit",
1160
+ params.category === ""
1161
+ ? "a category cannot be cleared"
1162
+ : "unknown category",
1163
+ next.detail,
1164
+ ),
1165
+ },
1166
+ ],
1167
+ details: { ok: false },
1168
+ };
1169
+ }
1170
+ entry.category = next.id;
1171
+ }
1172
+ if (params.audience !== undefined) {
1173
+ if (!isAudience(params.audience)) {
1174
+ return {
1175
+ content: [
1176
+ {
1177
+ type: "text",
1178
+ text: `Invalid audience "${params.audience}": use one of ${AUDIENCE_ORDER.join(", ")}.`,
1179
+ },
1180
+ ],
1181
+ details: { ok: false },
1182
+ };
1183
+ }
1184
+ entry.audience = params.audience;
1185
+ }
1186
+ if (params.model !== undefined) {
1187
+ const model = normalizeModel(params.model);
1188
+ if (!modelExists(model, ctx)) {
1189
+ return {
1190
+ content: [
1191
+ {
1192
+ type: "text",
1193
+ text: `Unknown model "${model}". Omit model for global scope, or use a model id from the registry.`,
1194
+ },
1195
+ ],
1196
+ details: { ok: false },
1197
+ };
1198
+ }
1199
+ entry.model = model;
1200
+ }
1201
+ entry.text = params.text;
1202
+ if (params.reason !== undefined) {
1203
+ const r = params.reason.trim();
1204
+ if (r === "") delete entry.reason;
1205
+ else if (r.length > REASON_MAX) {
1206
+ return {
1207
+ content: [
1208
+ {
1209
+ type: "text",
1210
+ text: `Reason too long: max ${REASON_MAX} chars.`,
1211
+ },
1212
+ ],
1213
+ details: { ok: false },
1214
+ };
1215
+ } else entry.reason = r;
1216
+ }
1217
+ saveStore(store);
1218
+ mySessionId = ctx.sessionManager?.getSessionId() ?? mySessionId;
1219
+ publishNotice({
1220
+ op: "edit",
1221
+ scope: { model: entry.model, audience: entry.audience },
1222
+ id: entry.id,
1223
+ text: params.text,
1224
+ ...(entry.reason ? { reason: entry.reason } : {}),
1225
+ });
1226
+ return {
1227
+ content: [
1228
+ {
1229
+ type: "text",
1230
+ text: `Edited canon [${entry.id}] (${entry.model} / ${entry.audience}): "${old}" -> "${params.text}"`,
1231
+ },
1232
+ ],
1233
+ details: {
1234
+ ok: true,
1235
+ id: entry.id,
1236
+ scope: { model: entry.model, audience: entry.audience },
1237
+ },
1238
+ };
1239
+ },
1240
+ });
1241
+
1242
+ const canonCategoryTool = defineTool({
1243
+ name: "canon_category",
1244
+ label: "Manage canon categories",
1245
+ description:
1246
+ "Manage canon categories: op add (title, description?) creates one; op edit (id, title?, description?) updates it; op remove (id) deletes it and detaches its entries to Uncategorized; op list shows all. Categories are un-ordered and render as sub-headings inside scope groups in store insertion order.",
1247
+ promptSnippet: "Manage canon categories (add/edit/remove/list)",
1248
+ parameters: Type.Object({
1249
+ op: Type.String({ description: "add | edit | remove | list" }),
1250
+ title: Type.Optional(
1251
+ Type.String({ description: "Category title (add/edit)" }),
1252
+ ),
1253
+ description: Type.Optional(
1254
+ Type.String({
1255
+ description:
1256
+ "Category description (add/edit); empty string clears. Shown in /canon-dump only.",
1257
+ }),
1258
+ ),
1259
+ id: Type.Optional(Type.String({ description: "Category id (edit/remove)" })),
1260
+ }),
1261
+ renderCall(args, theme) {
1262
+ return safeToolHeader(theme, "canon_category", () => {
1263
+ const parts: HeaderPart[] = [["accent", ` ${argText(args, "op") ?? "op?"}`]];
1264
+ const target = argText(args, "title") ?? argText(args, "id");
1265
+ if (target) parts.push(["dim", ` ${clip(target, 60)}`]);
1266
+ return parts;
1267
+ });
1268
+ },
1269
+ async execute(
1270
+ _toolCallId,
1271
+ params: {
1272
+ op: string;
1273
+ title?: string;
1274
+ description?: string;
1275
+ id?: string;
1276
+ },
1277
+ _signal,
1278
+ _onUpdate,
1279
+ ctx,
1280
+ ) {
1281
+ const store = loadStore();
1282
+ if (params.op === "list") {
1283
+ return {
1284
+ content: [
1285
+ {
1286
+ type: "text",
1287
+ text: store.categories.length
1288
+ ? store.categories
1289
+ .map(
1290
+ (c) =>
1291
+ `[${c.id}] ${c.title}${c.description ? ` — ${c.description}` : ""}`,
1292
+ )
1293
+ .join("\n")
1294
+ : "(no categories)",
1295
+ },
1296
+ ],
1297
+ details: { ok: true, count: store.categories.length },
1298
+ };
1299
+ }
1300
+ if (params.op === "add") {
1301
+ if (!params.title?.trim()) {
1302
+ return {
1303
+ content: [{ type: "text", text: "Title required for category add." }],
1304
+ details: { ok: false },
1305
+ };
1306
+ }
1307
+ const id = genId(store);
1308
+ const title = params.title.trim();
1309
+ const description = params.description?.trim() || undefined;
1310
+ store.categories.push({
1311
+ id,
1312
+ title,
1313
+ ...(description ? { description } : {}),
1314
+ });
1315
+ saveStore(store);
1316
+ mySessionId = ctx.sessionManager?.getSessionId() ?? mySessionId;
1317
+ publishNotice({
1318
+ op: "category_add",
1319
+ scope: { model: "global", audience: "all" },
1320
+ id,
1321
+ text: title,
1322
+ ...(description ? { reason: description } : {}),
1323
+ });
1324
+ return {
1325
+ content: [
1326
+ {
1327
+ type: "text",
1328
+ text: `Added category [${id}] ${title}`,
1329
+ },
1330
+ ],
1331
+ details: { ok: true, id, title },
1332
+ };
1333
+ }
1334
+ if (params.op === "edit") {
1335
+ const cat = store.categories.find((c) => c.id === params.id);
1336
+ if (!cat) {
1337
+ return {
1338
+ content: [
1339
+ {
1340
+ type: "text",
1341
+ text: `No category with id "${params.id ?? ""}".`,
1342
+ },
1343
+ ],
1344
+ details: { ok: false },
1345
+ };
1346
+ }
1347
+ if (params.title !== undefined && params.title.trim())
1348
+ cat.title = params.title.trim();
1349
+ if (params.description !== undefined) {
1350
+ const d = params.description.trim();
1351
+ if (d === "") delete cat.description;
1352
+ else cat.description = d;
1353
+ }
1354
+ saveStore(store);
1355
+ mySessionId = ctx.sessionManager?.getSessionId() ?? mySessionId;
1356
+ publishNotice({
1357
+ op: "category_edit",
1358
+ scope: { model: "global", audience: "all" },
1359
+ id: cat.id,
1360
+ text: cat.title,
1361
+ ...(cat.description ? { reason: cat.description } : {}),
1362
+ });
1363
+ return {
1364
+ content: [
1365
+ {
1366
+ type: "text",
1367
+ text: `Edited category [${cat.id}] ${cat.title}`,
1368
+ },
1369
+ ],
1370
+ details: { ok: true, id: cat.id },
1371
+ };
1372
+ }
1373
+ if (params.op === "remove") {
1374
+ const idx = store.categories.findIndex((c) => c.id === params.id);
1375
+ if (idx === -1) {
1376
+ return {
1377
+ content: [
1378
+ {
1379
+ type: "text",
1380
+ text: `No category with id "${params.id ?? ""}".`,
1381
+ },
1382
+ ],
1383
+ details: { ok: false },
1384
+ };
1385
+ }
1386
+ const [removed] = store.categories.splice(idx, 1);
1387
+ let detached = 0;
1388
+ for (const e of store.entries) {
1389
+ if (e.category === removed.id) {
1390
+ delete e.category;
1391
+ detached++;
1392
+ }
1393
+ }
1394
+ saveStore(store);
1395
+ mySessionId = ctx.sessionManager?.getSessionId() ?? mySessionId;
1396
+ publishNotice({
1397
+ op: "category_remove",
1398
+ scope: { model: "global", audience: "all" },
1399
+ id: removed.id,
1400
+ text: removed.title,
1401
+ ...(detached
1402
+ ? { reason: `${detached} entries detached to Uncategorized` }
1403
+ : {}),
1404
+ });
1405
+ return {
1406
+ content: [
1407
+ {
1408
+ type: "text",
1409
+ text: `Removed category [${removed.id}] ${removed.title}${detached ? ` (${detached} entries detached to Uncategorized)` : ""}`,
1410
+ },
1411
+ ],
1412
+ details: { ok: true, id: removed.id, detached },
1413
+ };
1414
+ }
1415
+ return {
1416
+ content: [
1417
+ {
1418
+ type: "text",
1419
+ text: "Invalid op: use add, edit, remove, or list.",
1420
+ },
1421
+ ],
1422
+ details: { ok: false },
1423
+ };
1424
+ },
1425
+ });
1426
+
1427
+ pi.registerTool(canonAddTool);
1428
+ pi.registerTool(canonRemoveTool);
1429
+ pi.registerTool(canonEditTool);
1430
+ pi.registerTool(canonCategoryTool);
1431
+
1432
+ // ---------- /canon-dump command ----------
1433
+
1434
+ pi.registerCommand("canon-dump", {
1435
+ description:
1436
+ "Dump the canon store into this conversation. Optional filter: all | parent | foreman | subagent (audience) or a model id. No filter = full store.",
1437
+ handler: async (args, _ctx) => {
1438
+ const store = loadStore();
1439
+ const filter = (args ?? "").trim().toLowerCase();
1440
+ let entries = store.entries;
1441
+ if (isAudience(filter)) {
1442
+ entries = entries.filter((e) => e.audience === filter);
1443
+ } else if (filter) {
1444
+ const m = normalizeModel(filter);
1445
+ entries = entries.filter((e) => e.model === m || e.model === filter);
1446
+ }
1447
+ const body = entries.length
1448
+ ? renderDump(entries, store.categories)
1449
+ : "(empty)";
1450
+ // The drift count, so a store that has lost its categorisation is visible
1451
+ // here as well as in the hook log — reachable without reading the store.
1452
+ const drift = categoryDrift(store);
1453
+ pi.sendMessage(
1454
+ {
1455
+ customType: "canon_dump",
1456
+ content: `## Canon dump${filter ? ` (${filter})` : ""} — ${entries.length}/${store.entries.length} entries · ${drift.uncategorized.length} uncategorised · ${drift.dangling.length} dangling\n\n${body}`,
1457
+ display: true,
1458
+ details: {
1459
+ count: entries.length,
1460
+ filter: filter || undefined,
1461
+ uncategorized: drift.uncategorized.length,
1462
+ dangling: drift.dangling.length,
1463
+ },
1464
+ },
1465
+ { deliverAs: "steer" },
1466
+ );
1467
+ },
1468
+ });
1469
+
1470
+ // ---------- /canon command ----------
1471
+
1472
+ pi.registerCommand("canon", {
1473
+ description:
1474
+ "Manage canon lines: list | add [--model M] [--audience A] <text> [--reason <why>] | remove <id> [--reason <why>] | edit <id> <text> [--reason <why>] | category list|add|edit|remove",
1475
+ handler: async (args, ctx) => {
1476
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
1477
+ const verb = tokens[0] ?? "list";
1478
+ try {
1479
+ if (verb === "list") {
1480
+ const store = loadStore();
1481
+ const model = currentModel();
1482
+ const audienceLabel = sessionLabel(audiences);
1483
+ const body = store.entries.length
1484
+ ? renderDump(store.entries, store.categories)
1485
+ : "(empty)";
1486
+ ctx.ui.notify(
1487
+ `Canon — active model: ${model} (${audienceLabel})\n\n${body}`,
1488
+ "info",
1489
+ );
1490
+ return;
1491
+ }
1492
+ const store = loadStore();
1493
+ if (verb === "category") {
1494
+ const sub = tokens[1];
1495
+ if (sub === "list") {
1496
+ ctx.ui.notify(
1497
+ store.categories.length
1498
+ ? store.categories
1499
+ .map(
1500
+ (c) =>
1501
+ `[${c.id}] ${c.title}${c.description ? ` — ${c.description}` : ""}`,
1502
+ )
1503
+ .join("\n")
1504
+ : "(no categories)",
1505
+ "info",
1506
+ );
1507
+ return;
1508
+ }
1509
+ if (sub === "add") {
1510
+ const di = tokens.indexOf("--desc");
1511
+ const title = tokens
1512
+ .slice(2, di === -1 ? undefined : di)
1513
+ .join(" ")
1514
+ .trim();
1515
+ const description =
1516
+ di === -1 ? undefined : tokens.slice(di + 1).join(" ");
1517
+ if (!title) {
1518
+ ctx.ui.notify(
1519
+ "Usage: /canon category add <title> [--desc <description>]",
1520
+ "error",
1521
+ );
1522
+ return;
1523
+ }
1524
+ if (description && description.length > REASON_MAX) {
1525
+ ctx.ui.notify(`Description too long: max ${REASON_MAX} chars`, "error");
1526
+ return;
1527
+ }
1528
+ const id = genId(store);
1529
+ store.categories.push({
1530
+ id,
1531
+ title,
1532
+ ...(description ? { description } : {}),
1533
+ });
1534
+ saveStore(store);
1535
+ publishNotice({
1536
+ op: "category_add",
1537
+ scope: { model: "global", audience: "all" },
1538
+ id,
1539
+ text: title,
1540
+ ...(description ? { reason: description } : {}),
1541
+ });
1542
+ ctx.ui.notify(`Added category [${id}] ${title}`, "info");
1543
+ return;
1544
+ }
1545
+ if (sub === "edit") {
1546
+ const id = tokens[2];
1547
+ const cat = store.categories.find((c) => c.id === id);
1548
+ if (!cat) {
1549
+ ctx.ui.notify(`No category with id "${id ?? ""}"`, "error");
1550
+ return;
1551
+ }
1552
+ const di = tokens.indexOf("--desc");
1553
+ const newTitle = tokens
1554
+ .slice(3, di === -1 ? undefined : di)
1555
+ .join(" ")
1556
+ .trim();
1557
+ if (newTitle) cat.title = newTitle;
1558
+ if (di !== -1) {
1559
+ const description = tokens.slice(di + 1).join(" ");
1560
+ if (description.length > REASON_MAX) {
1561
+ ctx.ui.notify(`Description too long: max ${REASON_MAX} chars`, "error");
1562
+ return;
1563
+ }
1564
+ if (description === "") delete cat.description;
1565
+ else cat.description = description;
1566
+ }
1567
+ saveStore(store);
1568
+ publishNotice({
1569
+ op: "category_edit",
1570
+ scope: { model: "global", audience: "all" },
1571
+ id: cat.id,
1572
+ text: cat.title,
1573
+ ...(cat.description ? { reason: cat.description } : {}),
1574
+ });
1575
+ ctx.ui.notify(`Edited category [${cat.id}] ${cat.title}`, "info");
1576
+ return;
1577
+ }
1578
+ if (sub === "remove" || sub === "rm") {
1579
+ const id = tokens[2];
1580
+ const idx = store.categories.findIndex((c) => c.id === id);
1581
+ if (!id || idx === -1) {
1582
+ ctx.ui.notify(`No category with id "${id ?? ""}"`, "error");
1583
+ return;
1584
+ }
1585
+ const [removed] = store.categories.splice(idx, 1);
1586
+ let detached = 0;
1587
+ for (const e of store.entries) {
1588
+ if (e.category === removed.id) {
1589
+ delete e.category;
1590
+ detached++;
1591
+ }
1592
+ }
1593
+ saveStore(store);
1594
+ publishNotice({
1595
+ op: "category_remove",
1596
+ scope: { model: "global", audience: "all" },
1597
+ id: removed.id,
1598
+ text: removed.title,
1599
+ ...(detached
1600
+ ? { reason: `${detached} entries detached to Uncategorized` }
1601
+ : {}),
1602
+ });
1603
+ ctx.ui.notify(
1604
+ `Removed category [${removed.id}]${detached ? ` (${detached} detached)` : ""}`,
1605
+ "info",
1606
+ );
1607
+ return;
1608
+ }
1609
+ ctx.ui.notify(
1610
+ "Usage: /canon category list | add <title> [--desc <d>] | edit <id> [<title>] [--desc <d>] | remove <id>",
1611
+ "error",
1612
+ );
1613
+ return;
1614
+ }
1615
+ if (verb === "add") {
1616
+ let model = "global";
1617
+ let audience: Audience = "all";
1618
+ let reason: string | undefined;
1619
+ const rest: string[] = [];
1620
+ for (let i = 1; i < tokens.length; i++) {
1621
+ if (tokens[i] === "--model" && tokens[i + 1])
1622
+ model = normalizeModel(tokens[++i]);
1623
+ else if (tokens[i] === "--audience" && tokens[i + 1]) {
1624
+ const a = tokens[++i];
1625
+ if (isAudience(a)) audience = a;
1626
+ else {
1627
+ ctx.ui.notify(
1628
+ `Invalid audience "${a}": use one of ${AUDIENCE_ORDER.join(", ")}`,
1629
+ "error",
1630
+ );
1631
+ return;
1632
+ }
1633
+ } else if (tokens[i] === "--reason") {
1634
+ reason = tokens.slice(i + 1).join(" ");
1635
+ break;
1636
+ } else rest.push(tokens[i]);
1637
+ }
1638
+ const text = rest.join(" ");
1639
+ if (!text) {
1640
+ ctx.ui.notify(
1641
+ "Usage: /canon add [--model M] [--audience A] <text> [--reason <why>]",
1642
+ "error",
1643
+ );
1644
+ return;
1645
+ }
1646
+ if (reason && reason.length > REASON_MAX) {
1647
+ ctx.ui.notify(`Reason too long: max ${REASON_MAX} chars`, "error");
1648
+ return;
1649
+ }
1650
+ const id = genId(store);
1651
+ store.entries.push({
1652
+ id,
1653
+ text,
1654
+ model,
1655
+ audience,
1656
+ ...(reason ? { reason } : {}),
1657
+ });
1658
+ saveStore(store);
1659
+ publishNotice({
1660
+ op: "add",
1661
+ scope: { model, audience },
1662
+ id,
1663
+ text,
1664
+ ...(reason ? { reason } : {}),
1665
+ });
1666
+ ctx.ui.notify(
1667
+ `Added canon [${id}] (${model} / ${audience})${reason ? ` — ${reason}` : ""}`,
1668
+ "info",
1669
+ );
1670
+ return;
1671
+ }
1672
+ if (verb === "remove" || verb === "rm") {
1673
+ const id = tokens[1];
1674
+ const reason =
1675
+ tokens
1676
+ .slice(2)
1677
+ .join(" ")
1678
+ .replace(/^--reason\s*/, "") || undefined;
1679
+ if (reason && reason.length > REASON_MAX) {
1680
+ ctx.ui.notify(`Reason too long: max ${REASON_MAX} chars`, "error");
1681
+ return;
1682
+ }
1683
+ const idx = store.entries.findIndex((e) => e.id === id);
1684
+ if (!id || idx === -1) {
1685
+ ctx.ui.notify(`No canon line with id "${id ?? ""}"`, "error");
1686
+ return;
1687
+ }
1688
+ const [removed] = store.entries.splice(idx, 1);
1689
+ saveStore(store);
1690
+ publishNotice({
1691
+ op: "remove",
1692
+ scope: { model: removed.model, audience: removed.audience },
1693
+ id: removed.id,
1694
+ text: removed.text,
1695
+ ...(reason ? { reason } : {}),
1696
+ });
1697
+ ctx.ui.notify(`Removed canon [${removed.id}]`, "info");
1698
+ return;
1699
+ }
1700
+ if (verb === "edit") {
1701
+ const id = tokens[1];
1702
+ let text: string;
1703
+ let reason: string | undefined;
1704
+ const ri = tokens.indexOf("--reason");
1705
+ if (ri === -1) {
1706
+ text = tokens.slice(2).join(" ");
1707
+ } else {
1708
+ text = tokens.slice(2, ri).join(" ");
1709
+ reason = tokens.slice(ri + 1).join(" ");
1710
+ }
1711
+ const entry = store.entries.find((e) => e.id === id);
1712
+ if (!id || !entry || !text) {
1713
+ ctx.ui.notify(
1714
+ "Usage: /canon edit <id> <new text> [--reason <why>]",
1715
+ "error",
1716
+ );
1717
+ return;
1718
+ }
1719
+ entry.text = text;
1720
+ if (reason !== undefined) {
1721
+ if (reason.length > REASON_MAX) {
1722
+ ctx.ui.notify(`Reason too long: max ${REASON_MAX} chars`, "error");
1723
+ return;
1724
+ }
1725
+ if (reason === "") delete entry.reason;
1726
+ else entry.reason = reason;
1727
+ }
1728
+ saveStore(store);
1729
+ publishNotice({
1730
+ op: "edit",
1731
+ scope: { model: entry.model, audience: entry.audience },
1732
+ id: entry.id,
1733
+ text,
1734
+ ...(entry.reason ? { reason: entry.reason } : {}),
1735
+ });
1736
+ ctx.ui.notify(`Edited canon [${entry.id}]`, "info");
1737
+ return;
1738
+ }
1739
+ ctx.ui.notify(
1740
+ "Usage: /canon list | add [--model M] [--audience A] <text> [--reason <why>] | remove <id> [--reason <why>] | edit <id> <text> [--reason <why>]",
1741
+ "error",
1742
+ );
1743
+ } catch (err) {
1744
+ ctx.ui.notify(
1745
+ `canon: ${err instanceof Error ? err.message : String(err)}`,
1746
+ "error",
1747
+ );
1748
+ }
1749
+ },
1750
+ });
1751
+ }