@xynogen/pix-runtime 0.12.2 → 0.13.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 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.1",
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,18 @@ 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 === "wrap" ? "wrap line" : part,
192
+ handle: prettySection,
193
+ values: part === "wrap" ? ["on", "off"] : ["show", "hide"],
194
+ read: (v) =>
195
+ part === "wrap" ? (v.footer[part] ? "on" : "off") : v.footer[part] ? "show" : "hide",
196
+ patch: (value) => ({ footer: { [part]: value === "show" || value === "on" } }),
197
+ });
198
+ }),
187
199
  ];
188
200
 
189
201
  /** Env whose agent dir is the folder holding pix.json (binary.json lives beside it). */
@@ -191,7 +203,7 @@ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
191
203
  return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
192
204
  }
193
205
 
194
- const TABS = ["Settings", "Binaries"] as const;
206
+ const TABS = ["Settings", "Binaries", "Footer"] as const;
195
207
 
196
208
  function buildSummary(runtime: PixRuntime): string {
197
209
  const lines = [`Pix Settings (${runtime.path})`, ""];
@@ -270,13 +282,16 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
270
282
  const switchTab = (direction: -1 | 1) => {
271
283
  const next = (TABS.indexOf(tab) + direction + TABS.length) % TABS.length;
272
284
  tab = TABS[next] ?? "Settings";
285
+ selected = 0;
273
286
  bodyOffset = 0;
274
287
  inspectingPage = false;
275
288
  if (tab === "Binaries") binariesTab().refresh();
276
289
  };
277
290
 
291
+ const rows = () =>
292
+ SETTINGS.filter((r) => (r.section === "Footer") === (tab === "Footer"));
278
293
  const cycle = (direction: -1 | 1) => {
279
- const row = SETTINGS[selected];
294
+ const row = rows()[selected];
280
295
  if (!row) return;
281
296
  // Functional updater: the runtime queue serializes writes, and the
282
297
  // callback sees the latest committed value — so rapid presses each
@@ -296,7 +311,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
296
311
  const next = (cur + direction + row.values.length) % row.values.length;
297
312
  const val = row.values[next];
298
313
  if (val === undefined) return state;
299
- return { ...state, ...(row.patch(val) as object) };
314
+ return deepMerge(state, row.patch(val) as DeepPartial<SettingsState>);
300
315
  };
301
316
  // Re-render AFTER the commit lands; the synchronous render in
302
317
  // handleInput fires before the queued write and shows a stale value.
@@ -306,7 +321,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
306
321
  .catch(() => ui.notify(`pix: failed to update ${row.label}`, "error"));
307
322
  };
308
323
  const move = (direction: -1 | 1) => {
309
- selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
324
+ selected = (selected + direction + rows().length) % rows().length;
310
325
  inspectingPage = false;
311
326
  };
312
327
 
@@ -346,12 +361,13 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
346
361
  maxBodyOffset = result.maxBodyOffset;
347
362
  return result.lines;
348
363
  }
349
- const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
364
+ const settings = rows();
365
+ const labelW = Math.max(...settings.map((r) => r.label.length));
350
366
  const body: string[] = [];
351
367
  const settingBodyLines: number[] = [];
352
368
  let lastSection = "";
353
- for (let i = 0; i < SETTINGS.length; i++) {
354
- const row = SETTINGS[i];
369
+ for (let i = 0; i < settings.length; i++) {
370
+ const row = settings[i];
355
371
  if (!row) continue;
356
372
  if (row.section !== lastSection) {
357
373
  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,22 @@ 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
+ | "wrap",
36
+ boolean
37
+ >;
22
38
  }
23
39
 
24
40
  const ICON_MODES: readonly IconMode[] = ["nerd", "unicode", "ascii"];
@@ -43,15 +59,36 @@ const DEFAULTS: Readonly<PrettyConfig> = {
43
59
  maxHighlightChars: 80_000,
44
60
  cacheLimit: 128,
45
61
  diff: { splitMinWidth: 150, splitMinCodeWidth: 60 },
62
+ footer: {
63
+ mode: true,
64
+ cwd: true,
65
+ git: true,
66
+ context: true,
67
+ model: true,
68
+ thinking: true,
69
+ price: true,
70
+ score: true,
71
+ statuses: true,
72
+ tokens: true,
73
+ cost: true,
74
+ tps: true,
75
+ wrap: false,
76
+ },
46
77
  };
47
78
 
48
79
  export const prettySection = defineSection<"pretty", PrettyConfig>({
49
80
  key: "pretty",
50
81
  defaults: DEFAULTS,
51
82
  parse(raw) {
52
- if (!isObj(raw)) return { ...DEFAULTS, diff: { ...DEFAULTS.diff } };
83
+ if (!isObj(raw)) return structuredClone(DEFAULTS);
53
84
  const rawDiff = isObj(raw.diff) ? raw.diff : {};
85
+ const rawFooter = isObj(raw.footer) ? raw.footer : {};
86
+ const footer = { ...DEFAULTS.footer };
87
+ for (const key of Object.keys(footer) as (keyof typeof footer)[]) {
88
+ footer[key] = boolOr(rawFooter[key], DEFAULTS.footer[key]);
89
+ }
54
90
  return {
91
+ footer,
55
92
  icons: enumOr(raw.icons, ICON_MODES, DEFAULTS.icons),
56
93
  lsStyle: enumOr(raw.lsStyle, LS_STYLES, DEFAULTS.lsStyle),
57
94
  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
+ });