@hypercarrier/rarebit 0.1.0-alpha.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/README.md +96 -20
  3. package/RUNBOOK.md +32 -9
  4. package/bin/piq.mjs +14 -0
  5. package/docs/examples/rarebit.settings.json +19 -0
  6. package/package.json +29 -3
  7. package/scripts/e2e-fork-pi.mjs +824 -0
  8. package/scripts/e2e-recap-pi.mjs +456 -0
  9. package/scripts/e2e-settings-pi.mjs +267 -0
  10. package/scripts/e2e-v020-pi.mjs +269 -0
  11. package/scripts/verify-package.mjs +20 -12
  12. package/src/extension.mjs +280 -21
  13. package/src/index.mjs +1 -0
  14. package/src/lifecycle.mjs +32 -7
  15. package/src/pi-session.mjs +237 -0
  16. package/src/piq-cli.mjs +96 -0
  17. package/src/piq.mjs +265 -0
  18. package/src/rarebit-activity.mjs +8 -2
  19. package/src/rarebit-cli.mjs +103 -5
  20. package/src/rarebit-command.mjs +29 -1
  21. package/src/rarebit-core.mjs +87 -9
  22. package/src/rarebit-fork-lineage.mjs +69 -0
  23. package/src/rarebit-fork.mjs +241 -0
  24. package/src/rarebit-recap.mjs +422 -0
  25. package/src/rarebit-service.mjs +60 -7
  26. package/src/rarebit-session.mjs +12 -160
  27. package/src/rarebit-settings-ui.mjs +479 -0
  28. package/src/rarebit-settings.mjs +90 -7
  29. package/src/types.d.ts +91 -3
  30. package/test/lifecycle-contract-independent.test.mjs +2 -0
  31. package/test/package-artifact-identity.test.mjs +54 -0
  32. package/test/piq.test.mjs +212 -0
  33. package/test/rarebit-command.test.mjs +11 -4
  34. package/test/rarebit-fork.test.mjs +124 -0
  35. package/test/rarebit-recap.test.mjs +513 -0
  36. package/test/rarebit-settings-ui.test.mjs +388 -0
  37. package/test/rarebit-v020.test.mjs +294 -0
  38. package/test/summary-attention-independent.test.mjs +1 -1
@@ -0,0 +1,479 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { chmod, lstat, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import {
7
+ DEFAULT_RAREBIT_MAX_INPUT_TOKENS,
8
+ MAX_RAREBIT_INPUT_TOKENS,
9
+ DEFAULT_RAREBIT_RECAP_DELAY_MS,
10
+ modelFromRarebitSettings,
11
+ } from "./rarebit-settings.mjs";
12
+ import {
13
+ DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE,
14
+ normalizeRarebitSummaryPrompt,
15
+ } from "./rarebit-core.mjs";
16
+
17
+ // This module owns Rarebit's human-only command palette and settings editor.
18
+ // It edits only the `rarebit` namespace in Pi settings. Model turns, Session
19
+ // records, and existing Teams remain outside this boundary.
20
+
21
+ const MAX_SETTINGS_BYTES = 2 * 1024 * 1024;
22
+ const HOST_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "host";
23
+ let tuiPrimitives;
24
+
25
+ async function loadTuiPrimitives() {
26
+ if (!tuiPrimitives) tuiPrimitives = await import("@earendil-works/pi-tui");
27
+ return tuiPrimitives;
28
+ }
29
+
30
+ export const RAREBIT_SETTINGS_TABS = Object.freeze([
31
+ "Actions",
32
+ "Summary",
33
+ "Recap",
34
+ "Session",
35
+ ]);
36
+
37
+ const isRecord = (value) =>
38
+ value !== null && typeof value === "object" && !Array.isArray(value);
39
+
40
+ const record = (value) => (isRecord(value) ? value : {});
41
+
42
+ const clean = (value) => String(value ?? "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
43
+
44
+ const getPath = (value, path) => path.reduce((current, key) => record(current)[key], value);
45
+
46
+ function setPath(root, path, value) {
47
+ if (!path.length) return;
48
+ const [key, ...rest] = path;
49
+ if (!rest.length) {
50
+ if (value === undefined) delete root[key];
51
+ else root[key] = value;
52
+ return;
53
+ }
54
+ if (value === undefined && !isRecord(root[key])) return;
55
+ root[key] = { ...record(root[key]) };
56
+ setPath(root[key], rest, value);
57
+ if (isRecord(root[key]) && Object.keys(root[key]).length === 0) delete root[key];
58
+ }
59
+
60
+ function digest(bytes) {
61
+ return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
62
+ }
63
+
64
+ async function readSettingsFile(file) {
65
+ let handle;
66
+ try {
67
+ try {
68
+ if ((await lstat(file)).isSymbolicLink())
69
+ throw new Error("Pi settings must not be a symbolic link.");
70
+ } catch (error) {
71
+ if (error?.code !== "ENOENT") throw error;
72
+ }
73
+ const flags = fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | (fsConstants.O_NOFOLLOW ?? 0);
74
+ handle = await open(file, flags);
75
+ const metadata = await handle.stat();
76
+ if (!metadata.isFile() || metadata.size > MAX_SETTINGS_BYTES)
77
+ throw new Error("Pi settings must be a regular file no larger than 2 MiB.");
78
+ const bytes = await handle.readFile();
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(bytes.toString("utf8"));
82
+ } catch {
83
+ throw new Error("Pi settings contain malformed JSON.");
84
+ }
85
+ if (!isRecord(parsed)) throw new Error("Pi settings root must be an object.");
86
+ return { root: parsed, bytes, mode: metadata.mode & 0o777, revision: digest(bytes) };
87
+ } catch (error) {
88
+ if (error?.code === "ENOENT") return { root: {}, bytes: Buffer.from("{}"), mode: 0o600, revision: "missing" };
89
+ throw error;
90
+ } finally {
91
+ await handle?.close().catch(() => {});
92
+ }
93
+ }
94
+
95
+ function settingsFile({ cwd = process.cwd(), agentDir, scope }) {
96
+ const resolvedAgentDir = resolve(agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"));
97
+ return scope === "project"
98
+ ? join(resolve(cwd), ".pi", "settings.json")
99
+ : join(resolvedAgentDir, "settings.json");
100
+ }
101
+
102
+ export async function readRarebitSettingsDocument({
103
+ cwd = process.cwd(),
104
+ agentDir,
105
+ scope = "global",
106
+ projectTrusted = false,
107
+ } = {}) {
108
+ if (scope === "project" && !projectTrusted)
109
+ throw new Error("Project settings require a trusted Pi project.");
110
+ const file = settingsFile({ cwd, agentDir, scope });
111
+ const observed = await readSettingsFile(file);
112
+ if (Object.hasOwn(observed.root, "rarebit") && !isRecord(observed.root.rarebit))
113
+ throw new Error("Pi settings rarebit namespace must be a JSON object.");
114
+ return {
115
+ file,
116
+ scope,
117
+ namespace: isRecord(observed.root.rarebit) ? observed.root.rarebit : {},
118
+ revision: observed.revision,
119
+ mode: observed.mode,
120
+ };
121
+ }
122
+
123
+ async function withLock(file, operation) {
124
+ // Pi's FileSettingsStorage uses <settings>.lock. Share that lock directory
125
+ // so an editor does not race a native Pi settings write.
126
+ const lock = `${file}.lock`;
127
+ await mkdir(dirname(file), { recursive: true });
128
+ let acquired = false;
129
+ try {
130
+ for (let attempt = 0; attempt < 80; attempt += 1) {
131
+ try {
132
+ await mkdir(lock);
133
+ acquired = true;
134
+ break;
135
+ } catch (error) {
136
+ if (error?.code !== "EEXIST") throw error;
137
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
138
+ }
139
+ }
140
+ if (!acquired) throw new Error("Another Rarebit settings editor is saving; retry.");
141
+ return await operation();
142
+ } finally {
143
+ if (acquired) await rm(lock, { recursive: true, force: true }).catch(() => {});
144
+ }
145
+ }
146
+
147
+ export async function saveRarebitSettingsDocument(document, namespace, { isCurrent } = {}) {
148
+ if (!document || (document.scope === "project" && !document.file))
149
+ throw new Error("Rarebit settings document is unavailable.");
150
+ if (!isRecord(namespace)) throw new Error("Rarebit settings must be a JSON object.");
151
+ const serializedNamespace = JSON.stringify(namespace);
152
+ if (Buffer.byteLength(serializedNamespace, "utf8") > MAX_SETTINGS_BYTES)
153
+ throw new Error("Rarebit settings exceed the 2 MiB editor limit.");
154
+ return withLock(document.file, async () => {
155
+ const current = await readSettingsFile(document.file);
156
+ if (current.revision !== document.revision)
157
+ throw new Error("Pi settings changed after they were opened. Reload before saving.");
158
+ if (typeof isCurrent === "function" && !isCurrent())
159
+ throw new Error("The Pi Session changed. Reopen Rarebit settings before saving.");
160
+ const updated = { ...current.root, rarebit: JSON.parse(serializedNamespace) };
161
+ const bytes = Buffer.from(`${JSON.stringify(updated, null, 2)}\n`, "utf8");
162
+ if (bytes.length > MAX_SETTINGS_BYTES)
163
+ throw new Error("Pi settings would exceed the 2 MiB editor limit.");
164
+ const temporary = join(dirname(document.file), `.${basename(document.file)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
165
+ try {
166
+ await writeFile(temporary, bytes, { mode: current.mode || 0o600, flag: "wx" });
167
+ if (current.mode) await chmod(temporary, current.mode).catch(() => {});
168
+ if (typeof isCurrent === "function" && !isCurrent())
169
+ throw new Error("The Pi Session changed. Reopen Rarebit settings before saving.");
170
+ await rename(temporary, document.file);
171
+ } finally {
172
+ await rm(temporary, { force: true }).catch(() => {});
173
+ }
174
+ return { ...document, namespace: JSON.parse(serializedNamespace), revision: digest(bytes), mode: current.mode };
175
+ });
176
+ }
177
+
178
+ const field = (id, tab, label, path, kind, description, options = {}) => ({
179
+ id,
180
+ tab,
181
+ label,
182
+ path,
183
+ kind,
184
+ description,
185
+ ...options,
186
+ });
187
+
188
+ export const RAREBIT_SETTINGS_FIELDS = Object.freeze([
189
+ field("summary_triggered_diagnostics", "Summary", "Show Summary triggered", ["diagnostics", "summary_triggered"], "boolean", "Show the Summary triggered diagnostic in the TUI. Warnings and errors always remain visible.", { defaultValue: false }),
190
+ field("summary_updated_diagnostics", "Summary", "Show Summary updated", ["diagnostics", "summary_updated"], "boolean", "Show the Summary updated diagnostic in the TUI. Warnings and errors always remain visible.", { defaultValue: false }),
191
+ field("max_input_tokens", "Summary", "Summary input cap", ["max_input_tokens"], "number", "Maximum Summary input in estimated tokens. The implementation bounds the prompt at about four UTF-16 characters per token.", { defaultValue: DEFAULT_RAREBIT_MAX_INPUT_TOKENS, min: 1, max: MAX_RAREBIT_INPUT_TOKENS }),
192
+ field("summary_prompt", "Summary", "Summary prompt", ["summary_prompt"], "multiline", "Optional multiline guidance for Summary prose. The fixed evidence and JSON status contract remains unchanged.", { defaultValue: DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE }),
193
+ field("min_total_length", "Summary", "Automatic Summary threshold", ["min_total_length"], "number", "Minimum Session prose length in estimated tokens before automatic Summary synthesis.", { defaultValue: 80_000, min: 0 }),
194
+ field("max_rarebit_ratio", "Summary", "Maximum Rarebit ratio", ["max_rarebit_ratio"], "ratio", "Maximum selected Rarebit prose ratio for automatic Summary synthesis.", { defaultValue: 0.4, min: 0, max: 1 }),
195
+ field("recap_enabled", "Recap", "Recap widget", ["recap", "enabled"], "boolean", "Show the human-only Recap widget after a current Summary is available.", { defaultValue: true }),
196
+ field("recap_delay_ms", "Recap", "Recap delay (ms)", ["recap", "delay_ms"], "number", "Delay before the human-only Recap widget appears.", { defaultValue: DEFAULT_RAREBIT_RECAP_DELAY_MS, min: 0 }),
197
+ field("recap_timezone", "Recap", "Recap timezone", ["recap", "timezone"], "timezone", `Timestamp zone. Blank uses the host timezone (${HOST_TIMEZONE}).`, { defaultValue: HOST_TIMEZONE }),
198
+ field("auto_title", "Session", "Automatic Session title", ["auto_title"], "boolean", "Generate a Rarebit title from the first persisted owner message.", { defaultValue: true }),
199
+ field("model", "Session", "Rarebit model", ["model"], "string", "Model in provider/model form. Blank removes the local override.", { defaultValue: "Not set" }),
200
+ ]);
201
+
202
+ const paletteItems = [
203
+ { id: "settings", tab: "Actions", label: "Settings", description: "Open the interactive Rarebit settings editor." },
204
+ { id: "status", tab: "Actions", label: "Status", description: "Show effective Rarebit configuration." },
205
+ { id: "help", tab: "Actions", label: "Command help", description: "Show direct /rarebit commands." },
206
+ { id: "summarize", tab: "Actions", label: "Summarize", description: "Force a Rarebit Summary materialization." },
207
+ { id: "recall", tab: "Actions", label: "Recall", description: "Recall active-branch Rarebit messages with a prompt." },
208
+ { id: "fork", tab: "Actions", label: "Fork", description: "Open a bounded Rarebit fork without a model turn." },
209
+ { id: "summary_status", tab: "Summary", label: "Summary status", description: "Show effective Summary policy." },
210
+ { id: "summary_settings", tab: "Summary", label: "Summary settings", description: "Edit Summary diagnostics and input policy." },
211
+ { id: "recap", tab: "Recap", label: "Recap", description: "Show the current Recap in the TUI." },
212
+ { id: "recap_settings", tab: "Recap", label: "Recap settings", description: "Edit Recap display and timestamp settings." },
213
+ { id: "title", tab: "Session", label: "Title Session", description: "Generate a title for the active Session." },
214
+ { id: "session_settings", tab: "Session", label: "Session settings", description: "Edit model and automatic title settings." },
215
+ ];
216
+
217
+ function key(data, expected, primitives) {
218
+ const { Key, matchesKey } = primitives;
219
+ if (expected === "escape") return matchesKey(data, Key.escape);
220
+ if (expected === "tab") return matchesKey(data, Key.tab);
221
+ if (expected === "shiftTab") return matchesKey(data, Key.shift(Key.tab));
222
+ if (expected === "up") return matchesKey(data, Key.up) || data === "k";
223
+ if (expected === "down") return matchesKey(data, Key.down) || data === "j";
224
+ if (expected === "enter") return matchesKey(data, Key.enter);
225
+ return false;
226
+ }
227
+
228
+ function createTabbedComponent({ title, tabs, items, initialTab, current, isCurrent, scopeLine }, host, theme, done, primitives) {
229
+ let activeTab = initialTab ?? tabs[0];
230
+ let selectedIndex = 0;
231
+ let closed = false;
232
+ const close = (value) => {
233
+ if (closed) return;
234
+ closed = true;
235
+ done(isCurrent() ? value : undefined);
236
+ };
237
+ const visible = () => items.filter((item) => item.tab === activeTab);
238
+ const selectTab = (step) => {
239
+ const index = tabs.indexOf(activeTab);
240
+ activeTab = tabs[(index + step + tabs.length) % tabs.length];
241
+ selectedIndex = 0;
242
+ host.requestRender(true);
243
+ };
244
+ return {
245
+ render(width) {
246
+ const rows = visible();
247
+ const selected = rows[selectedIndex];
248
+ const rule = theme.fg?.("borderMuted", "─".repeat(Math.max(0, width))) ?? "─".repeat(Math.max(0, width));
249
+ const tabLine = tabs.map((tab) => tab === activeTab
250
+ ? (theme.fg?.("accent", theme.bold?.(tab) ?? tab) ?? tab)
251
+ : (theme.fg?.("dim", tab) ?? tab)).join(theme.fg?.("dim", " / ") ?? " / ");
252
+ const output = [
253
+ rule,
254
+ theme.fg?.("accent", theme.bold?.(` ${title}`) ?? ` ${title}`) ?? ` ${title}`,
255
+ ...(scopeLine ? [theme.fg?.("dim", ` ${scopeLine}`) ?? ` ${scopeLine}`] : []),
256
+ "",
257
+ ` ${tabLine}`,
258
+ rule,
259
+ ];
260
+ for (const [index, item] of rows.entries()) {
261
+ const marker = index === selectedIndex ? "→ " : " ";
262
+ const value = current?.(item) ?? "Open";
263
+ output.push(`${marker}${item.label}: ${value}`);
264
+ }
265
+ if (selected) output.push(theme.fg?.("dim", ` ${selected.description}`) ?? ` ${selected.description}`);
266
+ output.push(theme.fg?.("dim", " Tab/Shift+Tab sections · ↑/↓ rows · Enter choose · Esc cancel") ?? " Tab/Shift+Tab sections · ↑/↓ rows · Enter choose · Esc cancel");
267
+ output.push(rule);
268
+ return output.map((line) => primitives.truncateToWidth(line, width, ""));
269
+ },
270
+ handleInput(data) {
271
+ if (closed) return;
272
+ if (!isCurrent() || key(data, "escape", primitives)) return close();
273
+ if (key(data, "shiftTab", primitives)) return selectTab(-1);
274
+ if (key(data, "tab", primitives)) return selectTab(1);
275
+ const rows = visible();
276
+ if (key(data, "up", primitives)) selectedIndex = (selectedIndex - 1 + rows.length) % Math.max(rows.length, 1);
277
+ else if (key(data, "down", primitives)) selectedIndex = (selectedIndex + 1) % Math.max(rows.length, 1);
278
+ else if (key(data, "enter", primitives)) close(rows[selectedIndex]);
279
+ host.requestRender();
280
+ },
281
+ invalidate() {},
282
+ dispose() { closed = true; },
283
+ };
284
+ }
285
+
286
+ export async function openRarebitPalette(ctx, { isCurrent = () => true } = {}) {
287
+ if (ctx?.mode !== "tui" || ctx?.hasUI === false || typeof ctx?.ui?.custom !== "function") return undefined;
288
+ const primitives = await loadTuiPrimitives();
289
+ return ctx.ui.custom((tui, theme, _keybindings, done) => createTabbedComponent({
290
+ title: "Rarebit",
291
+ tabs: RAREBIT_SETTINGS_TABS,
292
+ items: paletteItems,
293
+ isCurrent,
294
+ scopeLine: "Human-only command palette. Selecting an action closes the palette before it runs.",
295
+ }, { requestRender: (force) => tui.requestRender(force) }, theme, done, primitives));
296
+ }
297
+
298
+ function settingDisplay(fieldDefinition, namespace, scope, inherited) {
299
+ const raw = getPath(namespace, fieldDefinition.path);
300
+ const inheritedValue = getPath(inherited, fieldDefinition.path);
301
+ const value = raw !== undefined
302
+ ? raw
303
+ : inheritedValue !== undefined
304
+ ? inheritedValue
305
+ : fieldDefinition.defaultValue;
306
+ const source = raw !== undefined
307
+ ? ` (${scope})`
308
+ : inheritedValue !== undefined && scope === "project"
309
+ ? " (inherited)"
310
+ : fieldDefinition.defaultValue !== undefined
311
+ ? " (default)"
312
+ : "";
313
+ if (value === undefined) return "Not set";
314
+ if (fieldDefinition.kind === "boolean") return `${value ? "on" : "off"}${source}`;
315
+ if (fieldDefinition.kind === "number" || fieldDefinition.kind === "ratio") {
316
+ const rendered = fieldDefinition.kind === "ratio"
317
+ ? String(value)
318
+ : Number(value).toLocaleString();
319
+ return `${rendered}${source}`;
320
+ }
321
+ if (fieldDefinition.kind === "multiline") {
322
+ const rendered = clean(value).replace(/\s+/g, " ").trim();
323
+ const preview = rendered.length > 96 ? `${rendered.slice(0, 93).trimEnd()}…` : rendered;
324
+ return `${preview}${source}`;
325
+ }
326
+ if (fieldDefinition.kind === "timezone" && (value === "host" || value === HOST_TIMEZONE))
327
+ return `${HOST_TIMEZONE}${source}`;
328
+ if (fieldDefinition.kind === "string" && isRecord(value))
329
+ return `${clean(value.provider)}/${clean(value.id)}${source}`;
330
+ return `${clean(value)}${source}`;
331
+ }
332
+
333
+ function validateTimezone(value) {
334
+ if (!value || value === "host") return true;
335
+ try {
336
+ new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
337
+ return true;
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ async function editField(ctx, fieldDefinition, namespace, inherited) {
344
+ const raw = getPath(namespace, fieldDefinition.path);
345
+ if (fieldDefinition.kind === "boolean") {
346
+ const picked = await ctx.ui.select(fieldDefinition.label, [
347
+ raw === true ? "on" : raw === false ? "off" : "Default",
348
+ ...(raw === true || raw === undefined ? ["off"] : []),
349
+ ...(raw === false || raw === undefined ? ["on"] : []),
350
+ "Remove override",
351
+ ]);
352
+ if (picked === undefined) return undefined;
353
+ if (picked === "Remove override" || picked === "Default") return { value: undefined };
354
+ return { value: picked === "on" };
355
+ }
356
+ const displayedValue = fieldDefinition.kind === "multiline" && raw === undefined
357
+ ? getPath(inherited, fieldDefinition.path) ?? fieldDefinition.defaultValue
358
+ : raw;
359
+ const displayed = displayedValue === undefined
360
+ ? ""
361
+ : fieldDefinition.kind === "string" && isRecord(displayedValue)
362
+ ? `${clean(displayedValue.provider)}/${clean(displayedValue.id)}`
363
+ : String(displayedValue);
364
+ const prompt = `${fieldDefinition.label} (blank removes override)`;
365
+ const entered = fieldDefinition.kind === "multiline" && typeof ctx.ui.editor === "function"
366
+ ? await ctx.ui.editor(prompt, displayed)
367
+ : await ctx.ui.input(prompt, displayed);
368
+ if (entered === undefined) return undefined;
369
+ if (entered.trim() === "") return { value: undefined };
370
+ if (fieldDefinition.kind === "multiline") {
371
+ return { value: normalizeRarebitSummaryPrompt(entered).guidance };
372
+ }
373
+ if (fieldDefinition.kind === "string" || fieldDefinition.kind === "timezone") {
374
+ const value = entered.trim();
375
+ if (fieldDefinition.kind === "timezone" && !validateTimezone(value)) throw new Error("Use a valid IANA timezone such as Asia/Hong_Kong, or leave it blank for the host timezone.");
376
+ if (fieldDefinition.kind === "string") {
377
+ const parsed = modelFromRarebitSettings({ model: value });
378
+ if (parsed.error || !parsed.model) throw new Error(parsed.error ?? "Use a model in provider/model form.");
379
+ return { value: `${parsed.model.provider}/${parsed.model.id}` };
380
+ }
381
+ return { value };
382
+ }
383
+ const numberValue = Number(entered);
384
+ if (!Number.isSafeInteger(numberValue) && fieldDefinition.kind !== "ratio") throw new Error("Use a finite number.");
385
+ if (!Number.isFinite(numberValue)) throw new Error("Use a finite number.");
386
+ if (fieldDefinition.min !== undefined && numberValue < fieldDefinition.min) throw new Error(`Value must be at least ${fieldDefinition.min}.`);
387
+ if (fieldDefinition.max !== undefined && numberValue > fieldDefinition.max) throw new Error(`Value must be at most ${fieldDefinition.max}.`);
388
+ return { value: numberValue };
389
+ }
390
+
391
+ function fieldItems(scope, projectTrusted) {
392
+ const scopeItem = { id: "scope", tab: "Actions", label: "Editing scope", description: "Choose the global Pi settings or the trusted project override.", values: projectTrusted ? ["global", "project"] : ["global"] };
393
+ return [scopeItem, ...RAREBIT_SETTINGS_FIELDS];
394
+ }
395
+
396
+ export async function openRarebitSettings(ctx, {
397
+ initialScope = "global",
398
+ projectTrusted = ctx?.isProjectTrusted?.() === true,
399
+ agentDir,
400
+ isCurrent = () => true,
401
+ onSaved,
402
+ } = {}) {
403
+ if (ctx?.mode !== "tui" || ctx?.hasUI === false || typeof ctx?.ui?.custom !== "function") {
404
+ ctx?.ui?.notify?.("Rarebit settings require interactive Pi.", "warning");
405
+ return;
406
+ }
407
+ const primitives = await loadTuiPrimitives();
408
+ if (initialScope === "project" && !projectTrusted) {
409
+ ctx.ui.notify("Project settings require a trusted Pi project. No file was read or changed.", "warning");
410
+ return;
411
+ }
412
+ let scope = initialScope === "project" && projectTrusted ? "project" : "global";
413
+ let activeTab = "Summary";
414
+ while (isCurrent()) {
415
+ let document;
416
+ let readError;
417
+ try {
418
+ document = await readRarebitSettingsDocument({ cwd: ctx.cwd, agentDir, scope, projectTrusted });
419
+ } catch (error) {
420
+ readError = error instanceof Error ? error.message : String(error);
421
+ }
422
+ let inherited = {};
423
+ if (scope === "project" && projectTrusted) {
424
+ try { inherited = (await readRarebitSettingsDocument({ cwd: ctx.cwd, agentDir, scope: "global", projectTrusted: true })).namespace; } catch { inherited = {}; }
425
+ }
426
+ if (readError) {
427
+ ctx.ui.notify(`Rarebit settings unavailable: ${readError}`, "error");
428
+ return;
429
+ }
430
+ const selected = await ctx.ui.custom((tui, theme, _keybindings, done) => createTabbedComponent({
431
+ title: "Rarebit Settings",
432
+ tabs: RAREBIT_SETTINGS_TABS,
433
+ items: fieldItems(scope, projectTrusted),
434
+ initialTab: activeTab,
435
+ isCurrent,
436
+ scopeLine: `Editing ${scope} · ${document.file}`,
437
+ current: (item) => item.id === "scope"
438
+ ? scope
439
+ : settingDisplay(item, document.namespace, scope, inherited),
440
+ }, { requestRender: (force) => tui.requestRender(force) }, theme, done, primitives));
441
+ if (!selected || !isCurrent()) return;
442
+ activeTab = selected.tab;
443
+ if (selected.id === "scope") {
444
+ const picked = await ctx.ui.select("Editing scope", projectTrusted ? ["global", "project"] : ["global"]);
445
+ if (picked === "project" && projectTrusted) scope = "project";
446
+ else if (picked === "global") scope = "global";
447
+ continue;
448
+ }
449
+ const fieldDefinition = RAREBIT_SETTINGS_FIELDS.find((candidate) => candidate.id === selected.id);
450
+ if (!fieldDefinition) continue;
451
+ try {
452
+ const edit = await editField(ctx, fieldDefinition, document.namespace, inherited);
453
+ if (!edit) continue;
454
+ const next = structuredClone(record(document.namespace));
455
+ setPath(next, fieldDefinition.path, edit.value);
456
+ if (!await ctx.ui.confirm(`Save Rarebit ${scope} settings?`, `File: ${document.file}\nOnly the rarebit namespace will change.`)) continue;
457
+ if (!isCurrent()) return;
458
+ await saveRarebitSettingsDocument(document, next, { isCurrent });
459
+ ctx.ui.notify(`Saved Rarebit ${scope} settings.`, "info");
460
+ await onSaved?.({ scope, namespace: next, document });
461
+ } catch (error) {
462
+ ctx.ui.notify(`Rarebit settings were not saved: ${error instanceof Error ? error.message : String(error)}`, "error");
463
+ }
464
+ }
465
+ }
466
+
467
+ export function rarebitPaletteCommand(selection) {
468
+ switch (selection?.id) {
469
+ case "settings": case "summary_settings": case "recap_settings": case "session_settings": return "settings";
470
+ case "summary_status": return "status";
471
+ case "recap": return "recap";
472
+ case "summarize": return "summarize";
473
+ case "recall": return "recall";
474
+ case "title": return "title";
475
+ case "fork": return "fork";
476
+ case "help": return "help";
477
+ default: return undefined;
478
+ }
479
+ }
@@ -1,32 +1,80 @@
1
- import { DEFAULT_RAREBIT_SUMMARY_POLICY } from "./rarebit-core.mjs";
1
+ import {
2
+ DEFAULT_RAREBIT_SUMMARY_POLICY,
3
+ } from "./rarebit-core.mjs";
4
+
5
+ export const DEFAULT_RAREBIT_RECAP_DELAY_MS = 60_000;
6
+ export const DEFAULT_RAREBIT_RECAP_TIMEZONE = "host";
7
+ export const DEFAULT_RAREBIT_MAX_INPUT_TOKENS = 64_000;
8
+ export const MAX_RAREBIT_INPUT_TOKENS = Math.floor(Number.MAX_SAFE_INTEGER / 4);
9
+ export const DEFAULT_RAREBIT_DIAGNOSTICS = Object.freeze({
10
+ summaryTriggered: false,
11
+ summaryUpdated: false,
12
+ });
2
13
 
3
14
  function nonEmptyString(value) {
4
15
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
5
16
  }
6
17
 
18
+ function isRecord(value) {
19
+ return value !== null && typeof value === "object" && !Array.isArray(value);
20
+ }
21
+
22
+ export function normalizeRarebitRecapTimezone(value) {
23
+ const candidate = nonEmptyString(value);
24
+ if (!candidate || candidate === DEFAULT_RAREBIT_RECAP_TIMEZONE)
25
+ return DEFAULT_RAREBIT_RECAP_TIMEZONE;
26
+ try {
27
+ new Intl.DateTimeFormat(undefined, { timeZone: candidate }).format();
28
+ return candidate;
29
+ } catch {
30
+ return DEFAULT_RAREBIT_RECAP_TIMEZONE;
31
+ }
32
+ }
33
+
7
34
  /**
8
35
  * Merge just the dedicated Rarebit namespace. Pi's broader settings merge is
9
- * deliberately not reimplemented here: this boundary has only one nested
10
- * object whose inheritable fields must survive a Project override.
36
+ * deliberately not reimplemented here: this boundary has only the small
37
+ * nested objects whose inheritable fields must survive a Project override.
11
38
  */
12
39
  export function mergeRarebitSettings(
13
40
  globalSettings = {},
14
41
  projectSettings = {},
15
42
  ) {
16
43
  const globalRarebit =
17
- globalSettings?.rarebit && typeof globalSettings.rarebit === "object"
44
+ isRecord(globalSettings?.rarebit)
18
45
  ? globalSettings.rarebit
19
46
  : {};
20
47
  const projectRarebit =
21
- projectSettings?.rarebit && typeof projectSettings.rarebit === "object"
48
+ isRecord(projectSettings?.rarebit)
22
49
  ? projectSettings.rarebit
23
50
  : {};
24
- return { ...globalRarebit, ...projectRarebit };
51
+ const merged = { ...globalRarebit, ...projectRarebit };
52
+ if (
53
+ isRecord(globalRarebit.recap) &&
54
+ isRecord(projectRarebit.recap)
55
+ )
56
+ merged.recap = { ...globalRarebit.recap, ...projectRarebit.recap };
57
+ else if (isRecord(globalRarebit.recap) && !isRecord(projectRarebit.recap))
58
+ merged.recap = globalRarebit.recap;
59
+ if (
60
+ isRecord(globalRarebit.diagnostics) &&
61
+ isRecord(projectRarebit.diagnostics)
62
+ )
63
+ merged.diagnostics = {
64
+ ...globalRarebit.diagnostics,
65
+ ...projectRarebit.diagnostics,
66
+ };
67
+ else if (
68
+ isRecord(globalRarebit.diagnostics) &&
69
+ !isRecord(projectRarebit.diagnostics)
70
+ )
71
+ merged.diagnostics = globalRarebit.diagnostics;
72
+ return merged;
25
73
  }
26
74
 
27
75
  export function modelFromRarebitSettings(rarebit) {
28
76
  const candidate = rarebit?.model;
29
- if (candidate && typeof candidate === "object") {
77
+ if (isRecord(candidate)) {
30
78
  const provider = nonEmptyString(candidate.provider);
31
79
  const id = nonEmptyString(candidate.id);
32
80
  if (provider && id) return { model: { provider, id } };
@@ -62,6 +110,27 @@ export function resolveRarebitSettings(
62
110
  ) {
63
111
  const rarebit = mergeRarebitSettings(globalSettings, projectSettings);
64
112
  const resolved = modelFromRarebitSettings(rarebit);
113
+ const recap =
114
+ isRecord(rarebit?.recap)
115
+ ? rarebit.recap
116
+ : {};
117
+ const recapDelay = Number(recap.delay_ms);
118
+ const recapEnabled = recap.enabled;
119
+ const diagnostics =
120
+ isRecord(rarebit?.diagnostics)
121
+ ? rarebit.diagnostics
122
+ : {};
123
+ // Keep this raw so an invalid Summary prompt cannot discard valid model,
124
+ // Recap, or title settings during settings loading. The Summary service
125
+ // validates it before model selection or job reservation.
126
+ const summaryPrompt = rarebit.summary_prompt;
127
+ // Preserve an explicitly invalid cap so the service rejects the request
128
+ // before it can invoke a model. Only an omitted setting receives the safe
129
+ // default.
130
+ const maxInputTokens =
131
+ rarebit.max_input_tokens === undefined
132
+ ? DEFAULT_RAREBIT_MAX_INPUT_TOKENS
133
+ : Number(rarebit.max_input_tokens);
65
134
  return {
66
135
  ...(resolved.model ? { model: resolved.model } : {}),
67
136
  ...(resolved.error ? { modelConfigurationError: resolved.error } : {}),
@@ -75,5 +144,19 @@ export function resolveRarebitSettings(
75
144
  : { maxRarebitRatio: Number(rarebit.max_rarebit_ratio) }),
76
145
  },
77
146
  autoTitle: rarebit.auto_title !== false,
147
+ ...(summaryPrompt === undefined ? {} : { summaryPrompt }),
148
+ maxInputTokens,
149
+ diagnostics: {
150
+ summaryTriggered: diagnostics.summary_triggered === true,
151
+ summaryUpdated: diagnostics.summary_updated === true,
152
+ },
153
+ recap: {
154
+ enabled: recapEnabled !== false,
155
+ delayMs:
156
+ Number.isSafeInteger(recapDelay) && recapDelay >= 0
157
+ ? recapDelay
158
+ : DEFAULT_RAREBIT_RECAP_DELAY_MS,
159
+ timezone: normalizeRarebitRecapTimezone(recap.timezone),
160
+ },
78
161
  };
79
162
  }