pi-soly 2.3.3 → 2.4.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 +21 -0
- package/package.json +2 -1
- package/quota/format.ts +23 -0
- package/quota/minimax.ts +81 -0
- package/quota/poller.ts +99 -0
- package/quota/registry.ts +39 -0
- package/quota/types.ts +37 -0
- package/visual/data.ts +7 -0
- package/visual/footer.ts +6 -0
package/index.ts
CHANGED
|
@@ -65,6 +65,9 @@ import { registerTools } from "./tools.ts";
|
|
|
65
65
|
import { registerWorkflows } from "./workflows/index.ts";
|
|
66
66
|
import { readGitContext, buildGitSection, type GitContext } from "./git.ts";
|
|
67
67
|
import { startHotReload, type HotReloadHandle } from "./hotreload.ts";
|
|
68
|
+
import { registerQuotaProvider } from "./quota/registry.ts";
|
|
69
|
+
import { minimaxProvider } from "./quota/minimax.ts";
|
|
70
|
+
import { startQuotaPoller, type QuotaPoller } from "./quota/poller.ts";
|
|
68
71
|
import { detectEnv, buildEnvSection, type EnvSummary } from "./env.ts";
|
|
69
72
|
import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.ts";
|
|
70
73
|
import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.ts";
|
|
@@ -101,6 +104,12 @@ When terminal text isn't the best medium, reach for these (details + when-NOT in
|
|
|
101
104
|
- \`html_artifact\` — render HTML to a self-contained, browseable per-project gallery.`;
|
|
102
105
|
|
|
103
106
|
export default function solyExtension(pi: ExtensionAPI) {
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Register built-in quota providers (MiniMax via mmx CLI).
|
|
109
|
+
// Adding a new provider = new adapter + registerQuotaProvider() here.
|
|
110
|
+
// ============================================================================
|
|
111
|
+
registerQuotaProvider(minimaxProvider);
|
|
112
|
+
|
|
104
113
|
// ============================================================================
|
|
105
114
|
// State (module-local, lives for the duration of one extension instance)
|
|
106
115
|
// ============================================================================
|
|
@@ -168,6 +177,7 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
168
177
|
|
|
169
178
|
// Hot reload watcher for rules
|
|
170
179
|
let hotReload: HotReloadHandle | null = null;
|
|
180
|
+
let quotaPoller: QuotaPoller | null = null;
|
|
171
181
|
|
|
172
182
|
// Session stats (computed on demand)
|
|
173
183
|
let sessionStats: { turns: number; tokensEstimate: number } = { turns: 0, tokensEstimate: 0 };
|
|
@@ -536,6 +546,12 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
536
546
|
});
|
|
537
547
|
},
|
|
538
548
|
});
|
|
549
|
+
|
|
550
|
+
// Start the background quota poller (reads modelProvider from
|
|
551
|
+
// ChromeData each tick, resolves the registered adapter, writes
|
|
552
|
+
// quotaPercent/quotaResetsLabel back for the footer to render).
|
|
553
|
+
if (quotaPoller) quotaPoller.stop();
|
|
554
|
+
quotaPoller = startQuotaPoller(chrome.data, () => getActiveConfig().chrome.enabled);
|
|
539
555
|
// Editors save in bursts (write to .tmp, rename, touch). Coalesce
|
|
540
556
|
// those rapid reload events into a single sub-line event under the
|
|
541
557
|
// Working indicator (└─ reloaded 47 rules). Errors here are real
|
|
@@ -633,6 +649,11 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
633
649
|
hotReload.stop();
|
|
634
650
|
hotReload = null;
|
|
635
651
|
}
|
|
652
|
+
// Stop the background quota poller
|
|
653
|
+
if (quotaPoller) {
|
|
654
|
+
quotaPoller.stop();
|
|
655
|
+
quotaPoller = null;
|
|
656
|
+
}
|
|
636
657
|
// Restore pi's native footer/widgets/indicator before teardown
|
|
637
658
|
chrome.dispose(ctx.ui);
|
|
638
659
|
// Persist rule mtimes so the next session can show the diff
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"state.ts",
|
|
64
64
|
"hotreload.ts",
|
|
65
65
|
"built-in-rules",
|
|
66
|
+
"quota",
|
|
66
67
|
".assets"
|
|
67
68
|
],
|
|
68
69
|
"keywords": [
|
package/quota/format.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// quota/format.ts — human-readable reset time
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Turns a millisecond duration into a compact English label for the toolbar:
|
|
6
|
+
// 1_200_000 → "in 20m"
|
|
7
|
+
// 9_000_000 → "in 2h30m"
|
|
8
|
+
// 3_600_000 → "in 1h"
|
|
9
|
+
// 0 → "now"
|
|
10
|
+
//
|
|
11
|
+
// Kept pure (no DOM, no Date) so it's trivially testable.
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
/** Format a millisecond duration as a compact "in Nm" / "in Nh" / "in NhMm". */
|
|
15
|
+
export function formatReset(ms: number): string {
|
|
16
|
+
const totalMin = Math.round(ms / 60_000);
|
|
17
|
+
if (totalMin <= 0) return "now";
|
|
18
|
+
if (totalMin < 60) return `in ${totalMin}m`;
|
|
19
|
+
const hours = Math.floor(totalMin / 60);
|
|
20
|
+
const mins = totalMin % 60;
|
|
21
|
+
if (mins === 0) return `in ${hours}h`;
|
|
22
|
+
return `in ${hours}h${mins}m`;
|
|
23
|
+
}
|
package/quota/minimax.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// quota/minimax.ts — MiniMax quota provider (via mmx CLI)
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Shells out to `mmx quota show --output json --quiet` every poll cycle.
|
|
6
|
+
// Uses mmx (mmx-cli) because it already handles auth (~/.mmx/config.json),
|
|
7
|
+
// region detection (global vs cn), and the billing endpoint. Reimplementing
|
|
8
|
+
// that as direct HTTP would duplicate mmx's logic for no gain.
|
|
9
|
+
//
|
|
10
|
+
// The response shape (confirmed against a live MiniMax-M3 key):
|
|
11
|
+
// {
|
|
12
|
+
// "model_remains": [
|
|
13
|
+
// {
|
|
14
|
+
// "model_name": "general", ← chat models (MiniMax-M3)
|
|
15
|
+
// "current_interval_remaining_percent": 53,
|
|
16
|
+
// "remains_time": 1639649, ← ms until interval resets
|
|
17
|
+
// ...
|
|
18
|
+
// },
|
|
19
|
+
// { "model_name": "video", ... }
|
|
20
|
+
// ],
|
|
21
|
+
// "base_resp": { "status_code": 0, ... }
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// We pick the "general" entry — that's the quota bucket MiniMax-M3 (and all
|
|
25
|
+
// text/chat models) draw from. Video/music have separate buckets.
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
import { execFile } from "node:child_process";
|
|
29
|
+
import type { QuotaProvider, QuotaSnapshot } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
/** Minimal shape we read from `mmx quota show --output json`. */
|
|
32
|
+
type MinimaxQuotaResponse = {
|
|
33
|
+
model_remains?: Array<{
|
|
34
|
+
model_name?: string;
|
|
35
|
+
current_interval_remaining_percent?: number;
|
|
36
|
+
remains_time?: number;
|
|
37
|
+
}>;
|
|
38
|
+
base_resp?: { status_code?: number };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Run mmx and capture stdout. Resolves to null on any failure
|
|
42
|
+
* (mmx missing, non-zero exit, timeout, bad JSON). Never throws. */
|
|
43
|
+
function runMmx(args: string[], timeoutMs: number): Promise<string | null> {
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
execFile("mmx", args, { encoding: "utf-8", timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (err, stdout) => {
|
|
46
|
+
if (err) resolve(null);
|
|
47
|
+
else resolve(stdout ?? "");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse the mmx quota response, returning the "general" model snapshot. */
|
|
53
|
+
function parseGeneralQuota(raw: string): QuotaSnapshot | null {
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return null; // not valid JSON — mmx printed an error to stdout
|
|
59
|
+
}
|
|
60
|
+
// Cast at the boundary (single documented place, per code-style rules).
|
|
61
|
+
const data = parsed as MinimaxQuotaResponse;
|
|
62
|
+
if (data.base_resp?.status_code !== 0) return null;
|
|
63
|
+
const general = data.model_remains?.find((m) => m.model_name === "general");
|
|
64
|
+
if (!general) return null;
|
|
65
|
+
const pct = general.current_interval_remaining_percent;
|
|
66
|
+
if (typeof pct !== "number") return null;
|
|
67
|
+
return {
|
|
68
|
+
remainingPercent: pct,
|
|
69
|
+
resetsInMs: typeof general.remains_time === "number" ? general.remains_time : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** MiniMax quota provider. Polls `mmx quota show`. */
|
|
74
|
+
export const minimaxProvider: QuotaProvider = {
|
|
75
|
+
id: "minimax",
|
|
76
|
+
async fetch(): Promise<QuotaSnapshot | null> {
|
|
77
|
+
const stdout = await runMmx(["quota", "show", "--output", "json", "--quiet"], 15_000);
|
|
78
|
+
if (stdout === null) return null;
|
|
79
|
+
return parseGeneralQuota(stdout);
|
|
80
|
+
},
|
|
81
|
+
};
|
package/quota/poller.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// quota/poller.ts — background quota polling loop
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// startQuotaPoller() reads the active model's provider id from ChromeData,
|
|
6
|
+
// resolves it to a registered QuotaProvider, and calls fetch() on a timer.
|
|
7
|
+
// The result is written back into ChromeData (quotaPercent / quotaResetsLabel)
|
|
8
|
+
// which the footer reads live at render time.
|
|
9
|
+
//
|
|
10
|
+
// Design notes:
|
|
11
|
+
// - setTimeout recursion (not setInterval) so a slow fetch can't pile up
|
|
12
|
+
// overlapping ticks. The next tick is scheduled only after the current
|
|
13
|
+
// fetch resolves.
|
|
14
|
+
// - On fetch failure (null), the previous snapshot is kept — stale data is
|
|
15
|
+
// more useful than a flashing segment. The percent naturally drifts as
|
|
16
|
+
// the user consumes quota between successful polls.
|
|
17
|
+
// - When the active provider has no registered adapter, the quota fields
|
|
18
|
+
// are cleared (no segment shown) — self-disabling for providers we don't
|
|
19
|
+
// support yet.
|
|
20
|
+
// - The poller is started in session_start and stopped in session_shutdown.
|
|
21
|
+
// It only runs while a session is active.
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
import type { ChromeData } from "../visual/data.ts";
|
|
25
|
+
import { resolveQuotaProvider } from "./registry.ts";
|
|
26
|
+
import { formatReset } from "./format.ts";
|
|
27
|
+
|
|
28
|
+
/** Default poll interval: 60 seconds. */
|
|
29
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
/** A running poller handle. Call stop() to cancel the timer. */
|
|
32
|
+
export type QuotaPoller = {
|
|
33
|
+
/** Cancel the timer. Idempotent. */
|
|
34
|
+
stop(): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Start the quota poller. Reads `data.modelProvider` each tick to pick the
|
|
38
|
+
* right adapter. Returns a handle to stop the loop on session_shutdown.
|
|
39
|
+
*
|
|
40
|
+
* @param data - the shared ChromeData (mutated in place with quota fields)
|
|
41
|
+
* @param isEnabled - gate; return false to skip polling (e.g. chrome disabled)
|
|
42
|
+
* @param intervalMs - override poll interval (default 60_000); for tests */
|
|
43
|
+
export function startQuotaPoller(
|
|
44
|
+
data: ChromeData,
|
|
45
|
+
isEnabled: () => boolean,
|
|
46
|
+
intervalMs: number = POLL_INTERVAL_MS,
|
|
47
|
+
): QuotaPoller {
|
|
48
|
+
let stopped = false;
|
|
49
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
|
|
51
|
+
const scheduleNext = (): void => {
|
|
52
|
+
if (stopped) return;
|
|
53
|
+
timer = setTimeout(tick, intervalMs);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const tick = async (): Promise<void> => {
|
|
57
|
+
if (stopped) return;
|
|
58
|
+
if (!isEnabled()) {
|
|
59
|
+
scheduleNext();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const providerId = data.modelProvider;
|
|
64
|
+
if (!providerId) {
|
|
65
|
+
scheduleNext();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const provider = resolveQuotaProvider(providerId);
|
|
70
|
+
if (!provider) {
|
|
71
|
+
// No adapter for this provider — clear and skip.
|
|
72
|
+
data.quotaPercent = null;
|
|
73
|
+
data.quotaResetsLabel = null;
|
|
74
|
+
scheduleNext();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const snapshot = await provider.fetch();
|
|
79
|
+
if (snapshot) {
|
|
80
|
+
data.quotaPercent = snapshot.remainingPercent;
|
|
81
|
+
data.quotaResetsLabel = snapshot.resetsInMs !== null ? formatReset(snapshot.resetsInMs) : null;
|
|
82
|
+
}
|
|
83
|
+
// On null (fetch failed), keep the previous snapshot — don't clear.
|
|
84
|
+
scheduleNext();
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Fire immediately so the segment appears without waiting 60s.
|
|
88
|
+
tick();
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
stop(): void {
|
|
92
|
+
stopped = true;
|
|
93
|
+
if (timer) {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
timer = null;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// quota/registry.ts — provider registry
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A simple Map<id, QuotaProvider>. Providers register themselves at module
|
|
6
|
+
// load (index.ts calls registerQuotaProvider for each built-in adapter).
|
|
7
|
+
// The poller calls resolveQuotaProvider(providerId) which tries an exact
|
|
8
|
+
// match first, then falls back to the base id before the first "-" (so
|
|
9
|
+
// "minimax-cn" resolves to a "minimax" adapter).
|
|
10
|
+
//
|
|
11
|
+
// External extensions could also register providers by importing this
|
|
12
|
+
// module — but pi-soly has no runtime deps, so in practice the built-in
|
|
13
|
+
// adapters (minimax) are all that ship today.
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
import type { QuotaProvider } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
const providers = new Map<string, QuotaProvider>();
|
|
19
|
+
|
|
20
|
+
/** Register a quota provider. Idempotent — re-registering overwrites. */
|
|
21
|
+
export function registerQuotaProvider(provider: QuotaProvider): void {
|
|
22
|
+
providers.set(provider.id, provider);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Exact-match lookup. */
|
|
26
|
+
export function getQuotaProvider(id: string): QuotaProvider | undefined {
|
|
27
|
+
return providers.get(id);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolve a provider id to its adapter. Tries exact match first, then
|
|
31
|
+
* the base id before the first "-" (region-suffix fallback:
|
|
32
|
+
* "minimax-cn" → "minimax"). Returns undefined if nothing matches. */
|
|
33
|
+
export function resolveQuotaProvider(id: string): QuotaProvider | undefined {
|
|
34
|
+
const exact = providers.get(id);
|
|
35
|
+
if (exact) return exact;
|
|
36
|
+
const base = id.split("-")[0];
|
|
37
|
+
if (base !== id) return providers.get(base);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
package/quota/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// quota/types.ts — provider-agnostic quota snapshot
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A QuotaProvider knows how to fetch "how much quota is left" for one model
|
|
6
|
+
// provider (MiniMax, OpenAI, …). The poller (poller.ts) reads the active
|
|
7
|
+
// model's provider id from ChromeData, looks up the registered adapter, and
|
|
8
|
+
// polls it on a timer. Adding a new provider = implement this interface +
|
|
9
|
+
// registerQuotaProvider() — no changes to the poller or footer.
|
|
10
|
+
//
|
|
11
|
+
// This is intentionally minimal: just the two numbers the toolbar needs.
|
|
12
|
+
// Per-model breakdowns, weekly vs interval, cost in cents — none of that
|
|
13
|
+
// belongs in a status-bar segment. If a provider can't report a field, it
|
|
14
|
+
// returns null and the segment omits it.
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/** What a provider reports about remaining quota. */
|
|
18
|
+
export type QuotaSnapshot = {
|
|
19
|
+
/** Remaining quota as a percent (0–100). Drives the main number. */
|
|
20
|
+
readonly remainingPercent: number;
|
|
21
|
+
/** Milliseconds until the current quota window resets, or null if the
|
|
22
|
+
* provider doesn't expose a reset time. Rendered as "in 20m". */
|
|
23
|
+
readonly resetsInMs: number | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Adapter that fetches quota for one provider.
|
|
27
|
+
*
|
|
28
|
+
* Implementations MUST NOT throw — return null on any failure (no
|
|
29
|
+
* credentials, provider down, CLI missing, parse error). The poller
|
|
30
|
+
* treats null as "no data" and leaves the previous snapshot in place. */
|
|
31
|
+
export interface QuotaProvider {
|
|
32
|
+
/** Provider id matching `ctx.model.provider` (e.g. "minimax").
|
|
33
|
+
* Region-suffixed ids ("minimax-cn") fall back to the base id. */
|
|
34
|
+
readonly id: string;
|
|
35
|
+
/** Fetch the current snapshot, or null if unavailable. */
|
|
36
|
+
fetch(): Promise<QuotaSnapshot | null>;
|
|
37
|
+
}
|
package/visual/data.ts
CHANGED
|
@@ -45,6 +45,11 @@ export type ChromeData = {
|
|
|
45
45
|
recentEvent: string | null;
|
|
46
46
|
/** Level of the recent event (used for glyph/color). */
|
|
47
47
|
recentEventLevel: "info" | "warning" | "error" | null;
|
|
48
|
+
/** Remaining quota percent (0–100) for the active provider, polled in
|
|
49
|
+
* the background by quota/poller.ts. null = no adapter / not polled. */
|
|
50
|
+
quotaPercent: number | null;
|
|
51
|
+
/** Human-readable reset time label (e.g. "in 20m"), or null. */
|
|
52
|
+
quotaResetsLabel: string | null;
|
|
48
53
|
};
|
|
49
54
|
|
|
50
55
|
/** A fresh ChromeData with everything empty/idle. */
|
|
@@ -66,5 +71,7 @@ export function emptyChromeData(): ChromeData {
|
|
|
66
71
|
artifactCount: 0,
|
|
67
72
|
recentEvent: null,
|
|
68
73
|
recentEventLevel: null,
|
|
74
|
+
quotaPercent: null,
|
|
75
|
+
quotaResetsLabel: null,
|
|
69
76
|
};
|
|
70
77
|
}
|
package/visual/footer.ts
CHANGED
|
@@ -71,6 +71,12 @@ export function buildFooterLine(data: ChromeData, fd: FooterData, width: number,
|
|
|
71
71
|
left.push({ id: "git", text: styler.fg("muted", withGlyph("git", `${branch}${dirty}`, ascii)), priority: 7 });
|
|
72
72
|
}
|
|
73
73
|
|
|
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
|
+
}
|
|
79
|
+
|
|
74
80
|
if (data.rulesActive > 0) {
|
|
75
81
|
const word = data.rulesActive === 1 ? "rule" : "rules";
|
|
76
82
|
const rulesText = ascii ? `${data.rulesActive} ${word}` : `${RULES_GLYPH} ${data.rulesActive}`;
|