@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.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/canon.ts +1751 -0
- 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
|
+
}
|