@xynogen/pix-runtime 0.12.2 → 0.13.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/README.md CHANGED
@@ -10,7 +10,9 @@ See `DESIGN.md` for the full contract.
10
10
  ## What it does
11
11
 
12
12
  - Versioned, sparse `pix.json` (`$version: 1`) — defaults resolve in code.
13
- - Typed sections: `collapse`, `pretty`, `io`, `compaction`, `optimizer`, `gate`.
13
+ - Typed sections: `collapse`, `pretty`, `io`, `compaction`, `optimizer`, `gate`, `fetch`, `search`, `voice`, `toolbox`.
14
+ - `/pix` has a Footer tab for `pretty.footer` visibility.
15
+ - Startup imports legacy service config files and archives them only after a successful save.
14
16
  - Atomic writes behind a serialized in-process queue and a short-lived
15
17
  cross-process lock. A failed write leaves the old file intact.
16
18
  - Immutable, deeply frozen config snapshots with a monotonic revision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -28,6 +28,7 @@
28
28
  "./collapse": "./src/collapse.ts",
29
29
  "./icon-catalog": "./src/icon-catalog.ts",
30
30
  "./io": "./src/io.ts",
31
+ "./hashline": "./src/hashline.ts",
31
32
  "./lfid": "./src/lfid.ts",
32
33
  "./once": "./src/once.ts",
33
34
  "./paths": "./src/paths.ts",
@@ -63,5 +64,8 @@
63
64
  "peerDependencies": {
64
65
  "@earendil-works/pi-coding-agent": "*",
65
66
  "@earendil-works/pi-tui": "*"
67
+ },
68
+ "dependencies": {
69
+ "xxhash-wasm": "1.1.0"
66
70
  }
67
71
  }
@@ -0,0 +1,11 @@
1
+ import assert from "node:assert/strict";
2
+ import xxhash from "xxhash-wasm";
3
+
4
+ const { h32, h32Raw } = await xxhash();
5
+ // Official xxHash v0.8.3 tests/sanity_test_vectors.h, empty-input vectors.
6
+ assert.equal(h32("", 0), 0x02cc5d05);
7
+ assert.equal(h32("", 0x9e3779b1), 0x36b78ae7);
8
+ for (const text of ["", "hello", "中文🙂", " a ", "\tconst x = 1; "]) {
9
+ assert.equal(h32(text, 0), h32Raw(new TextEncoder().encode(text), 0));
10
+ console.log(`${(h32(text, 0) & 0xfff).toString(16).toUpperCase().padStart(3, "0")}|${text}`);
11
+ }
@@ -0,0 +1,71 @@
1
+ import xxhash from "xxhash-wasm";
2
+
3
+ // Pi reevaluates shared modules with Jiti. Both tools must share one WASM instance.
4
+ const global = globalThis as typeof globalThis & {
5
+ __pixHashline?: {
6
+ initialization?: Promise<void>;
7
+ h32?: (input: string, seed?: number) => number;
8
+ };
9
+ };
10
+ const state = global.__pixHashline ?? {};
11
+ global.__pixHashline = state;
12
+
13
+ export function initHashline(): Promise<void> {
14
+ state.initialization ??= xxhash()
15
+ .then((hash) => {
16
+ state.h32 = hash.h32;
17
+ })
18
+ .catch((error) => {
19
+ throw new Error("Hashline WASM initialization failed", { cause: error });
20
+ });
21
+ return state.initialization;
22
+ }
23
+
24
+ export function hashLine(text: string): string {
25
+ if (!state.h32) throw new Error("Hashline WASM is not initialized");
26
+ return (state.h32(text, 0) & 0xfff).toString(16).toUpperCase().padStart(3, "0");
27
+ }
28
+
29
+ export function anchor(line: number, text: string): string {
30
+ return `${line}#${hashLine(text)}`;
31
+ }
32
+
33
+ export function parseAnchor(value: unknown): { line: number; hash: string } {
34
+ const match = typeof value === "string" ? /^([1-9]\d*)#([0-9A-F]{3})$/.exec(value) : null;
35
+ if (!match || !Number.isSafeInteger(Number(match[1])))
36
+ throw new Error(`Invalid anchor: ${String(value)}`);
37
+ return { line: Number(match[1]), hash: match[2]! };
38
+ }
39
+
40
+ export type SourceLine = { text: string; ending: string };
41
+
42
+ export function decodeLines(bytes: Uint8Array): {
43
+ bom: string;
44
+ lines: SourceLine[];
45
+ delimiter: string;
46
+ finalNewline: boolean;
47
+ } {
48
+ const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
49
+ if (decoded.includes("\0")) throw new Error("Unsupported binary text: NUL byte");
50
+ const bom = decoded.startsWith("\uFEFF") ? "\uFEFF" : "";
51
+ const text = decoded.slice(bom.length);
52
+ const lines: SourceLine[] = [];
53
+ const counts = new Map<string, number>();
54
+ let start = 0;
55
+ for (const match of text.matchAll(/\r\n|\r|\n/g)) {
56
+ const ending = match[0];
57
+ lines.push({ text: text.slice(start, match.index), ending });
58
+ counts.set(ending, (counts.get(ending) ?? 0) + 1);
59
+ start = match.index + ending.length;
60
+ }
61
+ if (start < text.length) lines.push({ text: text.slice(start), ending: "" });
62
+ let delimiter = "\n";
63
+ let highest = 0;
64
+ for (const [ending, count] of counts) {
65
+ if (count > highest) {
66
+ delimiter = ending;
67
+ highest = count;
68
+ }
69
+ }
70
+ return { bom, lines, delimiter, finalNewline: lines.length > 0 && lines.at(-1)!.ending !== "" };
71
+ }
package/src/migrations.ts CHANGED
@@ -106,7 +106,31 @@ export function importOptimizerSidecar(
106
106
  agentDir: string,
107
107
  ctx: ParseContext,
108
108
  ): { changed: boolean; archive?: () => void } {
109
- const sidecarPath = join(agentDir, "optimizer.json");
109
+ const imports = ["optimizer", "voice", "fetch", "search", "toolbox"];
110
+ let changed = false;
111
+ const archives: (() => void)[] = [];
112
+ for (const section of imports) {
113
+ const result = importSidecar(doc, agentDir, ctx, section);
114
+ changed ||= result.changed;
115
+ if (result.archive) archives.push(result.archive);
116
+ }
117
+ return {
118
+ changed,
119
+ archive: archives.length
120
+ ? () => {
121
+ for (const archive of archives) archive();
122
+ }
123
+ : undefined,
124
+ };
125
+ }
126
+
127
+ function importSidecar(
128
+ doc: RawDocument,
129
+ agentDir: string,
130
+ ctx: ParseContext,
131
+ section: string,
132
+ ): { changed: boolean; archive?: () => void } {
133
+ const sidecarPath = join(agentDir, `${section}.json`);
110
134
  if (!existsSync(sidecarPath)) return { changed: false };
111
135
 
112
136
  let raw: Record<string, unknown>;
@@ -118,32 +142,39 @@ export function importOptimizerSidecar(
118
142
  ctx.diagnostic({
119
143
  code: "MIGRATION_FAILED",
120
144
  severity: "warning",
121
- path: "optimizer.json",
122
- message: "malformed optimizer sidecar left untouched",
145
+ path: `${section}.json`,
146
+ message: `malformed ${section} config left unchanged`,
123
147
  cause: err,
124
148
  });
125
149
  return { changed: false };
126
150
  }
127
151
 
128
- const existing = isObj(doc.optimizer) ? { ...doc.optimizer } : {};
152
+ const existing = isObj(doc[section]) ? { ...doc[section] } : {};
129
153
  let changed = false;
130
- for (const key of OPTIMIZER_KEYS) {
154
+ for (const key of section === "optimizer" ? OPTIMIZER_KEYS : Object.keys(raw)) {
131
155
  const value = raw[key];
132
- if (typeof value === "string" && !(key in existing)) {
156
+ if ((section !== "optimizer" || typeof value === "string") && !(key in existing)) {
133
157
  existing[key] = value;
134
158
  changed = true;
135
159
  }
136
160
  }
137
- if (changed) doc.optimizer = existing;
161
+ if (changed) doc[section] = existing;
138
162
 
139
- const hasLegacyToon = typeof raw.toon === "string";
163
+ const hasLegacyToon = section === "optimizer" && typeof raw.toon === "string";
140
164
  const archive = () => {
141
165
  let target = `${sidecarPath}.migrated-v1`;
142
166
  if (existsSync(target)) target = `${target}.${Date.now()}`;
143
167
  try {
144
168
  renameSync(sidecarPath, target);
145
- } catch {
146
- // A competing process may have moved it — ENOENT is benign.
169
+ } catch (err) {
170
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT")
171
+ ctx.diagnostic({
172
+ code: "MIGRATION_FAILED",
173
+ severity: "warning",
174
+ path: sidecarPath,
175
+ message: "config backup failed; original file remains",
176
+ cause: err,
177
+ });
147
178
  }
148
179
  };
149
180
 
@@ -19,7 +19,7 @@ import {
19
19
  import { createBinariesTab } from "./binaries-tab.ts";
20
20
  import { icon } from "./icon-catalog.ts";
21
21
  import type { PixRuntime } from "./runtime.ts";
22
- import type { DeepPartial, SectionHandle } from "./schema.ts";
22
+ import { type DeepPartial, deepMerge, type SectionHandle } from "./schema.ts";
23
23
  import { collapseSection } from "./sections/collapse.ts";
24
24
  import { compactionSection } from "./sections/compaction.ts";
25
25
  import { gateSection } from "./sections/gate.ts";
@@ -184,6 +184,17 @@ const SETTINGS: SettingRow<unknown>[] = [
184
184
  read: (v) => v.guardrails,
185
185
  patch: (guardrails) => ({ guardrails: guardrails as "on" | "off" }),
186
186
  }),
187
+ ...Object.keys(prettySection.defaults.footer).map((key) => {
188
+ const part = key as keyof typeof prettySection.defaults.footer;
189
+ return row({
190
+ section: "Footer",
191
+ label: part,
192
+ handle: prettySection,
193
+ values: ["show", "hide"],
194
+ read: (v) => (v.footer[part] ? "show" : "hide"),
195
+ patch: (value) => ({ footer: { [part]: value === "show" } }),
196
+ });
197
+ }),
187
198
  ];
188
199
 
189
200
  /** Env whose agent dir is the folder holding pix.json (binary.json lives beside it). */
@@ -191,7 +202,7 @@ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
191
202
  return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
192
203
  }
193
204
 
194
- const TABS = ["Settings", "Binaries"] as const;
205
+ const TABS = ["Settings", "Binaries", "Footer"] as const;
195
206
 
196
207
  function buildSummary(runtime: PixRuntime): string {
197
208
  const lines = [`Pix Settings (${runtime.path})`, ""];
@@ -270,13 +281,16 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
270
281
  const switchTab = (direction: -1 | 1) => {
271
282
  const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
272
283
  tab = TABS[next] ?? "Settings";
284
+ selected = 0;
273
285
  bodyOffset = 0;
274
286
  inspectingPage = false;
275
287
  if (tab === "Binaries") binariesTab().refresh();
276
288
  };
277
289
 
290
+ const rows = () =>
291
+ SETTINGS.filter((r) => (r.section === "Footer") === (tab === "Footer"));
278
292
  const cycle = (direction: -1 | 1) => {
279
- const row = SETTINGS[selected];
293
+ const row = rows()[selected];
280
294
  if (!row) return;
281
295
  // Functional updater: the runtime queue serializes writes, and the
282
296
  // callback sees the latest committed value — so rapid presses each
@@ -296,7 +310,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
296
310
  const next = (cur + direction + row.values.length) % row.values.length;
297
311
  const val = row.values[next];
298
312
  if (val === undefined) return state;
299
- return { ...state, ...(row.patch(val) as object) };
313
+ return deepMerge(state, row.patch(val) as DeepPartial<SettingsState>);
300
314
  };
301
315
  // Re-render AFTER the commit lands; the synchronous render in
302
316
  // handleInput fires before the queued write and shows a stale value.
@@ -306,7 +320,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
306
320
  .catch(() => ui.notify(`pix: failed to update ${row.label}`, "error"));
307
321
  };
308
322
  const move = (direction: -1 | 1) => {
309
- selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
323
+ selected = (selected + direction + rows().length) % rows().length;
310
324
  inspectingPage = false;
311
325
  };
312
326
 
@@ -346,12 +360,13 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
346
360
  maxBodyOffset = result.maxBodyOffset;
347
361
  return result.lines;
348
362
  }
349
- const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
363
+ const settings = rows();
364
+ const labelW = Math.max(...settings.map((r) => r.label.length));
350
365
  const body: string[] = [];
351
366
  const settingBodyLines: number[] = [];
352
367
  let lastSection = "";
353
- for (let i = 0; i < SETTINGS.length; i++) {
354
- const row = SETTINGS[i];
368
+ for (let i = 0; i < settings.length; i++) {
369
+ const row = settings[i];
355
370
  if (!row) continue;
356
371
  if (row.section !== lastSection) {
357
372
  if (lastSection) body.push("");
@@ -30,6 +30,18 @@ import { ioSection } from "./io.ts";
30
30
  import { optimizerSection } from "./optimizer.ts";
31
31
  import { prettySection } from "./pretty.ts";
32
32
 
33
+ export {
34
+ fetchSection,
35
+ searchSection,
36
+ type ToolboxConfig,
37
+ toolboxSection,
38
+ type VoiceConfig,
39
+ voiceSection,
40
+ type WebConfig,
41
+ } from "./services.ts";
42
+
43
+ import { fetchSection, searchSection, toolboxSection, voiceSection } from "./services.ts";
44
+
33
45
  /** All built-in sections, in stable registration order. */
34
46
  export const builtinSections = [
35
47
  collapseSection,
@@ -38,4 +50,8 @@ export const builtinSections = [
38
50
  optimizerSection,
39
51
  gateSection,
40
52
  compactionSection,
53
+ fetchSection,
54
+ searchSection,
55
+ voiceSection,
56
+ toolboxSection,
41
57
  ] as const;
@@ -1,4 +1,4 @@
1
- import { defineSection, enumOr, isObj, posNumOr } from "../schema.ts";
1
+ import { boolOr, defineSection, enumOr, isObj, posNumOr } from "../schema.ts";
2
2
 
3
3
  export type IconMode = "nerd" | "unicode" | "ascii";
4
4
  export type LsStyle = "grid" | "tree";
@@ -19,6 +19,21 @@ export interface PrettyConfig {
19
19
  maxHighlightChars: number;
20
20
  cacheLimit: number;
21
21
  diff: DiffConfig;
22
+ footer: Record<
23
+ | "mode"
24
+ | "cwd"
25
+ | "git"
26
+ | "context"
27
+ | "model"
28
+ | "thinking"
29
+ | "price"
30
+ | "score"
31
+ | "statuses"
32
+ | "tokens"
33
+ | "cost"
34
+ | "tps",
35
+ boolean
36
+ >;
22
37
  }
23
38
 
24
39
  const ICON_MODES: readonly IconMode[] = ["nerd", "unicode", "ascii"];
@@ -43,15 +58,35 @@ const DEFAULTS: Readonly<PrettyConfig> = {
43
58
  maxHighlightChars: 80_000,
44
59
  cacheLimit: 128,
45
60
  diff: { splitMinWidth: 150, splitMinCodeWidth: 60 },
61
+ footer: {
62
+ mode: true,
63
+ cwd: true,
64
+ git: true,
65
+ context: true,
66
+ model: true,
67
+ thinking: true,
68
+ price: true,
69
+ score: true,
70
+ statuses: true,
71
+ tokens: true,
72
+ cost: true,
73
+ tps: true,
74
+ },
46
75
  };
47
76
 
48
77
  export const prettySection = defineSection<"pretty", PrettyConfig>({
49
78
  key: "pretty",
50
79
  defaults: DEFAULTS,
51
80
  parse(raw) {
52
- if (!isObj(raw)) return { ...DEFAULTS, diff: { ...DEFAULTS.diff } };
81
+ if (!isObj(raw)) return structuredClone(DEFAULTS);
53
82
  const rawDiff = isObj(raw.diff) ? raw.diff : {};
83
+ const rawFooter = isObj(raw.footer) ? raw.footer : {};
84
+ const footer = { ...DEFAULTS.footer };
85
+ for (const key of Object.keys(footer) as (keyof typeof footer)[]) {
86
+ footer[key] = boolOr(rawFooter[key], DEFAULTS.footer[key]);
87
+ }
54
88
  return {
89
+ footer,
55
90
  icons: enumOr(raw.icons, ICON_MODES, DEFAULTS.icons),
56
91
  lsStyle: enumOr(raw.lsStyle, LS_STYLES, DEFAULTS.lsStyle),
57
92
  maxRenderWidth: renderSizeOr(raw.maxRenderWidth, DEFAULTS.maxRenderWidth),
@@ -0,0 +1,71 @@
1
+ import { boolOr, defineSection, isObj, strArr } from "../schema.ts";
2
+
3
+ export interface WebConfig {
4
+ provider: string;
5
+ nineRouterModel: string;
6
+ }
7
+
8
+ const webDefaults: WebConfig = { provider: "auto", nineRouterModel: "exa" };
9
+ function parseWeb(raw: unknown): WebConfig {
10
+ const item = isObj(raw) ? raw : {};
11
+ return {
12
+ provider: typeof item.provider === "string" ? item.provider : webDefaults.provider,
13
+ nineRouterModel:
14
+ typeof item.nineRouterModel === "string" ? item.nineRouterModel : webDefaults.nineRouterModel,
15
+ };
16
+ }
17
+ export const fetchSection = defineSection({ key: "fetch", defaults: webDefaults, parse: parseWeb });
18
+ export const searchSection = defineSection({
19
+ key: "search",
20
+ defaults: webDefaults,
21
+ parse: parseWeb,
22
+ });
23
+
24
+ const voiceDefaults = {
25
+ sttProvider: "auto",
26
+ ttsProvider: "auto",
27
+ sttNineRouterModel: "dg/nova-3",
28
+ ttsNineRouterModel: "edge-tts/en-US-AriaNeural",
29
+ ttsPlay: true,
30
+ sttDevice: "default",
31
+ sttLanguage: "auto",
32
+ sttShortcut: "ctrl+alt+z",
33
+ sttCleanup: "off",
34
+ };
35
+ export type VoiceConfig = typeof voiceDefaults;
36
+ export const voiceSection = defineSection({
37
+ key: "voice",
38
+ defaults: voiceDefaults,
39
+ parse(raw): VoiceConfig {
40
+ const item = isObj(raw) ? raw : {};
41
+ const result = { ...voiceDefaults };
42
+ for (const key of Object.keys(result) as (keyof VoiceConfig)[]) {
43
+ if (key === "ttsPlay") result.ttsPlay = boolOr(item.ttsPlay, true);
44
+ else if (typeof item[key] === "string" && item[key].trim()) result[key] = item[key].trim();
45
+ }
46
+ result.sttLanguage = result.sttLanguage.toLowerCase();
47
+ return result;
48
+ },
49
+ });
50
+ export interface ToolboxConfig {
51
+ disabledTools?: string[];
52
+ loadedTools?: string[];
53
+ enabledTools?: string[];
54
+ }
55
+ export const toolboxSection = defineSection<"toolbox", ToolboxConfig>({
56
+ key: "toolbox",
57
+ defaults: {},
58
+ parse(raw) {
59
+ const result: ToolboxConfig = {};
60
+ if (isObj(raw)) {
61
+ for (const key of ["disabledTools", "loadedTools", "enabledTools"] as const) {
62
+ if (
63
+ Array.isArray(raw[key]) &&
64
+ raw[key].every((value: unknown) => typeof value === "string")
65
+ )
66
+ result[key] = strArr(raw[key]);
67
+ }
68
+ }
69
+ return result;
70
+ },
71
+ });