@xynogen/pix-runtime 0.5.1 → 0.5.3

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
@@ -58,8 +58,9 @@ to calculate the trigger; the default is `60` and `0` disables the self-trigger
58
58
  `compaction.minimumTokens` is the absolute floor for that calculation. The
59
59
  effective threshold is `max(contextWindow × triggerPercent, minimumTokens)`, so
60
60
  a 300K-context model at 10% waits for 100K tokens instead of compacting at 30K.
61
- The default floor is 100K; `/pix` offers 100K, 150K, 200K, 300K, 400K, 500K, and
62
- 600K. pix-core consumes both settings.
61
+ The default floor is 100K and the hard minimum is 25K (values below clamp up);
62
+ `/pix` offers 25K, 50K, 100K, 150K, 200K, 300K, 400K, 600K, 800K, and 1M.
63
+ pix-core consumes both settings.
63
64
 
64
65
  Collapse policy helpers:
65
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",
@@ -37,6 +37,23 @@ function row<T>(r: SettingRow<T>): SettingRow<unknown> {
37
37
  return r as SettingRow<unknown>;
38
38
  }
39
39
 
40
+ /** Render a token count as a compact label: 1_000_000 → "1M", 150_000 → "150k". */
41
+ function formatTokens(tokens: number): string {
42
+ return tokens % 1_000_000 === 0 ? `${tokens / 1_000_000}M` : `${tokens / 1000}k`;
43
+ }
44
+
45
+ /** Parse a compact token label ("1M", "150k") back to an absolute count. */
46
+ function parseTokens(label: string): number {
47
+ const raw = label.trim();
48
+ const m = raw.match(/^([0-9]*\.?[0-9]+)\s*([km])?$/i);
49
+ if (!m) return Number.NaN;
50
+ const numStr = m[1] ?? "";
51
+ const n = Number.parseFloat(numStr);
52
+ if (!Number.isFinite(n)) return Number.NaN;
53
+ const suf = (m[2] ?? "").toLowerCase();
54
+ return suf === "m" ? Math.round(n * 1_000_000) : Math.round(n * 1000);
55
+ }
56
+
40
57
  const SETTINGS: SettingRow<unknown>[] = [
41
58
  row({
42
59
  section: "Pretty",
@@ -82,7 +99,8 @@ const SETTINGS: SettingRow<unknown>[] = [
82
99
  section: "Compaction",
83
100
  label: "Trigger (% ctx)",
84
101
  handle: compactionSection,
85
- values: ["0", "5", "10", "15", "20", "25", "30", "40", "50", "60", "70", "80", "90"],
102
+ // 0–100 in 5% steps.
103
+ values: Array.from({ length: 21 }, (_, i) => String(i * 5)),
86
104
  read: (v) => String(v.triggerPercent),
87
105
  patch: (value) => ({ triggerPercent: Number(value) }),
88
106
  }),
@@ -90,9 +108,9 @@ const SETTINGS: SettingRow<unknown>[] = [
90
108
  section: "Compaction",
91
109
  label: "Minimum tokens",
92
110
  handle: compactionSection,
93
- values: ["100k", "150k", "200k", "300k", "400k", "500k", "600k"],
94
- read: (v) => `${v.minimumTokens / 1000}k`,
95
- patch: (value) => ({ minimumTokens: Number.parseInt(value, 10) * 1000 }),
111
+ values: ["25k", "50k", "100k", "150k", "200k", "300k", "400k", "600k", "800k", "1M"],
112
+ read: (v) => formatTokens(v.minimumTokens),
113
+ patch: (value) => ({ minimumTokens: parseTokens(value) }),
96
114
  }),
97
115
  row({
98
116
  section: "Gate",
@@ -114,7 +132,7 @@ function buildSummary(runtime: PixRuntime): string {
114
132
  lastSection = row.section;
115
133
  }
116
134
  const value = row.read(runtime.get(row.handle));
117
- const isDefault = value === row.values[0];
135
+ const isDefault = value === row.read(row.handle.defaults);
118
136
  lines.push(` ${row.label}: ${value}${isDefault ? "" : " *"}`);
119
137
  }
120
138
  return lines.join("\n");
@@ -124,6 +142,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
124
142
  pi.registerCommand("pix", {
125
143
  description: "pix: open shared settings (edit pix.json)",
126
144
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
145
+ // Re-parse pix.json so section parsers remain the single validation source.
146
+ await runtime.reload({ origin: "command", source: "pix" });
127
147
  const ui = ctx.ui as unknown as {
128
148
  custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
129
149
  notify(m: string, t?: "info" | "warning" | "error"): void;
@@ -158,11 +178,29 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
158
178
  const cycle = (direction: -1 | 1) => {
159
179
  const row = SETTINGS[selected];
160
180
  if (!row) return;
161
- const cur = row.values.indexOf(row.read(runtime.get(row.handle)));
162
- const next = (cur + direction + row.values.length) % row.values.length;
163
- const val = row.values[next];
164
- if (val === undefined) return;
165
- void runtime.update(row.handle, row.patch(val), { origin: "command", source: "pix" });
181
+ // Functional updater: the runtime queue serializes writes, and the
182
+ // callback sees the latest committed value — so rapid presses each
183
+ // advance exactly one step instead of collapsing on a stale snapshot.
184
+ const step = (current: unknown): unknown => {
185
+ const curVal = row.read(current);
186
+ let cur = row.values.indexOf(curVal);
187
+ // ponytail: unsupported custom values snap to section default on first change.
188
+ if (cur === -1) {
189
+ const def = row.read(row.handle.defaults);
190
+ const defIdx = row.values.indexOf(def);
191
+ cur = defIdx !== -1 ? defIdx - direction : -1;
192
+ }
193
+ const next = (cur + direction + row.values.length) % row.values.length;
194
+ const val = row.values[next];
195
+ if (val === undefined) return current;
196
+ return { ...(current as object), ...(row.patch(val) as object) };
197
+ };
198
+ // Re-render AFTER the commit lands; the synchronous render in
199
+ // handleInput fires before the queued write and shows a stale value.
200
+ void runtime
201
+ .update(row.handle, step, { origin: "command", source: "pix" })
202
+ .then(() => tui.requestRender())
203
+ .catch(() => ui.notify(`pix: failed to update ${row.label}`, "error"));
166
204
  };
167
205
  const move = (direction: -1 | 1) => {
168
206
  selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
@@ -176,7 +214,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
176
214
  const settingBodyLines: number[] = [];
177
215
  let lastSection = "";
178
216
  for (let i = 0; i < SETTINGS.length; i++) {
179
- const row = SETTINGS[i]!;
217
+ const row = SETTINGS[i];
218
+ if (!row) continue;
180
219
  if (row.section !== lastSection) {
181
220
  if (lastSection) body.push("");
182
221
  body.push(theme.fg("dim", ` ${row.section}`));
@@ -186,7 +225,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
186
225
  const cursor = sel ? theme.fg("accent", "→") : " ";
187
226
  const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
188
227
  const value = row.read(runtime.get(row.handle));
189
- const isDefault = value === row.values[0];
228
+ const isDefault = value === row.read(row.handle.defaults);
190
229
  settingBodyLines[i] = body.length;
191
230
  body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
192
231
  }
@@ -230,20 +269,15 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
230
269
  // matchesKey handles both legacy bytes and Kitty CSI-u encodings
231
270
  // for letters and special keys alike — raw string compares like
232
271
  // `data === "k"` silently fail under the Kitty keyboard protocol.
233
- if (matchesKey(data, "k") || matchesKey(data, "up")) move(-1);
234
- else if (matchesKey(data, "j") || matchesKey(data, "down")) move(1);
235
- else if (matchesKey(data, "h") || matchesKey(data, "left")) cycle(-1);
236
- else if (
237
- matchesKey(data, "l") ||
238
- matchesKey(data, "right") ||
239
- matchesKey(data, "space") ||
240
- matchesKey(data, "enter")
241
- )
242
- cycle(1);
243
- else if (matchesKey(data, "escape") || matchesKey(data, "q")) {
272
+ if (kb.matches(data, "tui.select.cancel")) {
244
273
  done(null);
245
274
  return;
246
- } else return;
275
+ }
276
+ if (kb.matches(data, "tui.select.up")) move(-1);
277
+ else if (kb.matches(data, "tui.select.down")) move(1);
278
+ else if (matchesKey(data, Key.left)) cycle(-1);
279
+ else if (matchesKey(data, Key.right)) cycle(1);
280
+ else return;
247
281
  tui.requestRender();
248
282
  },
249
283
  };
@@ -346,12 +380,13 @@ function frameRows(
346
380
  const dashes = "─".repeat(width - 2);
347
381
  const SENTINEL = "\x00";
348
382
  const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
349
- const reassert = (s: string): string =>
350
- bgOpen
351
- ? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
352
- p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
353
- )
354
- : s;
383
+ const reassert = (s: string): string => {
384
+ if (!bgOpen) return s;
385
+ return s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) => {
386
+ if (p === "0" || p.split(";").includes("49")) return `${seq}${bgOpen}`;
387
+ return seq;
388
+ });
389
+ };
355
390
  const row = (content: string): string => {
356
391
  const fitted = truncateToWidth(content, inner, "…");
357
392
  const padded = fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted)));
package/src/runtime.ts CHANGED
@@ -347,12 +347,24 @@ class RuntimeImpl implements PixRuntime {
347
347
  const values = new Map<string, unknown>();
348
348
  for (const s of this.registry.all()) values.set(s.key, s.parse(base[s.key], ctx));
349
349
 
350
- const currentValue = values.get(section.key) as Readonly<T>;
351
- const nextValue =
350
+ // Guard against a version-skewed second runtime copy whose registry
351
+ // lacks this section (e.g. an old npm copy handling a new handle). The
352
+ // functional updater would otherwise crash the whole process on
353
+ // `undefined`. Fall back to the handle's own parse of the raw doc.
354
+ const currentValue = (
355
+ values.has(section.key)
356
+ ? values.get(section.key)
357
+ : section.__section.parse(base[section.key], ctx)
358
+ ) as Readonly<T>;
359
+ const mergedValue =
352
360
  typeof updater === "function"
353
361
  ? (updater as (c: Readonly<T>) => T)(currentValue)
354
362
  : deepMerge(currentValue as T, updater);
355
363
 
364
+ // Re-validate through the section's own parse so a bad patch (NaN,
365
+ // Infinity, wrong type) can never enter the live snapshot.
366
+ const nextValue = section.__section.parse(mergedValue, ctx) as T;
367
+
356
368
  if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
357
369
  values.set(section.key, nextValue);
358
370
 
@@ -14,11 +14,12 @@ export interface CompactionConfig {
14
14
  minimumTokens: number;
15
15
  }
16
16
 
17
- const MINIMUM_TOKEN_FLOOR = 100_000;
17
+ const MINIMUM_TOKEN_FLOOR = 25_000;
18
+ const MINIMUM_TOKEN_DEFAULT = 100_000;
18
19
 
19
20
  const DEFAULTS: Readonly<CompactionConfig> = {
20
21
  triggerPercent: 60,
21
- minimumTokens: MINIMUM_TOKEN_FLOOR,
22
+ minimumTokens: MINIMUM_TOKEN_DEFAULT,
22
23
  };
23
24
 
24
25
  /** Clamp to [0, 100]; non-numbers fall back. */
@@ -27,20 +28,38 @@ function pctOr(v: unknown, fallback: number): number {
27
28
  return Math.min(100, Math.max(0, v));
28
29
  }
29
30
 
30
- /** Require a finite token floor of at least 100k. */
31
+ /** Require a finite token floor of at least 25k. */
31
32
  function minimumTokensOr(v: unknown, fallback: number): number {
32
33
  if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
34
+ // Strings from older/hand-edited pix.json (e.g. "150k") → parse before clamping.
33
35
  return Math.max(MINIMUM_TOKEN_FLOOR, Math.round(v));
34
36
  }
35
37
 
38
+ function parseTokenString(label: string): number | undefined {
39
+ const raw = label.trim();
40
+ if (!raw) return undefined;
41
+ const m = raw.match(/^([0-9]*\.?[0-9]+)\s*([km])?$/i);
42
+ if (!m) return undefined;
43
+ const numStr = m[1] ?? "";
44
+ const n = Number.parseFloat(numStr);
45
+ if (!Number.isFinite(n)) return undefined;
46
+ const suf = (m[2] ?? "").toLowerCase();
47
+ if (suf === "m") return Math.round(n * 1_000_000);
48
+ if (suf === "k") return Math.round(n * 1000);
49
+ return Math.round(n);
50
+ }
51
+
36
52
  export const compactionSection = defineSection<"compaction", CompactionConfig>({
37
53
  key: "compaction",
38
54
  defaults: DEFAULTS,
39
55
  parse(raw) {
40
56
  if (!isObj(raw)) return { ...DEFAULTS };
57
+ const rawMin = raw.minimumTokens;
58
+ const coercedMin =
59
+ typeof rawMin === "string" ? (parseTokenString(rawMin) ?? Number.NaN) : rawMin;
41
60
  return {
42
61
  triggerPercent: pctOr(raw.triggerPercent, DEFAULTS.triggerPercent),
43
- minimumTokens: minimumTokensOr(raw.minimumTokens, DEFAULTS.minimumTokens),
62
+ minimumTokens: minimumTokensOr(coercedMin, DEFAULTS.minimumTokens),
44
63
  };
45
64
  },
46
65
  });