@mevsgame/pi-model-alias-profiles 0.1.0 → 0.1.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
@@ -110,6 +110,10 @@ Other processes do not change this process's active profile. After a process
110
110
  crash, a stale `.lock` may require removal after confirming no writer is active.
111
111
  Local atomic-rename/lock semantics are assumed; network filesystems may differ.
112
112
 
113
+ ## Optional pi-usage integration
114
+
115
+ When `@narumitw/pi-usage` is installed, selecting a tier queries its mapped physical provider through `usage:query:v1`. The separate `model-profiles-usage` footer item is cleared when the tier changes or its report expires. Queries may perform network requests. Without pi-usage there is no usage item. The installed pi-usage settings have no global status enable switch: its Anthropic status is gated by `anthropicExtraUsage`, and xAI is menu-only. The bridge respects these gates and Codex percentage/countdown preferences in `pi-usage.json`. Its compact formatter currently covers percentage buckets and Anthropic remaining spend; it does not reproduce pi-usage's full provider-specific formatting or custom `statuslineDisplay` templates.
116
+
113
117
  ## Known limitations and troubleshooting
114
118
 
115
119
  - Another extension registering `tier/large`, `tier/medium`, or `tier/small`
@@ -138,6 +142,19 @@ Installed Pi-Bolt smoke checks confirm extension loading, `--model tier/medium`,
138
142
  and all three aliases in `--list-models tier` without sending model requests.
139
143
  See `VERIFICATION.md` for exact results and remaining manual checks.
140
144
 
145
+ ## Publishing
146
+
147
+ `.github/workflows/publish.yml` tests and publishes the package to npm when a
148
+ GitHub Release is published. Configure npm Trusted Publishing once for
149
+ `@mevsgame/pi-model-alias-profiles` with GitHub Actions, owner `mevsgame`,
150
+ repository `pi-model-alias-profiles`, and workflow `publish.yml` (no environment).
151
+ No npm token secret is needed. Stable releases publish under `latest`; GitHub
152
+ prereleases publish under `next`.
153
+
154
+ For each release, update `version` in `package.json` and `package-lock.json`,
155
+ push the change and a matching `vX.Y.Z` tag, then publish a GitHub Release for
156
+ that tag. The workflow checks the tag/version match before publishing.
157
+
141
158
  ## License and links
142
159
 
143
160
  MIT. [Source and issues](https://github.com/mevsgame/pi-model-alias-profiles).
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import { ALIASES, ConfigStore, type Config, type Alias } from "./src/config.ts";
4
4
  import { route, validateProfile } from "./src/profiles.ts";
5
5
  import { branchProfile, STATE_ENTRY } from "./src/state.ts";
6
6
  import { manage, quickPicker, type Controller } from "./src/ui.ts";
7
+ import { createUsageBridge } from "./src/usage.ts";
7
8
 
8
9
  export default function modelProfiles(pi: ExtensionAPI) {
9
10
  const store = new ConfigStore(join(getAgentDir(), "model-profiles.json"));
@@ -15,12 +16,14 @@ export default function modelProfiles(pi: ExtensionAPI) {
15
16
  if (ctx.hasUI) ctx.ui.notify(message, error ? "error" : "info");
16
17
  else console.error(`[model-profiles] ${message}`);
17
18
  };
19
+ const usage = createUsageBridge(pi, () => config, () => active);
18
20
  const status = (ctx: ExtensionContext) => {
19
21
  if (!ctx.hasUI) return;
20
22
  const alias = ctx.model?.provider === "tier" ? ctx.model.id as Alias : undefined;
21
23
  ctx.ui.setStatus("model-profiles", alias
22
24
  ? `${active ?? "no profile"} · ${alias} → ${active && config.profiles[active]?.[alias] || "(unconfigured)"}`
23
25
  : undefined);
26
+ if (pi.events) usage.update(ctx);
24
27
  };
25
28
  const record = () => pi.appendEntry(STATE_ENTRY, { profile: active ?? null });
26
29
  const controller: Controller = {
@@ -71,11 +74,14 @@ export default function modelProfiles(pi: ExtensionAPI) {
71
74
  };
72
75
  pi.on("session_start", (_event, ctx) => {
73
76
  restore(ctx);
77
+ if (pi.events) usage.start(ctx);
74
78
  if (loadError) report(ctx, String(loadError), true);
75
79
  else if (!Object.keys(config.profiles).length) report(ctx, "No model profiles configured. Run /model-profiles to create your first profile.");
76
80
  });
81
+ pi.on("session_shutdown", (_event, ctx) => { if (pi.events) usage.stop(ctx); });
77
82
  pi.on("session_tree", (_event, ctx) => restore(ctx));
78
83
  pi.on("model_select", (_event, ctx) => status(ctx));
84
+ pi.on("agent_end", (_event, ctx) => { if (pi.events) usage.update(ctx); });
79
85
  for (const alias of ALIASES) pi.registerVirtualModel({
80
86
  provider: "tier", id: alias, name: `Tier ${alias}`,
81
87
  thinkingLevels: ["off", "minimal", "low", "medium", "high", "xhigh"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mevsgame/pi-model-alias-profiles",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic Pi model aliases with switchable, persistent profiles",
6
6
  "license": "MIT",
@@ -0,0 +1,41 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type QueryOutcome = { kind: "result"; value: unknown } | { kind: "no-service" | "cancelled" | "timeout" | "protocol-error" };
4
+
5
+ /** The response offer is synchronous even when the offered result is asynchronous. */
6
+ export function queryUsage(pi: ExtensionAPI, session: ExtensionContext["sessionManager"], sessionId: string,
7
+ providerId: string, controller: AbortController, timeoutMs = 15_000): Promise<QueryOutcome> {
8
+ return new Promise(resolve => {
9
+ const signal = controller.signal;
10
+ if (signal.aborted) { resolve({ kind: "cancelled" }); return; }
11
+ let open = true, accepted = false, settled = false;
12
+ let deadline: ReturnType<typeof setTimeout> | undefined;
13
+ const finish = (outcome: QueryOutcome) => {
14
+ if (settled) return;
15
+ settled = true;
16
+ if (deadline) clearTimeout(deadline);
17
+ signal.removeEventListener("abort", onAbort);
18
+ resolve(outcome);
19
+ };
20
+ const onAbort = () => finish({ kind: "cancelled" });
21
+ signal.addEventListener("abort", onAbort, { once: true });
22
+ deadline = setTimeout(() => { finish({ kind: "timeout" }); controller.abort(); }, timeoutMs);
23
+ try {
24
+ pi.events.emit("usage:query:v1", {
25
+ session, sessionId, providerId, refresh: true, signal,
26
+ respond(offered: unknown) {
27
+ // Even a rejected duplicate or late promise must have its rejection observed.
28
+ const promise = Promise.resolve(offered);
29
+ if (!open || accepted || settled || signal.aborted) { void promise.catch(() => {}); return false; }
30
+ accepted = true;
31
+ void promise.then(value => finish({ kind: "result", value }), () => finish({ kind: "protocol-error" }));
32
+ return true;
33
+ },
34
+ });
35
+ } catch {
36
+ finish({ kind: "protocol-error" });
37
+ controller.abort();
38
+ } finally { open = false; }
39
+ if (!accepted && !settled) { finish({ kind: "no-service" }); controller.abort(); }
40
+ });
41
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { resolveTarget } from "./profiles.ts";
6
+ import type { Alias, Config } from "./config.ts";
7
+ import { queryUsage } from "./usage-query.ts";
8
+
9
+ // pi-usage has no global status switch. Its Anthropic status is explicitly opt-in;
10
+ // xAI is deliberately menu-only. Do not start a query for either when disabled.
11
+ function settings() {
12
+ try {
13
+ const value = JSON.parse(readFileSync(join(getAgentDir(), "pi-usage.json"), "utf8"));
14
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
15
+ } catch { return {}; }
16
+ }
17
+
18
+ export function formatUsage(value: any, provider: string, opts: any): string | undefined {
19
+ if (value?.status !== "ready" || value.providerId !== provider || value.report?.providerId !== provider ||
20
+ !Number.isFinite(value.expiresAt) || value.expiresAt <= Date.now() || !Array.isArray(value.report.buckets)) return;
21
+ if (provider === "anthropic") {
22
+ const metric = Array.isArray(value.report.metrics) && value.report.metrics.find((m: any) => m?.id === "extra-usage-remaining");
23
+ return typeof metric?.value === "number" && Number.isFinite(metric.value) ? `anthropic $${metric.value.toFixed(2)} left` : undefined;
24
+ }
25
+ const buckets = value.report.buckets.filter((b: any) => b?.unit === "percent" && Number.isFinite(b.remaining));
26
+ if (!buckets.length) return;
27
+ const label = provider === "openai-codex" ? "codex" : provider.replace(/[^a-zA-Z0-9._-]/g, "");
28
+ const parts = buckets.slice(0, 4).map((b: any) => {
29
+ const amount = provider === "openai-codex" && opts.codexStatusPercentage === "used"
30
+ ? (Number.isFinite(b.used) ? b.used : 100 - b.remaining) : b.remaining;
31
+ const percent = `${Math.max(0, Math.min(100, amount)).toFixed(0)}%`;
32
+ const mins = b.windowMinutes;
33
+ const window = Number.isFinite(mins) && mins > 0 ? mins === 10080 ? "wk" : mins % 1440 === 0 ? `${mins / 1440}d` : mins % 60 === 0 ? `${mins / 60}h` : `${mins}m` : "";
34
+ const reset = provider === "openai-codex" && opts.codexStatusResetCountdown !== false && Number.isFinite(b.resetsAt)
35
+ ? Math.max(0, Math.ceil((b.resetsAt - Date.now()) / 60000)) : undefined;
36
+ return reset !== undefined ? `${percent} ↻ ${reset >= 60 ? `${Math.floor(reset / 60)}h${reset % 60 ? `${reset % 60}m` : ""}` : `${reset}m`}` : `${window ? `${window} ` : ""}${percent}`;
37
+ });
38
+ return `${label} ${parts.join(" · ")}`;
39
+ }
40
+
41
+ export function createUsageBridge(pi: ExtensionAPI, getConfig: () => Config, getActive: () => string | undefined,
42
+ timing: { timeoutMs?: number; retryBaseMs?: number; retryMaxMs?: number; minRefreshMs?: number } = {}) {
43
+ let generation = 0;
44
+ let controller: AbortController | undefined;
45
+ let timer: ReturnType<typeof setTimeout> | undefined;
46
+ let running = false;
47
+ let failures = 0;
48
+ const clear = (ctx: ExtensionContext) => { if (ctx.hasUI) ctx.ui.setStatus("model-profiles-usage", undefined); };
49
+ const cancel = () => { generation++; controller?.abort(); controller = undefined; if (timer) clearTimeout(timer); timer = undefined; };
50
+ const update = (ctx: ExtensionContext) => {
51
+ cancel();
52
+ clear(ctx);
53
+ if (!running || !ctx.hasUI || !pi.events || ctx.model?.provider !== "tier") return;
54
+ let model;
55
+ try { model = resolveTarget(getActive(), ctx.model.id as Alias, getConfig(), ctx.modelRegistry); } catch { return; }
56
+ const opts = settings();
57
+ if (model.provider === "xai" || (model.provider === "anthropic" && opts.anthropicExtraUsage !== true)) return;
58
+ const session = ctx.sessionManager;
59
+ if (typeof session.getSessionId !== "function") return;
60
+ const sessionId = session.getSessionId();
61
+ const current = generation;
62
+ const abort = new AbortController(); controller = abort;
63
+ void queryUsage(pi, session, sessionId, model.provider, abort, timing.timeoutMs).then(outcome => {
64
+ if (!running || current !== generation || session.getSessionId() !== sessionId) return;
65
+ if (ctx.model?.provider !== "tier" || JSON.stringify(settings()) !== JSON.stringify(opts)) return;
66
+ try {
67
+ const target = resolveTarget(getActive(), ctx.model.id as Alias, getConfig(), ctx.modelRegistry);
68
+ if (target.provider !== model.provider || target.id !== model.id) return;
69
+ } catch { return; }
70
+ if (controller === abort) controller = undefined;
71
+ const result = outcome.kind === "result" ? outcome.value : undefined;
72
+ const value = formatUsage(result, model.provider, opts);
73
+ if (value) {
74
+ failures = 0;
75
+ ctx.ui.setStatus("model-profiles-usage", value);
76
+ const expiresAt = (result as any).expiresAt as number;
77
+ const remaining = Math.max(0, expiresAt - Date.now());
78
+ timer = setTimeout(() => {
79
+ if (current !== generation) return;
80
+ clear(ctx);
81
+ const delay = Math.max(0, (timing.minRefreshMs ?? 1000) - remaining);
82
+ if (delay) timer = setTimeout(() => { if (current === generation) update(ctx); }, delay);
83
+ else update(ctx);
84
+ }, Math.min(2147483647, remaining));
85
+ } else {
86
+ // No service and all unavailable/malformed results are retried with capped backoff.
87
+ // External lifecycle/model changes can trigger an earlier attempt.
88
+ const max = timing.retryMaxMs ?? 60_000;
89
+ const delay = outcome.kind === "no-service" ? max : Math.min(max, (timing.retryBaseMs ?? 1000) * 2 ** Math.min(failures++, 16));
90
+ timer = setTimeout(() => { if (current === generation) update(ctx); }, delay);
91
+ }
92
+ }).catch(() => {});
93
+ };
94
+ return {
95
+ start(ctx: ExtensionContext) { running = true; const started = ++generation; queueMicrotask(() => { if (running && started === generation) update(ctx); }); },
96
+ update,
97
+ stop(ctx: ExtensionContext) { running = false; cancel(); clear(ctx); },
98
+ };
99
+ }