pi-canon 0.2.3 → 0.3.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.
@@ -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.2.3",
3
+ "version": "0.3.0",
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": {
@@ -49,5 +49,8 @@
49
49
  "homepage": "https://github.com/shaneconner/pi-canon#readme",
50
50
  "devDependencies": {
51
51
  "jiti": "^2.7.0"
52
+ },
53
+ "dependencies": {
54
+ "@earendil-works/pi-tui": "^0.84.2"
52
55
  }
53
56
  }