pi-canon 0.2.4 → 0.3.1
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/README.md +124 -62
- package/extensions/canon.ts +18 -3
- package/extensions/index.js +9 -3
- package/extensions/lib/lint.ts +47 -5
- package/extensions/lib/schema.ts +251 -0
- package/extensions/lib/store.ts +13 -3
- package/extensions/lib/surfacing.ts +62 -5
- package/extensions/lib/tool.ts +149 -28
- package/extensions/settings.ts +328 -0
- package/package.json +6 -5
package/extensions/lib/tool.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
/* The
|
|
1
|
+
/* The canon tool: one tool, five verbs. Read and update over create; the journal
|
|
2
2
|
for events; map to orient; search when the agent asks. */
|
|
3
3
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { basename, join } from "node:path";
|
|
6
|
-
import { advise, unretained } from "./lint.ts";
|
|
6
|
+
import { advise, orphaned, unretained } from "./lint.ts";
|
|
7
|
+
import { checkArticle, loadSchema, outgoingOf, READ_ONLY, SCHEMA_FILE } from "./schema.ts";
|
|
7
8
|
import { contained, normalize, type CanonStore } from "./store.ts";
|
|
8
9
|
import { type Candidate, LexicalRetriever, RULE_SCOPE } from "./retrieval.ts";
|
|
9
10
|
import type { Mount, Surfacer } from "./surfacing.ts";
|
|
@@ -33,6 +34,13 @@ export const CANON_TOOL_PARAMETERS = {
|
|
|
33
34
|
},
|
|
34
35
|
capsule: { type: "string", description: "write: one dense line injected when the asset is touched." },
|
|
35
36
|
query: { type: "string", description: "search: words to look for, across articles and the journal." },
|
|
37
|
+
journal: {
|
|
38
|
+
type: "boolean",
|
|
39
|
+
description:
|
|
40
|
+
"search: true to include journal entries in the results. Off by default because " +
|
|
41
|
+
"events are history, not current truth; the result names how many entries matched " +
|
|
42
|
+
"so you can opt in when the history is the point.",
|
|
43
|
+
},
|
|
36
44
|
scope: {
|
|
37
45
|
type: "string",
|
|
38
46
|
enum: ["rule", "asset"],
|
|
@@ -99,8 +107,8 @@ function settle(mount: Mount, raw: string, cwd: string): string {
|
|
|
99
107
|
|
|
100
108
|
export function buildCanonTool(ready: (ctx: unknown) => CanonRuntime, retrieval = "none") {
|
|
101
109
|
return {
|
|
102
|
-
name: "
|
|
103
|
-
label: "
|
|
110
|
+
name: "canon",
|
|
111
|
+
label: "canon",
|
|
104
112
|
description: canonToolDescription(retrieval),
|
|
105
113
|
parameters: CANON_TOOL_PARAMETERS,
|
|
106
114
|
async execute(
|
|
@@ -156,10 +164,19 @@ function filingTail(retrieval: string): string {
|
|
|
156
164
|
named. Neither is decoration.
|
|
157
165
|
|
|
158
166
|
Ranking reuses LexicalRetriever rather than growing a second notion of relevance, so search
|
|
159
|
-
and recommendation cannot drift apart.
|
|
167
|
+
and recommendation cannot drift apart.
|
|
168
|
+
|
|
169
|
+
The journal is opt-in (Shane, 2026-08-20): events are history, not current truth,
|
|
170
|
+
and R1 measured what including them by default cost, journal entries about an
|
|
171
|
+
event crowding out the article that carries its current truth for 8 to 20 points
|
|
172
|
+
of governing-article recall at realistic query lengths. Default search ranks
|
|
173
|
+
articles alone, never reads a journal body, and says the journal exists; with
|
|
174
|
+
journal true the window is split, articles up to half, journal the rest,
|
|
175
|
+
whichever side runs short ceding its slots. Current truth first, always. */
|
|
160
176
|
const SEARCH_RESULTS = 10;
|
|
177
|
+
const ARTICLE_SLOTS = 5;
|
|
161
178
|
|
|
162
|
-
function search(store: CanonStore, query: string): string {
|
|
179
|
+
function search(store: CanonStore, query: string, includeJournal: boolean): string {
|
|
163
180
|
if (!query.trim()) return "search needs a query.";
|
|
164
181
|
const articles: Candidate[] = [];
|
|
165
182
|
for (const path of store.list()) {
|
|
@@ -175,20 +192,26 @@ function search(store: CanonStore, query: string): string {
|
|
|
175
192
|
}
|
|
176
193
|
}
|
|
177
194
|
/* Journal entries enter the same index under a `journal/` key so one ranking covers both.
|
|
178
|
-
The key is an index handle, never an address: it is not something `read` accepts.
|
|
179
|
-
|
|
195
|
+
The key is an index handle, never an address: it is not something `read` accepts.
|
|
196
|
+
Built only on opt-in, so a default search never pays for reading every entry body. */
|
|
180
197
|
const byKey = new Map<string, { logged: string; subjects: string[]; body: string }>();
|
|
181
|
-
const journal: Candidate[] =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
const journal: Candidate[] = !includeJournal
|
|
199
|
+
? []
|
|
200
|
+
: store.journalEntries().map((entry) => {
|
|
201
|
+
const key = `journal/${entry.name.replace(/\.md$/, "")}`;
|
|
202
|
+
byKey.set(key, entry);
|
|
203
|
+
return {
|
|
204
|
+
path: key,
|
|
205
|
+
capsule: entry.subjects.join(", "),
|
|
206
|
+
body: entry.body,
|
|
207
|
+
updated: entry.logged,
|
|
208
|
+
declared: false,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
const invitation =
|
|
212
|
+
!includeJournal && store.journalCount() > 0
|
|
213
|
+
? "The journal was not searched; pass journal true to search events too."
|
|
214
|
+
: "";
|
|
192
215
|
|
|
193
216
|
const all = [...articles, ...journal];
|
|
194
217
|
if (!all.length) return "Nothing in the canon yet.";
|
|
@@ -196,9 +219,18 @@ function search(store: CanonStore, query: string): string {
|
|
|
196
219
|
retriever.index(all);
|
|
197
220
|
const scored = retriever.score(query, all);
|
|
198
221
|
const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
|
|
199
|
-
if (!ranked.length) return `Nothing matches "${query}"
|
|
222
|
+
if (!ranked.length) return [`Nothing matches "${query}".`, invitation].filter(Boolean).join(" ");
|
|
223
|
+
|
|
224
|
+
const articleRanked = ranked.filter(([key]) => !byKey.has(key));
|
|
225
|
+
const journalRanked = ranked.filter(([key]) => byKey.has(key));
|
|
226
|
+
const articleQuota = Math.min(
|
|
227
|
+
articleRanked.length,
|
|
228
|
+
Math.max(ARTICLE_SLOTS, SEARCH_RESULTS - journalRanked.length),
|
|
229
|
+
);
|
|
230
|
+
const journalQuota = Math.min(journalRanked.length, SEARCH_RESULTS - articleQuota);
|
|
231
|
+
const chosen = [...articleRanked.slice(0, articleQuota), ...journalRanked.slice(0, journalQuota)];
|
|
200
232
|
|
|
201
|
-
const lines =
|
|
233
|
+
const lines = chosen.map(([key]) => {
|
|
202
234
|
const entry = byKey.get(key);
|
|
203
235
|
if (entry) {
|
|
204
236
|
const subjects = entry.subjects.length ? ` (${entry.subjects.join(", ")})` : "";
|
|
@@ -208,9 +240,10 @@ function search(store: CanonStore, query: string): string {
|
|
|
208
240
|
return `${key}: ${article?.capsule || excerpt(article?.body ?? "")}`;
|
|
209
241
|
});
|
|
210
242
|
/* Say what was dropped. A silent cap reads as "that is everything". */
|
|
211
|
-
if (ranked.length >
|
|
212
|
-
lines.push(`... ${ranked.length -
|
|
243
|
+
if (ranked.length > chosen.length) {
|
|
244
|
+
lines.push(`... ${ranked.length - chosen.length} more matched; narrow the query to see them.`);
|
|
213
245
|
}
|
|
246
|
+
if (invitation) lines.push(invitation);
|
|
214
247
|
return lines.join("\n");
|
|
215
248
|
}
|
|
216
249
|
|
|
@@ -253,7 +286,18 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
253
286
|
const index = recent.length
|
|
254
287
|
? `\n\njournal: ${recent.join(", ")}${earlier ? ` and ${earlier} earlier` : ""}`
|
|
255
288
|
: "";
|
|
256
|
-
|
|
289
|
+
/* Reads never reject, but they do report: an agent holding a noncompliant
|
|
290
|
+
article is the one agent positioned to heal it, and silence here is how a
|
|
291
|
+
store drifts out of its own contract one read at a time. */
|
|
292
|
+
const { schema, problems } = loadSchema(store.root);
|
|
293
|
+
const standing = schema ? checkArticle(article, schema, READ_ONLY) : { rejections: [], warnings: [] };
|
|
294
|
+
const issues = [...standing.warnings, ...problems];
|
|
295
|
+
const report = issues.length
|
|
296
|
+
? `\n\nschema (${SCHEMA_FILE}): ${issues.join(" ")} This article can be healed with a write.`
|
|
297
|
+
: "";
|
|
298
|
+
const missing = orphaned(mount.dir, article);
|
|
299
|
+
const orphan = missing ? `\n\n${missing}` : "";
|
|
300
|
+
return `${title}\n${head}\n\n${article.body}`.trim() + index + report + orphan;
|
|
257
301
|
}
|
|
258
302
|
case "write": {
|
|
259
303
|
if (!path) return "write needs a path.";
|
|
@@ -266,7 +310,7 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
266
310
|
prior state; which fields this call happened to set is a separate question and is
|
|
267
311
|
answered separately below. */
|
|
268
312
|
const prior = store.read(path);
|
|
269
|
-
const
|
|
313
|
+
const fields = {
|
|
270
314
|
capsule: params.capsule ? String(params.capsule) : undefined,
|
|
271
315
|
body: params.body ? String(params.body) : undefined,
|
|
272
316
|
/* "asset" is the way back. The enum is the only vocabulary the model has, so
|
|
@@ -274,16 +318,93 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
274
318
|
one: every other input falls through to undefined, which means untouched. It
|
|
275
319
|
stores empty, which is the default state, the address being the claim. */
|
|
276
320
|
scope: params.scope === "asset" ? "" : params.scope ? String(params.scope) : undefined,
|
|
277
|
-
}
|
|
321
|
+
};
|
|
322
|
+
/* The store's declared contract, held against the article this write WOULD
|
|
323
|
+
store, before anything touches disk. Only a required rule the write itself
|
|
324
|
+
touched (or a brand new article) rejects; everything else warns, so the write
|
|
325
|
+
still lands and the agent still learns. */
|
|
326
|
+
const { schema, problems } = loadSchema(store.root);
|
|
327
|
+
const composed = store.compose(path, fields);
|
|
328
|
+
const verdict = schema
|
|
329
|
+
? checkArticle(composed, schema, {
|
|
330
|
+
capsule: fields.capsule !== undefined,
|
|
331
|
+
body: fields.body !== undefined,
|
|
332
|
+
/* Touched means the reference SET changed, not that a body was sent: a
|
|
333
|
+
body edit that keeps its citations must not re-litigate them. */
|
|
334
|
+
refs: outgoingOf(prior ? prior.body : "").join("\n") !== outgoingOf(composed.body).join("\n"),
|
|
335
|
+
created: !prior,
|
|
336
|
+
})
|
|
337
|
+
: { rejections: [], warnings: [] };
|
|
338
|
+
if (verdict.rejections.length) {
|
|
339
|
+
return [
|
|
340
|
+
`Write rejected by this store's ${SCHEMA_FILE}:`,
|
|
341
|
+
...verdict.rejections.map((line) => `- ${line}`),
|
|
342
|
+
"Nothing was written. Fix the listed fields and write again.",
|
|
343
|
+
].join("\n");
|
|
344
|
+
}
|
|
345
|
+
/* A write that changes nothing is a restatement, not a change. Measured (W1j):
|
|
346
|
+
restating the current state through the write path was the one store
|
|
347
|
+
corruption no content rule could catch, because no field differs; the only
|
|
348
|
+
thing it changed was the freshness stamp, which then lied. So equality is
|
|
349
|
+
checked here, mechanically, and `updated` keeps meaning what it says. */
|
|
350
|
+
if (
|
|
351
|
+
prior &&
|
|
352
|
+
composed.capsule === prior.capsule &&
|
|
353
|
+
composed.scope === prior.scope &&
|
|
354
|
+
composed.body.trimEnd() === prior.body.trimEnd()
|
|
355
|
+
) {
|
|
356
|
+
surfacer.markUpdated(
|
|
357
|
+
qualify(composed.path),
|
|
358
|
+
[params.capsule, params.body].filter(Boolean).map(String).join("\n"),
|
|
359
|
+
);
|
|
360
|
+
return [
|
|
361
|
+
`${qualify(composed.path)} is already current: this write matches the stored article, ` +
|
|
362
|
+
`so nothing was rewritten and updated stays ${prior.updated}.`,
|
|
363
|
+
...verdict.warnings.map((line) => `schema: ${line}`),
|
|
364
|
+
...problems,
|
|
365
|
+
].join("\n");
|
|
366
|
+
}
|
|
367
|
+
const article = store.write(path, fields);
|
|
368
|
+
/* When a rewrite grows the body, the result says so and restates the split.
|
|
369
|
+
Measured over three captures, two arms each, on byte-identical
|
|
370
|
+
eight-session lineages: writers narrate history into articles until the
|
|
371
|
+
store outgrows the raw transcripts it distills, and prompt-side guidance
|
|
372
|
+
does not change the habit. The arm that got this line ended with fewer
|
|
373
|
+
standing superseded values in all three, 51 and 45 and 71 of 96 against
|
|
374
|
+
88 and 87 and 85. Take the DIRECTION and not the size. The third capture
|
|
375
|
+
counterbalanced the arm order and kept the direction while losing most of
|
|
376
|
+
the magnitude, and re-running a matched untreated cell moved its median
|
|
377
|
+
store 39 percent, so this instrument does not measure its own magnitudes
|
|
378
|
+
reliably. Two readers in 96 sessions were harmed by a stale value, one
|
|
379
|
+
from each arm, so this line is not known to protect readers. It is also
|
|
380
|
+
not one exposure: it fired after 117, 99, and 126 of that arm's 198, 184,
|
|
381
|
+
and 207 writes. Any growth fires; that exact behavior is what was
|
|
382
|
+
measured. Creation is not growth, and a capsule-only write never grows
|
|
383
|
+
the stored body. */
|
|
384
|
+
const priorBytes = prior ? Buffer.byteLength(prior.body.trimEnd()) : null;
|
|
385
|
+
const nextBytes = Buffer.byteLength(composed.body.trimEnd());
|
|
386
|
+
const growth =
|
|
387
|
+
priorBytes !== null && nextBytes > priorBytes
|
|
388
|
+
? [
|
|
389
|
+
`Body grew ${priorBytes} -> ${nextBytes} bytes. An article carries current ` +
|
|
390
|
+
`state; if this growth is narrated history (old values, transitions), move ` +
|
|
391
|
+
`it to the journal and keep the article at what is true now.`,
|
|
392
|
+
]
|
|
393
|
+
: [];
|
|
278
394
|
/* What this write put in the window, which is what the agent supplied, not the
|
|
279
395
|
merged article: a capsule-only write does not deliver the stored body. */
|
|
280
396
|
surfacer.markUpdated(
|
|
281
397
|
qualify(article.path),
|
|
282
398
|
[params.capsule, params.body].filter(Boolean).map(String).join("\n"),
|
|
283
399
|
);
|
|
400
|
+
const missingAsset = orphaned(mount.dir, article);
|
|
284
401
|
return [
|
|
285
402
|
`Wrote ${qualify(article.path)}.`,
|
|
286
|
-
...
|
|
403
|
+
...growth,
|
|
404
|
+
...verdict.warnings.map((line) => `schema: ${line}`),
|
|
405
|
+
...problems,
|
|
406
|
+
...advise(article, store, prior?.body, { dir: mount.dir, retrieval: runtime.retrieval }, schema),
|
|
407
|
+
...(missingAsset ? [missingAsset] : []),
|
|
287
408
|
].join("\n");
|
|
288
409
|
}
|
|
289
410
|
case "journal": {
|
|
@@ -330,7 +451,7 @@ export function runCanon(runtime: CanonRuntime, params: Record<string, unknown>)
|
|
|
330
451
|
case "map":
|
|
331
452
|
return store.map(path);
|
|
332
453
|
case "search":
|
|
333
|
-
return search(store, String(params.query ?? ""));
|
|
454
|
+
return search(store, String(params.query ?? ""), params.journal === true);
|
|
334
455
|
default:
|
|
335
456
|
return `Unknown action "${action}". Actions: read, write, journal, map, search.`;
|
|
336
457
|
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/* /canon-settings: user-facing configuration for the behavior options that are
|
|
2
|
+
otherwise code. The storage stays exactly what registerPiCanon accepts; this
|
|
3
|
+
module only changes the experience: one validation path, an in-TUI editor over
|
|
4
|
+
a JSON file, and every state that reaches disk already passed validation.
|
|
5
|
+
|
|
6
|
+
root and mounts deliberately have no row here: they are per-project topology,
|
|
7
|
+
and a global file overriding them would be wrong. The editor carries the four
|
|
8
|
+
behavior options a user might legitimately flip session to session.
|
|
9
|
+
|
|
10
|
+
The editor is built from pi-tui's own SettingsList and Input so it looks and
|
|
11
|
+
behaves like Pi's native /settings screen, and ALL key handling goes through
|
|
12
|
+
matchesKey: raw byte matching freezes on terminals in application cursor mode,
|
|
13
|
+
where arrows arrive as SS3 rather than CSI.
|
|
14
|
+
|
|
15
|
+
This file must stay loadable by PLAIN NODE (the entry-point gate imports it
|
|
16
|
+
without jiti), so it uses none of the TypeScript that requires a transform:
|
|
17
|
+
no parameter properties and no accessibility modifiers, only erasable
|
|
18
|
+
annotations. */
|
|
19
|
+
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { Container, Input, Key, matchesKey, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_CANON_SETTINGS_PATH = join(
|
|
26
|
+
homedir(),
|
|
27
|
+
".config",
|
|
28
|
+
"pi-canon",
|
|
29
|
+
"settings.json",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export interface CanonUserSettings {
|
|
33
|
+
surface?: boolean;
|
|
34
|
+
resurface?: boolean;
|
|
35
|
+
retrieval?: "none" | "lexical";
|
|
36
|
+
standout?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SETTING_IDS = ["surface", "resurface", "retrieval", "standout"] as const;
|
|
40
|
+
export type CanonSettingId = (typeof SETTING_IDS)[number];
|
|
41
|
+
|
|
42
|
+
const RETRIEVAL_CHOICES = ["none", "lexical"];
|
|
43
|
+
/* The standout lattice, stepped by left/right and clamped at the ends. 1 is no
|
|
44
|
+
cutoff; the default 1.4 is the operating point the 120-cell study priced. */
|
|
45
|
+
const STANDOUT_LATTICE = [1, 1.2, 1.4, 1.6, 1.8, 2, 2.5, 3];
|
|
46
|
+
|
|
47
|
+
export function applyCanonSettingsEdit(
|
|
48
|
+
draft: CanonUserSettings,
|
|
49
|
+
id: CanonSettingId,
|
|
50
|
+
rawValue: string,
|
|
51
|
+
): { ok: true; draft: CanonUserSettings } | { ok: false; error: string } {
|
|
52
|
+
const text = rawValue.trim();
|
|
53
|
+
if (id === "surface" || id === "resurface") {
|
|
54
|
+
if (text !== "on" && text !== "off") {
|
|
55
|
+
return { ok: false, error: `${id} must be on or off` };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, draft: { ...draft, [id]: text === "on" } };
|
|
58
|
+
}
|
|
59
|
+
if (id === "retrieval") {
|
|
60
|
+
if (!RETRIEVAL_CHOICES.includes(text)) {
|
|
61
|
+
return { ok: false, error: 'retrieval must be "none" or "lexical"' };
|
|
62
|
+
}
|
|
63
|
+
return { ok: true, draft: { ...draft, retrieval: text as "none" | "lexical" } };
|
|
64
|
+
}
|
|
65
|
+
const value = Number(text);
|
|
66
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
67
|
+
return { ok: false, error: "standout must be a number of at least 1 (1 is no cutoff)" };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, draft: { ...draft, standout: value } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadCanonSettingsFile(path: string = DEFAULT_CANON_SETTINGS_PATH): CanonUserSettings {
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = readFileSync(path, "utf8");
|
|
76
|
+
} catch (error: any) {
|
|
77
|
+
if (error?.code === "ENOENT") return {};
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
81
|
+
for (const key of Object.keys(parsed)) {
|
|
82
|
+
if (!SETTING_IDS.includes(key as CanonSettingId)) {
|
|
83
|
+
throw new Error(`canon settings file has no ${key} field: the surface is surface, resurface, retrieval, standout`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let draft: CanonUserSettings = {};
|
|
87
|
+
for (const id of SETTING_IDS) {
|
|
88
|
+
if (parsed[id] === undefined) continue;
|
|
89
|
+
const raw =
|
|
90
|
+
typeof parsed[id] === "boolean" ? (parsed[id] ? "on" : "off") : String(parsed[id]);
|
|
91
|
+
const result = applyCanonSettingsEdit(draft, id, raw);
|
|
92
|
+
if (!result.ok) throw new Error(result.error);
|
|
93
|
+
draft = result.draft;
|
|
94
|
+
}
|
|
95
|
+
return draft;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function saveCanonSettingsFile(path: string, settings: CanonUserSettings): void {
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
const temporary = `${path}.tmp`;
|
|
101
|
+
writeFileSync(temporary, `${JSON.stringify(settings, null, 2)}\n`);
|
|
102
|
+
renameSync(temporary, path);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface EditorRow {
|
|
106
|
+
id: CanonSettingId;
|
|
107
|
+
label: string;
|
|
108
|
+
description: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const EDITOR_ROWS: readonly EditorRow[] = [
|
|
112
|
+
{ id: "surface", label: "Surfacing on touch", description: "Govern articles surface as tool calls touch their assets" },
|
|
113
|
+
{ id: "resurface", label: "Resurface after folding", description: "A surfaced article surfaces again once it leaves the context window" },
|
|
114
|
+
{ id: "retrieval", label: "Retrieval ranking", description: "How off-spine articles are ranked against what the agent is doing" },
|
|
115
|
+
{ id: "standout", label: "Standout cutoff", description: "How far the best rank must stand out before it rides; ignored while retrieval is none" },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
function rowRawValue(settings: CanonUserSettings, id: CanonSettingId): string {
|
|
119
|
+
if (id === "surface" || id === "resurface") return settings[id] === false ? "off" : "on";
|
|
120
|
+
if (id === "retrieval") return settings.retrieval ?? "none";
|
|
121
|
+
return String(settings.standout ?? 1.4);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function rowDisplayValue(settings: CanonUserSettings, id: CanonSettingId): string {
|
|
125
|
+
const raw = rowRawValue(settings, id);
|
|
126
|
+
if (id === "standout" && (settings.retrieval ?? "none") === "none") return `${raw} (unused)`;
|
|
127
|
+
return raw;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The frame Pi's own /settings screen draws around its list. Implemented locally
|
|
131
|
+
// rather than imported because jiti keeps a separate module cache: the border's
|
|
132
|
+
// default color closure would bind to a different theme instance than the one the
|
|
133
|
+
// editor receives, so the color always arrives explicitly.
|
|
134
|
+
class SettingsBorder {
|
|
135
|
+
color: (text: string) => string;
|
|
136
|
+
|
|
137
|
+
constructor(color: (text: string) => string) {
|
|
138
|
+
this.color = color;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
invalidate(): void {}
|
|
142
|
+
|
|
143
|
+
render(width: number): string[] {
|
|
144
|
+
return [this.color("─".repeat(Math.max(1, width)))];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The exact-value editor behind the standout row's submenu: an Input prefilled
|
|
149
|
+
// with the raw value; Enter applies through applyCanonSettingsEdit and only a
|
|
150
|
+
// valid result calls done, so an invalid state can never reach the list, the
|
|
151
|
+
// file, or registration. Composition mirrors the native SelectSubmenu.
|
|
152
|
+
class StandoutEditor extends Container {
|
|
153
|
+
input: Input;
|
|
154
|
+
errorText: Text;
|
|
155
|
+
themeLike: any;
|
|
156
|
+
apply: (raw: string) => { ok: true; display: string } | { ok: false; error: string };
|
|
157
|
+
done: (displayValue?: string) => void;
|
|
158
|
+
|
|
159
|
+
constructor(themeLike: any, initialValue: string, apply: any, done: (displayValue?: string) => void) {
|
|
160
|
+
super();
|
|
161
|
+
this.themeLike = themeLike;
|
|
162
|
+
this.apply = apply;
|
|
163
|
+
this.done = done;
|
|
164
|
+
const theme = this.themeLike;
|
|
165
|
+
this.input = new Input();
|
|
166
|
+
this.errorText = new Text("", 0, 0);
|
|
167
|
+
this.addChild(new Text(theme.bold(theme.fg("accent", "Standout cutoff")), 0, 0));
|
|
168
|
+
this.addChild(new Text(theme.fg("muted", "How far the best-ranked article must outscore the rest before it rides. 1 is no cutoff."), 0, 0));
|
|
169
|
+
this.addChild(new Spacer(1));
|
|
170
|
+
this.input.setValue(initialValue);
|
|
171
|
+
// setValue parks the cursor at 0; a prefilled editor must start at the end,
|
|
172
|
+
// or typing inserts at the front and backspace deletes nothing.
|
|
173
|
+
(this.input as any).cursor = initialValue.length;
|
|
174
|
+
this.input.onSubmit = () => this.submit();
|
|
175
|
+
this.input.onEscape = () => this.done();
|
|
176
|
+
this.addChild(this.input);
|
|
177
|
+
this.addChild(new Spacer(1));
|
|
178
|
+
this.addChild(this.errorText);
|
|
179
|
+
this.addChild(new Text(theme.fg("dim", " Enter to apply · Esc to go back"), 0, 0));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
submit(): void {
|
|
183
|
+
const result = this.apply(this.input.getValue());
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
this.errorText.setText(this.themeLike.fg("error", ` ${result.error}`));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.done(result.display);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
handleInput(data: string): void {
|
|
192
|
+
this.input.handleInput(data);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The /canon-settings screen itself. Boolean and retrieval rows CYCLE through
|
|
197
|
+
// their values natively; the standout row STEPS along its lattice with left/right
|
|
198
|
+
// (clamped at the ends) and takes an exact value on Enter.
|
|
199
|
+
export class CanonSettingsEditor extends Container {
|
|
200
|
+
draft: CanonUserSettings;
|
|
201
|
+
settingsPath: string;
|
|
202
|
+
themeLike: any;
|
|
203
|
+
done: (saved: boolean) => void;
|
|
204
|
+
settingsList: SettingsList;
|
|
205
|
+
|
|
206
|
+
constructor(draft: CanonUserSettings, settingsPath: string, themeLike: any, done: (saved: boolean) => void) {
|
|
207
|
+
super();
|
|
208
|
+
this.draft = draft;
|
|
209
|
+
this.settingsPath = settingsPath;
|
|
210
|
+
this.themeLike = themeLike;
|
|
211
|
+
this.done = done;
|
|
212
|
+
const theme = this.themeLike;
|
|
213
|
+
this.addChild(new SettingsBorder((text: string) => theme.fg("border", text)));
|
|
214
|
+
this.addChild(new Text(theme.bold(theme.fg("accent", "pi-canon settings")), 0, 0));
|
|
215
|
+
this.addChild(new Text(theme.fg("muted", "Edits save immediately. ←→ steps the cutoff · Enter changes or types a value."), 0, 0));
|
|
216
|
+
this.addChild(new Spacer(1));
|
|
217
|
+
// SettingsList takes its own theme shape; adapt it off the live theme.
|
|
218
|
+
const listTheme = {
|
|
219
|
+
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
|
|
220
|
+
value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
|
|
221
|
+
description: (text: string) => theme.fg("dim", text),
|
|
222
|
+
cursor: theme.fg("accent", "→ "),
|
|
223
|
+
hint: (text: string) => theme.fg("dim", text),
|
|
224
|
+
};
|
|
225
|
+
this.settingsList = new SettingsList(
|
|
226
|
+
EDITOR_ROWS.map((row) => ({
|
|
227
|
+
id: row.id,
|
|
228
|
+
label: row.label,
|
|
229
|
+
description: row.description,
|
|
230
|
+
currentValue: rowDisplayValue(this.draft, row.id),
|
|
231
|
+
values: row.id === "surface" || row.id === "resurface"
|
|
232
|
+
? ["on", "off"]
|
|
233
|
+
: row.id === "retrieval"
|
|
234
|
+
? [...RETRIEVAL_CHOICES]
|
|
235
|
+
: undefined,
|
|
236
|
+
submenu: row.id === "standout"
|
|
237
|
+
? (_current: string, submenuDone: (displayValue?: string) => void) =>
|
|
238
|
+
new StandoutEditor(
|
|
239
|
+
themeLike,
|
|
240
|
+
rowRawValue(this.draft, row.id),
|
|
241
|
+
(raw: string) => this.applyAndSave(row.id, raw),
|
|
242
|
+
submenuDone,
|
|
243
|
+
)
|
|
244
|
+
: undefined,
|
|
245
|
+
})),
|
|
246
|
+
EDITOR_ROWS.length + 2,
|
|
247
|
+
listTheme,
|
|
248
|
+
(id: string, newValue: string) => this.applyCycled(id as CanonSettingId, newValue),
|
|
249
|
+
() => this.done(true),
|
|
250
|
+
);
|
|
251
|
+
this.addChild(this.settingsList);
|
|
252
|
+
this.addChild(new Spacer(1));
|
|
253
|
+
this.addChild(new Text(theme.fg("dim", " ←→ to step the cutoff · Enter to change · Esc to close"), 0, 0));
|
|
254
|
+
this.addChild(new SettingsBorder((text: string) => theme.fg("border", text)));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
applyAndSave(id: CanonSettingId, raw: string): { ok: true; display: string } | { ok: false; error: string } {
|
|
258
|
+
const result = applyCanonSettingsEdit(this.draft, id, raw);
|
|
259
|
+
if (!result.ok) return result;
|
|
260
|
+
this.draft = result.draft;
|
|
261
|
+
saveCanonSettingsFile(this.settingsPath, this.draft);
|
|
262
|
+
for (const row of EDITOR_ROWS) {
|
|
263
|
+
const item = (this.settingsList as any).items.find((candidate: any) => candidate.id === row.id);
|
|
264
|
+
if (item) item.currentValue = rowDisplayValue(this.draft, row.id);
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, display: rowDisplayValue(this.draft, id) };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* Cycling rows reach this handler from SettingsList itself. The standout row
|
|
270
|
+
reaches it too when its submenu closes: SettingsList re-fires onChange with
|
|
271
|
+
the DISPLAY string, which is not a number, so the handler ignores that row
|
|
272
|
+
entirely; the submit path already applied and saved it. */
|
|
273
|
+
applyCycled(id: CanonSettingId, newValue: string): void {
|
|
274
|
+
if (id === "standout") return;
|
|
275
|
+
this.applyAndSave(id, newValue);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// One step along the standout lattice, clamped at its ends. Stepping is inert
|
|
279
|
+
// while retrieval is none, because the value is ignored there anyway.
|
|
280
|
+
stepStandout(direction: number): void {
|
|
281
|
+
if ((this.draft.retrieval ?? "none") === "none") return;
|
|
282
|
+
const current = Number(rowRawValue(this.draft, "standout"));
|
|
283
|
+
const target = direction > 0
|
|
284
|
+
? STANDOUT_LATTICE.find((candidate) => candidate > current + 1e-9)
|
|
285
|
+
: [...STANDOUT_LATTICE].reverse().find((candidate) => candidate < current - 1e-9);
|
|
286
|
+
if (target === undefined) return;
|
|
287
|
+
this.applyAndSave("standout", String(target));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
handleInput(data: string): void {
|
|
291
|
+
// An open submenu owns everything until it closes.
|
|
292
|
+
if (this.settingsList.submenuComponent) {
|
|
293
|
+
this.settingsList.handleInput(data);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (matchesKey(data, Key.left)) {
|
|
297
|
+
this.stepStandout(-1);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (matchesKey(data, Key.right)) {
|
|
301
|
+
this.stepStandout(+1);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (matchesKey(data, Key.escape)) {
|
|
305
|
+
this.done(true);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this.settingsList.handleInput(data);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function registerCanonSettings(
|
|
313
|
+
pi: any,
|
|
314
|
+
options: { settingsPath?: string } = {},
|
|
315
|
+
): void {
|
|
316
|
+
const settingsPath = options.settingsPath ?? DEFAULT_CANON_SETTINGS_PATH;
|
|
317
|
+
pi.registerCommand("canon-settings", {
|
|
318
|
+
description: "Configure pi-canon: surfacing, resurfacing, retrieval ranking, standout cutoff",
|
|
319
|
+
handler: async (_args: string, ctx: any) => {
|
|
320
|
+
if (typeof ctx.ui?.custom !== "function") {
|
|
321
|
+
throw new Error("/canon-settings needs an interactive UI; set the options in the settings file instead");
|
|
322
|
+
}
|
|
323
|
+
const draft = loadCanonSettingsFile(settingsPath);
|
|
324
|
+
await ctx.ui.custom((_tui: unknown, theme: any, _keybindings: unknown, done: (saved: boolean) => void) =>
|
|
325
|
+
new CanonSettingsEditor(draft, settingsPath, theme, done));
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-canon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Canonical project memory for the Pi coding agent: one article per asset at a knowable address, an append-only journal beneath it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"node": ">=22.18"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@earendil-works/pi-coding-agent": ">=0.83.0 <
|
|
24
|
+
"@earendil-works/pi-coding-agent": ">=0.83.0 <2",
|
|
25
|
+
"@earendil-works/pi-tui": "*"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
28
|
"test": "node tests/verify.mjs"
|
|
@@ -43,10 +44,10 @@
|
|
|
43
44
|
"license": "MIT",
|
|
44
45
|
"repository": {
|
|
45
46
|
"type": "git",
|
|
46
|
-
"url": "git+https://github.com/shaneconner/
|
|
47
|
+
"url": "git+https://github.com/shaneconner/canon.git"
|
|
47
48
|
},
|
|
48
|
-
"bugs": "https://github.com/shaneconner/
|
|
49
|
-
"homepage": "https://github.com/shaneconner/
|
|
49
|
+
"bugs": "https://github.com/shaneconner/canon/issues",
|
|
50
|
+
"homepage": "https://github.com/shaneconner/canon#readme",
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"jiti": "^2.7.0"
|
|
52
53
|
}
|