pi-soly 2.5.5 → 2.6.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/index.ts CHANGED
@@ -353,17 +353,7 @@ export default function solyExtension(pi: ExtensionAPI) {
353
353
  d.home = os.homedir();
354
354
  const model = ctx.model as { id?: string; provider?: string; reasoning?: boolean } | undefined;
355
355
  d.modelId = model?.id ?? null;
356
- const prevProvider = d.modelProvider;
357
356
  d.modelProvider = model?.provider ?? null;
358
- // TEMP DEBUG
359
- if (prevProvider !== d.modelProvider) {
360
- try {
361
- const fs = require("node:fs");
362
- const os = require("node:os");
363
- const p = require("node:path");
364
- fs.appendFileSync(p.join(os.tmpdir(), "pi-soly-quota-debug.log"), `[${new Date().toISOString()}] updateChromeData: modelProvider changed ${prevProvider} -> ${d.modelProvider}, modelId=${d.modelId}\n`);
365
- } catch {}
366
- }
367
357
  d.reasoning = Boolean(model?.reasoning);
368
358
  try {
369
359
  d.thinkingLevel = pi.getThinkingLevel();
@@ -562,14 +552,6 @@ export default function solyExtension(pi: ExtensionAPI) {
562
552
  // quotaPercent/quotaResetsLabel back for the footer to render).
563
553
  if (quotaPoller) quotaPoller.stop();
564
554
  quotaPoller = startQuotaPoller(chrome.data, () => getActiveConfig().chrome.enabled, () => chrome.poke());
565
- // TEMP DEBUG
566
- try {
567
- const fs = await import("node:fs");
568
- const os = await import("node:os");
569
- const p = await import("node:path");
570
- const dbgLog = p.join(os.tmpdir(), "pi-soly-quota-debug.log");
571
- fs.appendFileSync(dbgLog, `[${new Date().toISOString()}] session_start: poller started, chrome.enabled=${getActiveConfig().chrome.enabled}, modelProvider=${chrome.data.modelProvider}\n`);
572
- } catch {}
573
555
  // Editors save in bursts (write to .tmp, rename, touch). Coalesce
574
556
  // those rapid reload events into a single sub-line event under the
575
557
  // Working indicator (└─ reloaded 47 rules). Errors here are real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.5.5",
3
+ "version": "2.6.0",
4
4
  "description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/quota/minimax.ts CHANGED
@@ -23,25 +23,19 @@
23
23
  //
24
24
  // We pick the "general" entry — that's the quota bucket MiniMax-M3 (and all
25
25
  // text/chat models) draw from. Video/music have separate buckets.
26
+ //
27
+ // The API returns `current_interval_remaining_percent` (what's left). We
28
+ // invert it to **used** (100 - remaining) to match the MiniMax web dashboard
29
+ // semantics — "used" grows as you spend, which reads more intuitively in a
30
+ // status bar than "remaining" which shrinks.
26
31
  // =============================================================================
27
32
 
28
33
  import { execFile } from "node:child_process";
29
- import * as fs from "node:fs";
30
- import * as os from "node:os";
31
- import * as path from "node:path";
32
34
  import type { QuotaProvider, QuotaSnapshot } from "./types.ts";
33
35
 
34
- /** TEMP DEBUG. */
35
- const DEBUG_LOG = path.join(os.tmpdir(), "pi-soly-quota-debug.log");
36
- function dbg(msg: string): void {
37
- try { fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] [minimax] ${msg}\n`); } catch { /* best effort */ }
38
- }
39
-
40
- /** Detect Windows (where npm-installed CLIs are `.cmd` shims that execFile
41
- * can't spawn directly without a shell). */
42
- const IS_WIN = process.platform === "win32";
43
-
44
- /** Minimal shape we read from `mmx quota show --output json`. */
36
+ // Minimal shape we read from `mmx quota show --output json`. We track
37
+ // `remaining` (what the API calls `current_interval_remaining_percent`) and
38
+ // invert it to `used` for display.
45
39
  type MinimaxQuotaResponse = {
46
40
  model_remains?: Array<{
47
41
  model_name?: string;
@@ -51,6 +45,10 @@ type MinimaxQuotaResponse = {
51
45
  base_resp?: { status_code?: number };
52
46
  };
53
47
 
48
+ /** Detect Windows (where npm-installed CLIs are `.cmd` shims that execFile
49
+ * can't spawn directly without a shell). */
50
+ const IS_WIN = process.platform === "win32";
51
+
54
52
  /** Run mmx and capture stdout. Resolves to null on any failure
55
53
  * (mmx missing, non-zero exit, timeout, bad JSON). Never throws.
56
54
  *
@@ -66,45 +64,31 @@ function runMmx(args: string[], timeoutMs: number): Promise<string | null> {
66
64
  maxBuffer: 1024 * 1024,
67
65
  shell: IS_WIN, // resolve .cmd shims on Windows
68
66
  };
69
- execFile("mmx", args, opts, (err, stdout, stderr) => {
70
- if (err) {
71
- dbg(`runMmx error: ${err instanceof Error ? err.message : String(err)} (code=${(err as { code?: string }).code ?? "?"}) stderr=${(stderr ?? "").slice(0, 200)}`);
72
- resolve(null);
73
- } else {
74
- dbg(`runMmx ok: ${String(stdout).length} bytes`);
75
- resolve(stdout ?? "");
76
- }
67
+ execFile("mmx", args, opts, (err, stdout) => {
68
+ if (err) resolve(null);
69
+ else resolve(stdout ?? "");
77
70
  });
78
71
  });
79
72
  }
80
73
 
81
- /** Parse the mmx quota response, returning the "general" model snapshot. */
74
+ /** Parse the mmx quota response, returning the "general" model snapshot
75
+ * with `remainingPercent` expressed as **used** (100 - remaining). */
82
76
  function parseGeneralQuota(raw: string): QuotaSnapshot | null {
83
77
  let parsed: unknown;
84
78
  try {
85
79
  parsed = JSON.parse(raw);
86
- } catch (e) {
87
- dbg(`parse: JSON.parse failed: ${e instanceof Error ? e.message : String(e)}; raw[0..100]=${raw.slice(0, 100)}`);
80
+ } catch {
88
81
  return null; // not valid JSON — mmx printed an error to stdout
89
82
  }
90
83
  // Cast at the boundary (single documented place, per code-style rules).
91
84
  const data = parsed as MinimaxQuotaResponse;
92
- if (data.base_resp?.status_code !== 0) {
93
- dbg(`parse: status_code=${data.base_resp?.status_code} (expected 0)`);
94
- return null;
95
- }
85
+ if (data.base_resp?.status_code !== 0) return null;
96
86
  const general = data.model_remains?.find((m) => m.model_name === "general");
97
- if (!general) {
98
- dbg(`parse: no 'general' model entry; have: ${(data.model_remains ?? []).map((m) => m.model_name).join(",")}`);
99
- return null;
100
- }
101
- const pct = general.current_interval_remaining_percent;
102
- if (typeof pct !== "number") {
103
- dbg(`parse: pct is ${typeof pct}`);
104
- return null;
105
- }
87
+ if (!general) return null;
88
+ const remaining = general.current_interval_remaining_percent;
89
+ if (typeof remaining !== "number") return null;
106
90
  return {
107
- remainingPercent: pct,
91
+ remainingPercent: Math.max(0, Math.min(100, 100 - remaining)),
108
92
  resetsInMs: typeof general.remains_time === "number" ? general.remains_time : null,
109
93
  };
110
94
  }
package/quota/poller.ts CHANGED
@@ -21,21 +21,10 @@
21
21
  // It only runs while a session is active.
22
22
  // =============================================================================
23
23
 
24
- import * as fs from "node:fs";
25
- import * as os from "node:os";
26
- import * as path from "node:path";
27
24
  import type { ChromeData } from "../visual/data.ts";
28
25
  import { resolveQuotaProvider } from "./registry.ts";
29
26
  import { formatReset } from "./format.ts";
30
27
 
31
- /** TEMP DEBUG — append a line to the quota debug log. Remove after diagnosis. */
32
- const DEBUG_LOG = path.join(os.tmpdir(), "pi-soly-quota-debug.log");
33
- function dbg(msg: string): void {
34
- try {
35
- fs.appendFileSync(DEBUG_LOG, `[${new Date().toISOString()}] ${msg}\n`);
36
- } catch { /* best effort */ }
37
- }
38
-
39
28
  /** Default poll interval: 60 seconds. */
40
29
  const POLL_INTERVAL_MS = 60_000;
41
30
 
@@ -70,25 +59,23 @@ export function startQuotaPoller(
70
59
 
71
60
  const tick = async (): Promise<void> => {
72
61
  if (stopped) return;
73
- dbg(`tick: enabled=${isEnabled()} provider=${data.modelProvider}`);
74
62
  if (!isEnabled()) {
75
- dbg("disabled, skipping");
76
63
  scheduleNext();
77
64
  return;
78
65
  }
79
66
 
80
67
  const providerId = data.modelProvider;
81
68
  if (!providerId) {
82
- dbg("no modelProvider");
83
69
  scheduleNext();
84
70
  return;
85
71
  }
86
72
 
87
73
  const provider = resolveQuotaProvider(providerId);
88
74
  if (!provider) {
89
- dbg(`no adapter for ${providerId}`);
75
+ // No adapter for this provider — clear and skip.
90
76
  data.quotaPercent = null;
91
77
  data.quotaResetsLabel = null;
78
+ data.quotaResetsMs = null;
92
79
  scheduleNext();
93
80
  return;
94
81
  }
@@ -96,18 +83,16 @@ export function startQuotaPoller(
96
83
  let snapshot;
97
84
  try {
98
85
  snapshot = await provider.fetch();
99
- dbg(`fetch result: ${JSON.stringify(snapshot)}`);
100
- } catch (e) {
101
- dbg(`fetch threw: ${e instanceof Error ? e.message : String(e)}`);
86
+ } catch {
102
87
  snapshot = null;
103
88
  }
104
89
  if (snapshot) {
105
90
  data.quotaPercent = snapshot.remainingPercent;
91
+ data.quotaResetsMs = snapshot.resetsInMs;
106
92
  data.quotaResetsLabel = snapshot.resetsInMs !== null ? formatReset(snapshot.resetsInMs) : null;
107
- dbg(`wrote data: pct=${data.quotaPercent} label=${data.quotaResetsLabel}`);
108
93
  onUpdate();
109
- dbg("called onUpdate (poke)");
110
94
  }
95
+ // On null (fetch failed), keep the previous snapshot — don't clear.
111
96
  scheduleNext();
112
97
  };
113
98
 
package/visual/colors.ts CHANGED
@@ -22,3 +22,12 @@ export function ctxColor(percent: number | null): ChromeColor {
22
22
  if (percent > 70) return "warning";
23
23
  return "muted";
24
24
  }
25
+
26
+ /** Quota color by **used** percentage. Conservative threshold: only yellow when
27
+ * > 90% of the interval is spent (no red — quota resets, it's not a hard
28
+ * error). Muted otherwise. */
29
+ export function quotaColor(usedPercent: number | null): ChromeColor {
30
+ if (usedPercent === null || !Number.isFinite(usedPercent)) return "muted";
31
+ if (usedPercent > 90) return "warning";
32
+ return "muted";
33
+ }
package/visual/data.ts CHANGED
@@ -46,10 +46,15 @@ export type ChromeData = {
46
46
  /** Level of the recent event (used for glyph/color). */
47
47
  recentEventLevel: "info" | "warning" | "error" | null;
48
48
  /** Remaining quota percent (0–100) for the active provider, polled in
49
- * the background by quota/poller.ts. null = no adapter / not polled. */
49
+ * the background by quota/poller.ts. null = no adapter / not polled.
50
+ * Semantic: **used** (inverted from API's remaining) — grows as you
51
+ * spend, matching the MiniMax dashboard. */
50
52
  quotaPercent: number | null;
51
53
  /** Human-readable reset time label (e.g. "in 20m"), or null. */
52
54
  quotaResetsLabel: string | null;
55
+ /** Raw ms until the quota window resets, or null. Used by the footer to
56
+ * color the time yellow when < 30 minutes remain. */
57
+ quotaResetsMs: number | null;
53
58
  };
54
59
 
55
60
  /** A fresh ChromeData with everything empty/idle. */
@@ -73,5 +78,6 @@ export function emptyChromeData(): ChromeData {
73
78
  recentEventLevel: null,
74
79
  quotaPercent: null,
75
80
  quotaResetsLabel: null,
81
+ quotaResetsMs: null,
76
82
  };
77
83
  }
package/visual/footer.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import type { Component } from "@earendil-works/pi-tui";
19
19
  import type { Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { composeBar, type Segment } from "./segments.ts";
21
- import { ctxColor } from "./colors.ts";
21
+ import { ctxColor, quotaColor } from "./colors.ts";
22
22
  import { fitPath, formatTokens } from "./format.ts";
23
23
  import { withGlyph } from "./glyphs.ts";
24
24
  import type { ChromeData } from "./data.ts";
@@ -72,24 +72,19 @@ export function buildFooterLine(data: ChromeData, fd: FooterData, width: number,
72
72
  }
73
73
 
74
74
  if (data.quotaPercent !== null) {
75
- const quotaText = ascii ? `${data.quotaPercent}%` : `⬢ ${data.quotaPercent}%`;
76
- const label = data.quotaResetsLabel ? `${quotaText} · ${data.quotaResetsLabel}` : quotaText;
77
- left.push({ id: "quota", text: styler.fg("muted", label), priority: 6 });
78
- // TEMP DEBUG
79
- try {
80
- const fs = require("node:fs");
81
- const os = require("node:os");
82
- const p = require("node:path");
83
- fs.appendFileSync(p.join(os.tmpdir(), "pi-soly-quota-debug.log"), `[${new Date().toISOString()}] [footer] render: pushed quota segment pct=${data.quotaPercent}\n`);
84
- } catch {}
85
- } else {
86
- // TEMP DEBUG
87
- try {
88
- const fs = require("node:fs");
89
- const os = require("node:os");
90
- const p = require("node:path");
91
- fs.appendFileSync(p.join(os.tmpdir(), "pi-soly-quota-debug.log"), `[${new Date().toISOString()}] [footer] render: quotaPercent=null (segment skipped)\n`);
92
- } catch {}
75
+ const used = data.quotaPercent;
76
+ const glyph = ascii ? "" : "⬢ ";
77
+ const pctPart = styler.fg(quotaColor(used), `${glyph}${used}%`);
78
+ let label: string;
79
+ if (data.quotaResetsLabel) {
80
+ // Yellow time when < 30 minutes until reset; muted otherwise.
81
+ const soon = data.quotaResetsMs !== null && data.quotaResetsMs <= 30 * 60_000;
82
+ const timePart = soon ? styler.fg("warning", data.quotaResetsLabel) : styler.dim(data.quotaResetsLabel);
83
+ label = `${pctPart} · ${timePart}`;
84
+ } else {
85
+ label = pctPart;
86
+ }
87
+ left.push({ id: "quota", text: label, priority: 8 });
93
88
  }
94
89
 
95
90
  if (data.rulesActive > 0) {