pi-hypercharm-provider 1.3.39 → 1.3.40
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/AGENTS.md +6 -2
- package/CHANGELOG.md +8 -0
- package/README.md +26 -2
- package/identity.ts +3 -0
- package/index.ts +233 -32
- package/package.json +13 -12
- package/sync-policy.ts +309 -0
- package/tests/provider.integration.test.ts +8 -5
- package/tests/quiet-startup-sync.test.ts +411 -0
- package/tests/sync-policy.test.ts +259 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +12 -0
- package/bun.lock +0 -328
package/AGENTS.md
CHANGED
|
@@ -23,6 +23,7 @@ When a model needs overrides, new properties, or corrections, edit the appropria
|
|
|
23
23
|
| `scripts/update-models.js` | The sync script itself (edit only if changing how models are fetched/transformed). |
|
|
24
24
|
| `identity.ts` | Every identifier this extension registers into a shared pi surface (provider id, custom api name, status/widget keys, command, prism entry type, auth key + env var, config/cache file names). `tests/identity.test.ts` enforces namespacing, uniqueness, and disjointness from the official `@charmland/pi-hyper-provider` — never hardcode one of these elsewhere. |
|
|
25
25
|
| `notify.ts` | Deduplicated warning sink: fetch/parse failures go to the session UI once one is active, stderr before that, never thrown. |
|
|
26
|
+
| `sync-policy.ts` | Quiet startup sync policy (TTL, key fingerprint, cross-process lock). Provider-agnostic and shared verbatim across pi-*-provider repos — keep it free of provider specifics. Also gates the footer's startup account prefetch (`hypercharm-account.json`). |
|
|
26
27
|
| `prism.ts` | Hyper routing-header validation (sanitizer + persisted-entry re-validation). Pure module — no pi imports; exercised by `tests/prism.test.ts`. |
|
|
27
28
|
|
|
28
29
|
## Data Flow
|
|
@@ -64,8 +65,9 @@ Provider API ──fetch──► models.json ──apply──► patch.jso
|
|
|
64
65
|
## Tests
|
|
65
66
|
|
|
66
67
|
`npm run check` runs `tsc --noEmit`, the dependency-free status smoke test, and
|
|
67
|
-
the node:test suites (through `jiti/register` so the
|
|
68
|
-
TS imports resolve)
|
|
68
|
+
the test suites: the node:test suites (through `jiti/register` so the
|
|
69
|
+
extension's extensionless TS imports resolve), then the vitest suites listed in
|
|
70
|
+
`vitest.config.ts`. `npm test` runs only the test suites.
|
|
69
71
|
|
|
70
72
|
| File | Scope |
|
|
71
73
|
|------|-------|
|
|
@@ -73,6 +75,8 @@ TS imports resolve). `npm test` runs only the test suites.
|
|
|
73
75
|
| `tests/identity.test.ts` | Co-install invariant: namespaced + unique + disjoint from the official provider's reserved names |
|
|
74
76
|
| `tests/prism.test.ts` | Prism header/label sanitization and persisted-entry validation |
|
|
75
77
|
| `tests/notify.test.ts` | Warning dedupe, UI routing, stale-ctx stderr fallback |
|
|
78
|
+
| `tests/sync-policy.test.ts` | (vitest) Shared quiet-sync policy: TTL, fingerprint, backoff, lock — copied verbatim across pi-*-provider repos |
|
|
79
|
+
| `tests/quiet-startup-sync.test.ts` | (vitest) session_start integration: no-credential/no-fetch, TTL, lock, OAuth identity, /login force, shared account snapshot, UI-only account prefetch |
|
|
76
80
|
| `tests/provider.integration.test.ts` | Real pi runtime (DefaultResourceLoader + createAgentSession + ExtensionRunner): embedded catalog, catalog hot-swap/cache/retention, deprecated grace window, warning surfacing, prism entry durability, and co-installation against `tests/fixtures/official-surface.ts` |
|
|
77
81
|
|
|
78
82
|
The integration suite stubs `globalThis.fetch` and emits the session lifecycle
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.40
|
|
4
|
+
|
|
5
|
+
- Quiet startup sync: background `/v1/provider` revalidation runs only with credentials, only when the disk cache is older than 1 hour (`PI_PROVIDER_SYNC_TTL_MS`, `0` = always), missing, or fetched with different credentials, and only in one pi process at a time (lock file next to the cache). Failed refreshes back off for 5 minutes; sessions that skip adopt a cache refreshed by another process. A completed `/login` forces a revalidation.
|
|
6
|
+
- OAuth sessions are fingerprinted by the stored refresh token (not the rotating access token), so token refreshes don't trigger refetches. Only a salted sha256 fingerprint is stored (`cache/hypercharm-models.sync.json`); the cache file format is unchanged.
|
|
7
|
+
- The footer's session-start credits/team/devices prefetch now runs only in interactive sessions (not `pi -p` or child agents) and is shared across pi processes through `cache/hypercharm-account.json` (2-minute window). Turn-triggered balance polls are unchanged.
|
|
8
|
+
- Write the model cache atomically; `session_start` never awaits the network and swallows background failures.
|
|
9
|
+
- Add vitest coverage for the sync policy and the session_start integration (`npm test` runs it after the node:test suites).
|
|
10
|
+
|
|
3
11
|
## 1.3.36
|
|
4
12
|
|
|
5
13
|
- Pin Pi SDK development dependencies to 1.0.0 while retaining wildcard host peers.
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Run `bun run test:pi` for offline manifest, catalog, lifecycle and streaming che
|
|
|
24
24
|
- **DeepSeek Native Thinking** — Uses the `deepseek` thinking format for Charm Hyper requests, with native `reasoning_effort` on models that publish levels
|
|
25
25
|
- **OpenAI-compatible API** via Charm Hyper's `/v1/chat/completions` endpoint
|
|
26
26
|
- **OAuth Device Flow** — sign in with `/login` under the `hypercharm` provider (independent of the official provider's `hyper` registration)
|
|
27
|
-
- **Official Catalog Sync** from Charm's typed `/v1/provider` endpoint, matching `@charmland/pi-hyper-provider`
|
|
27
|
+
- **Official Catalog Sync** from Charm's typed `/v1/provider` endpoint, matching `@charmland/pi-hyper-provider` — refreshed quietly in the background, at most hourly and by one pi process at a time ([details](#model-sync))
|
|
28
28
|
- **Reasoning Models** with provider-published on/off states and exact effort levels
|
|
29
29
|
- **Attachment Support** for models the official catalog marks as attachment-capable
|
|
30
30
|
|
|
@@ -138,7 +138,11 @@ renders when the session-start or model-select credits fetch lands, and the
|
|
|
138
138
|
session side joins it after the first HyperCharm turn completes. Selecting
|
|
139
139
|
another provider hides it (`hideOnOtherProvider` defaults to `true`), the
|
|
140
140
|
balance refreshes when the agent run fully settles, and sessions that never
|
|
141
|
-
use HyperCharm make no status-related API calls
|
|
141
|
+
use HyperCharm make no status-related API calls. The session-start prefetch
|
|
142
|
+
runs only in interactive sessions (never in `pi -p` or child agents) and is
|
|
143
|
+
shared across pi processes: one process polls and writes
|
|
144
|
+
`~/.pi/agent/cache/hypercharm-account.json`, and sessions started within the
|
|
145
|
+
next 2 minutes render that snapshot instead of polling again:
|
|
142
146
|
|
|
143
147
|
```
|
|
144
148
|
⚡ 1.24 hc · 7 req Xu's Team ◆ 249 hc · 996/1k/h · 29d
|
|
@@ -209,6 +213,7 @@ Get your API key from [hyper.charm.land](https://hyper.charm.land).
|
|
|
209
213
|
| Variable | Required | Description |
|
|
210
214
|
|----------|----------|-------------|
|
|
211
215
|
| `HYPERCHARM_API_KEY` | No | Your Charm Hyper API key (fallback if not in auth.json) |
|
|
216
|
+
| `PI_PROVIDER_SYNC_TTL_MS` | No | Model sync freshness window in ms (default `3600000`; `0` = revalidate every session start) |
|
|
212
217
|
|
|
213
218
|
## Configuration
|
|
214
219
|
|
|
@@ -246,6 +251,25 @@ Model metadata matches Charm's official [`@charmland/pi-hyper-provider`](https:/
|
|
|
246
251
|
|
|
247
252
|
Each entry carries the model's full level map, because `patch.json` replaces `thinkingLevelMap` wholesale rather than merging it.
|
|
248
253
|
|
|
254
|
+
## Model sync
|
|
255
|
+
|
|
256
|
+
Startup never waits on the network. pi starts with the cached catalog (`~/.pi/agent/cache/hypercharm-models.json`,
|
|
257
|
+
falling back to the bundled `models.json`) and revalidates against `/v1/provider` in the background — only when:
|
|
258
|
+
|
|
259
|
+
- HyperCharm credentials are configured (no credentials, no request),
|
|
260
|
+
- the cache is older than 1 hour, missing, or was fetched with different credentials, and
|
|
261
|
+
- no other pi process is already refreshing it (a `hypercharm-models.lock` file next to the cache).
|
|
262
|
+
|
|
263
|
+
So `/new`, `/reload`, `pi -p` and fan-out child agents share one cache instead of each calling the API; a
|
|
264
|
+
session that skips the fetch still picks up a cache another process refreshed. A failed refresh is retried after
|
|
265
|
+
5 minutes at the earliest. `PI_PROVIDER_SYNC_TTL_MS` changes the window (`0` revalidates on every session start).
|
|
266
|
+
Only a hash fingerprint of the credential is stored (`hypercharm-models.sync.json`), never the credential itself.
|
|
267
|
+
For OAuth the fingerprint follows the stored refresh token rather than the short-lived access token, so routine
|
|
268
|
+
token refreshes do not trigger a refetch, while a completed `/login` always forces one.
|
|
269
|
+
|
|
270
|
+
The footer's session-start account prefetch uses the same policy with its own cache
|
|
271
|
+
(`hypercharm-account.json`, 2-minute window), and only in interactive sessions.
|
|
272
|
+
|
|
249
273
|
## Updating Models
|
|
250
274
|
|
|
251
275
|
Run the update script to fetch the latest models from the HyperCharm API:
|
package/identity.ts
CHANGED
|
@@ -27,6 +27,8 @@ export const PRISM_ENTRY_TYPE = `${PROVIDER_ID}-prism-route`;
|
|
|
27
27
|
|
|
28
28
|
export const CONFIG_FILE_NAME = `${PROVIDER_ID}.json`;
|
|
29
29
|
export const CACHE_FILE_NAME = `${PROVIDER_ID}-models.json`;
|
|
30
|
+
/** Cross-process account snapshot (balance/team/device expiry) shared by quiet startup sync. */
|
|
31
|
+
export const ACCOUNT_CACHE_FILE_NAME = `${PROVIDER_ID}-account.json`;
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
34
|
* Every distinct namespaced identifier this extension registers or writes.
|
|
@@ -45,5 +47,6 @@ export const IDENTIFIERS: readonly string[] = Object.freeze([
|
|
|
45
47
|
PRISM_ENTRY_TYPE,
|
|
46
48
|
CONFIG_FILE_NAME,
|
|
47
49
|
CACHE_FILE_NAME,
|
|
50
|
+
ACCOUNT_CACHE_FILE_NAME,
|
|
48
51
|
]),
|
|
49
52
|
]);
|
package/index.ts
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Model resolution strategy: Stale-While-Revalidate
|
|
14
14
|
* 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
|
|
15
|
-
* 2. Revalidate in background: live API /v1/
|
|
15
|
+
* 2. Revalidate in background: live API /v1/provider → merge with embedded → cache → hot-swap
|
|
16
|
+
* Quiet startup sync (sync-policy.ts): only with credentials, only when the
|
|
17
|
+
* disk cache is older than the TTL (1h, PI_PROVIDER_SYNC_TTL_MS), missing,
|
|
18
|
+
* or fetched under another credential identity (OAuth: the stored refresh
|
|
19
|
+
* token, since access tokens rotate), and only one pi process at a time
|
|
20
|
+
* (lock file next to the cache). A completed /login forces a revalidation.
|
|
16
21
|
* 3. patch.json + custom-models.json applied on top of whichever source won
|
|
17
22
|
*
|
|
18
23
|
* Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
|
|
@@ -40,7 +45,11 @@
|
|
|
40
45
|
* everything the moment the active model belongs to another provider.
|
|
41
46
|
* Credits/team are prefetched on session start or model select when a
|
|
42
47
|
* HyperCharm model is active, so the first turn ends with data already
|
|
43
|
-
* cached. The
|
|
48
|
+
* cached. The session-start prefetch runs only with a UI (never in print
|
|
49
|
+
* mode or child agents) and is shared across pi processes: the result is
|
|
50
|
+
* written to cache/hypercharm-account.json and processes that start within
|
|
51
|
+
* ACCOUNT_SYNC_TTL_MS (2 min) of it render that snapshot instead of
|
|
52
|
+
* polling (same lock/fingerprint policy as the model sync). The balance is polled again on pi's agent_settled event (fires
|
|
44
53
|
* only once no automatic retry,
|
|
45
54
|
* compaction, or queued continuation can follow) — and nowhere else, so
|
|
46
55
|
* sessions without HyperCharm turns make zero status-related API calls.
|
|
@@ -107,9 +116,11 @@
|
|
|
107
116
|
|
|
108
117
|
import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
|
|
109
118
|
import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
|
|
119
|
+
import type { OAuthCredentials } from "@earendil-works/pi-ai";
|
|
110
120
|
import { Text } from "@earendil-works/pi-tui";
|
|
111
121
|
import { USER_AGENT, loginHypercharm, refreshHypercharmToken } from "./oauth";
|
|
112
122
|
import {
|
|
123
|
+
ACCOUNT_CACHE_FILE_NAME,
|
|
113
124
|
API_KEY_PLACEHOLDER,
|
|
114
125
|
API_NAME,
|
|
115
126
|
CACHE_FILE_NAME,
|
|
@@ -149,6 +160,14 @@ import {
|
|
|
149
160
|
import fs from "fs";
|
|
150
161
|
import { hostname } from "os";
|
|
151
162
|
import path from "path";
|
|
163
|
+
import {
|
|
164
|
+
acquireModelSync,
|
|
165
|
+
cacheChangedSinceLoad,
|
|
166
|
+
keyFingerprint,
|
|
167
|
+
markCacheLoaded,
|
|
168
|
+
syncMetaPath,
|
|
169
|
+
writeFileAtomic,
|
|
170
|
+
} from "./sync-policy";
|
|
152
171
|
|
|
153
172
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
154
173
|
|
|
@@ -388,7 +407,8 @@ function loadCachedModels(): JsonModel[] | null {
|
|
|
388
407
|
function cacheModels(models: JsonModel[]): void {
|
|
389
408
|
try {
|
|
390
409
|
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
391
|
-
|
|
410
|
+
// Atomic (tmp + rename): other pi processes may be reading the cache right now.
|
|
411
|
+
writeFileAtomic(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
|
|
392
412
|
} catch (err) {
|
|
393
413
|
// Non-fatal: the freshly fetched catalog still serves this session.
|
|
394
414
|
notifier.warn(`Could not write the HyperCharm model cache to ${CACHE_PATH}: ${describeError(err)}.`);
|
|
@@ -455,6 +475,7 @@ function withDeprecated(models: JsonModel[]): JsonModel[] {
|
|
|
455
475
|
}
|
|
456
476
|
|
|
457
477
|
function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
478
|
+
markCacheLoaded(CACHE_PATH); // stat before read: lets session_start spot newer caches
|
|
458
479
|
const cached = loadCachedModels();
|
|
459
480
|
if (!cached || cached.length === 0) return embeddedModels;
|
|
460
481
|
|
|
@@ -468,24 +489,77 @@ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
|
|
|
468
489
|
return cached;
|
|
469
490
|
}
|
|
470
491
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
492
|
+
/**
|
|
493
|
+
* Background revalidation, gated by the quiet startup sync policy
|
|
494
|
+
* (sync-policy.ts): no credentials → no fetch; fresh cache (TTL) → no fetch;
|
|
495
|
+
* another process already fetching → no fetch. When skipped, a cache that
|
|
496
|
+
* another process refreshed since we loaded it is adopted from disk instead.
|
|
497
|
+
* `force` bypasses the TTL (e.g. after /login); it is still single-flight.
|
|
498
|
+
*
|
|
499
|
+
* `identity` is the stable credential identity used only for presence and the
|
|
500
|
+
* fingerprint (see credentialIdentity); `apiKey` is the bearer token we fetch with.
|
|
501
|
+
*/
|
|
502
|
+
async function revalidateModels(
|
|
503
|
+
apiKey: string | undefined,
|
|
504
|
+
identity: string | undefined,
|
|
505
|
+
embeddedModels: JsonModel[],
|
|
506
|
+
signal?: AbortSignal,
|
|
507
|
+
force = false,
|
|
508
|
+
): Promise<JsonModel[] | null> {
|
|
509
|
+
const lease = acquireModelSync({ cachePath: CACHE_PATH, apiKey: apiKey ? identity : undefined, force });
|
|
510
|
+
if (!lease) return cacheChangedSinceLoad(CACHE_PATH) ? loadStaleModels(embeddedModels) : null;
|
|
511
|
+
let merged: JsonModel[] | null = null;
|
|
512
|
+
try {
|
|
513
|
+
const liveModels = apiKey ? await fetchLiveModels(apiKey, signal) : null;
|
|
514
|
+
if (!liveModels || liveModels.length === 0) return null;
|
|
515
|
+
merged = mergeWithEmbedded(liveModels, embeddedModels);
|
|
516
|
+
cacheModels(merged);
|
|
517
|
+
return merged;
|
|
518
|
+
} finally {
|
|
519
|
+
lease.release(merged ? "fetched" : signal?.aborted ? "aborted" : "failed");
|
|
520
|
+
}
|
|
478
521
|
}
|
|
479
522
|
|
|
480
523
|
// ─── API Key Resolution (via ModelRegistry) ────────────────────────────────────
|
|
481
524
|
|
|
482
525
|
let cachedApiKey: string | undefined;
|
|
526
|
+
// Stable identity behind cachedApiKey for the quiet-sync fingerprints.
|
|
527
|
+
let cachedSyncIdentity: string | undefined;
|
|
483
528
|
let revalidateAbort: AbortController | null = null;
|
|
484
529
|
|
|
530
|
+
const AUTH_PATH = path.join(getAgentDir(), "auth.json");
|
|
531
|
+
|
|
532
|
+
const oauthIdentity = (refresh: string): string => `oauth:${refresh}`;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Stable credential identity for the quiet-sync fingerprint. OAuth access
|
|
536
|
+
* tokens rotate on every token refresh, so fingerprinting the resolved key
|
|
537
|
+
* would look like an account change each time. For an OAuth entry in
|
|
538
|
+
* auth.json use its refresh token instead (it changes on /login); API keys —
|
|
539
|
+
* auth.json, --api-key, HYPERCHARM_API_KEY — are static, so use the key itself.
|
|
540
|
+
* Only ever hashed (sync-policy keyFingerprint), never stored.
|
|
541
|
+
*/
|
|
542
|
+
function credentialIdentity(apiKey: string | undefined): string | undefined {
|
|
543
|
+
if (!apiKey) return undefined;
|
|
544
|
+
try {
|
|
545
|
+
const stored = JSON.parse(fs.readFileSync(AUTH_PATH, "utf8"))?.[PROVIDER_ID];
|
|
546
|
+
if (stored?.type === "oauth" && typeof stored.refresh === "string" && stored.refresh) {
|
|
547
|
+
return oauthIdentity(stored.refresh);
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// No/unreadable auth.json: the key resolved from elsewhere (env, --api-key).
|
|
551
|
+
}
|
|
552
|
+
return apiKey;
|
|
553
|
+
}
|
|
554
|
+
|
|
485
555
|
async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
|
|
486
556
|
cachedApiKey = await modelRegistry.getApiKeyForProvider(PROVIDER_ID) ?? undefined;
|
|
557
|
+
cachedSyncIdentity = credentialIdentity(cachedApiKey);
|
|
487
558
|
}
|
|
488
559
|
|
|
560
|
+
// Set by the extension entry point: runs after a /login completes.
|
|
561
|
+
let onLoginComplete: ((credentials: OAuthCredentials) => void) | null = null;
|
|
562
|
+
|
|
489
563
|
// ─── Status Display Configuration ──────────────────────────────────────────────
|
|
490
564
|
|
|
491
565
|
const CONFIG_PATH = path.join(getAgentDir(), "extensions", "hypercharm.json");
|
|
@@ -698,6 +772,12 @@ let statusEpoch = 0;
|
|
|
698
772
|
let lastCreditsFetchAt = 0;
|
|
699
773
|
let creditsInFlight: Promise<void> | null = null;
|
|
700
774
|
let metaFetched = false;
|
|
775
|
+
// Bumped on every successful /v1/credits read (lets the startup sync tell a
|
|
776
|
+
// fresh balance from one adopted off disk).
|
|
777
|
+
let creditsOkCount = 0;
|
|
778
|
+
// Absolute OAuth device-session expiry behind account.authDaysLeft (persisted
|
|
779
|
+
// in the account snapshot so other processes recompute days-left correctly).
|
|
780
|
+
let authExpiresAtMs: number | null = null;
|
|
701
781
|
|
|
702
782
|
async function fetchJsonGet(url: string, apiKey: string, signal: AbortSignal | undefined, label: string): Promise<any | null> {
|
|
703
783
|
try {
|
|
@@ -735,6 +815,7 @@ function refreshCredits(apiKey: string | undefined, signal: AbortSignal | undefi
|
|
|
735
815
|
const rawBalance = typeof data?.balance === "number" ? data.balance : typeof data?.balance_usd === "number" ? data.balance_usd * 20 : undefined;
|
|
736
816
|
if (typeof rawBalance === "number" && Number.isFinite(rawBalance)) {
|
|
737
817
|
account.balance = rawBalance;
|
|
818
|
+
creditsOkCount += 1;
|
|
738
819
|
}
|
|
739
820
|
} finally {
|
|
740
821
|
creditsInFlight = null;
|
|
@@ -764,13 +845,104 @@ async function refreshAccountMeta(apiKey: string | undefined, signal?: AbortSign
|
|
|
764
845
|
const own = devices.items.find((d: any) => typeof d?.name === "string" && d.name === `Pi (${hostname()})`);
|
|
765
846
|
const expMs = own ? Date.parse(own.expires_at ?? "") : NaN;
|
|
766
847
|
if (!Number.isNaN(expMs)) {
|
|
767
|
-
|
|
848
|
+
authExpiresAtMs = expMs;
|
|
849
|
+
account.authDaysLeft = daysUntil(expMs);
|
|
768
850
|
}
|
|
769
851
|
}
|
|
770
852
|
|
|
771
853
|
if (account.teamName !== null || account.authDaysLeft !== null) metaFetched = true;
|
|
772
854
|
}
|
|
773
855
|
|
|
856
|
+
function daysUntil(ms: number): number {
|
|
857
|
+
return Math.max(0, Math.ceil((ms - Date.now()) / 86_400_000));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ─── Quiet Startup Account Sync ──────────────────────────────────────────────
|
|
861
|
+
// Every pi process with a UI used to poll /v1/credits + /v1/teams + /v1/devices
|
|
862
|
+
// on session_start. The startup prefetch now goes through the same
|
|
863
|
+
// cross-process policy as the model sync (sync-policy.ts), with its own cache:
|
|
864
|
+
// one process polls and writes cache/hypercharm-account.json, every other
|
|
865
|
+
// process starting within ACCOUNT_SYNC_TTL_MS renders that snapshot from disk.
|
|
866
|
+
// Turn-triggered polls (model_select, agent_settled, 402, /hypercharm-status
|
|
867
|
+
// refresh) are unchanged and stay throttled per process.
|
|
868
|
+
|
|
869
|
+
const ACCOUNT_CACHE_PATH = path.join(CACHE_DIR, ACCOUNT_CACHE_FILE_NAME);
|
|
870
|
+
/**
|
|
871
|
+
* Freshness window for the shared startup account snapshot. 2 minutes: long
|
|
872
|
+
* enough to collapse /new, /reload and fan-out bursts into one poll, short
|
|
873
|
+
* enough that a session started later shows a current balance. The balance
|
|
874
|
+
* also moves optimistically per turn and agent_settled reconciles it.
|
|
875
|
+
*/
|
|
876
|
+
const ACCOUNT_SYNC_TTL_MS = 2 * 60 * 1000;
|
|
877
|
+
|
|
878
|
+
interface AccountSnapshot {
|
|
879
|
+
version: 1;
|
|
880
|
+
balance: number | null;
|
|
881
|
+
teamName: string | null;
|
|
882
|
+
authExpiresAt: number | null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Render a snapshot another process wrote, if it belongs to the same credential identity. */
|
|
886
|
+
function adoptAccountSnapshot(identity: string): void {
|
|
887
|
+
try {
|
|
888
|
+
const meta = JSON.parse(fs.readFileSync(syncMetaPath(ACCOUNT_CACHE_PATH), "utf8"));
|
|
889
|
+
if (meta?.keyFingerprint !== keyFingerprint(identity)) return;
|
|
890
|
+
const snap = JSON.parse(fs.readFileSync(ACCOUNT_CACHE_PATH, "utf8")) as Partial<AccountSnapshot> | null;
|
|
891
|
+
if (!snap || typeof snap !== "object") return;
|
|
892
|
+
if (typeof snap.balance === "number" && Number.isFinite(snap.balance)) account.balance = snap.balance;
|
|
893
|
+
if (typeof snap.teamName === "string" && snap.teamName.trim()) account.teamName = snap.teamName.trim();
|
|
894
|
+
if (typeof snap.authExpiresAt === "number" && Number.isFinite(snap.authExpiresAt)) {
|
|
895
|
+
authExpiresAtMs = snap.authExpiresAt;
|
|
896
|
+
account.authDaysLeft = daysUntil(snap.authExpiresAt);
|
|
897
|
+
}
|
|
898
|
+
if (account.teamName !== null || account.authDaysLeft !== null) metaFetched = true;
|
|
899
|
+
} catch {
|
|
900
|
+
// Missing/corrupt snapshot: nothing to adopt.
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function writeAccountSnapshot(): void {
|
|
905
|
+
const snap: AccountSnapshot = {
|
|
906
|
+
version: 1,
|
|
907
|
+
balance: account.balance,
|
|
908
|
+
teamName: account.teamName,
|
|
909
|
+
authExpiresAt: authExpiresAtMs,
|
|
910
|
+
};
|
|
911
|
+
try {
|
|
912
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
913
|
+
writeFileAtomic(ACCOUNT_CACHE_PATH, JSON.stringify(snap) + "\n");
|
|
914
|
+
} catch {
|
|
915
|
+
// Non-fatal: other processes just poll themselves.
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Session-start account prefetch: adopt the shared snapshot (synchronously,
|
|
921
|
+
* before the first await, so the caller can render it at once), then poll only
|
|
922
|
+
* if this process wins the lease. Callers gate on ctx.hasUI.
|
|
923
|
+
*/
|
|
924
|
+
async function startupAccountSync(apiKey: string | undefined, identity: string | undefined, signal: AbortSignal): Promise<void> {
|
|
925
|
+
if (!apiKey || !identity) return;
|
|
926
|
+
adoptAccountSnapshot(identity);
|
|
927
|
+
const lease = acquireModelSync({
|
|
928
|
+
cachePath: ACCOUNT_CACHE_PATH,
|
|
929
|
+
apiKey: identity,
|
|
930
|
+
ttlMs: ACCOUNT_SYNC_TTL_MS,
|
|
931
|
+
isValidCache: (d) => d != null,
|
|
932
|
+
});
|
|
933
|
+
if (!lease) return;
|
|
934
|
+
let ok = false;
|
|
935
|
+
try {
|
|
936
|
+
const before = creditsOkCount;
|
|
937
|
+
metaFetched = false;
|
|
938
|
+
await Promise.all([refreshCredits(apiKey, signal, true), refreshAccountMeta(apiKey, signal)]);
|
|
939
|
+
ok = !signal.aborted && creditsOkCount > before;
|
|
940
|
+
if (ok) writeAccountSnapshot();
|
|
941
|
+
} finally {
|
|
942
|
+
lease.release(ok ? "fetched" : signal.aborted ? "aborted" : "failed");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
774
946
|
// ─── Status Rendering ─────────────────────────────────────────────────────────
|
|
775
947
|
|
|
776
948
|
function currentProviderId(ctx: ExtensionContext): string | undefined {
|
|
@@ -882,6 +1054,7 @@ function resetStatusState(): void {
|
|
|
882
1054
|
outOfCreditsNotified = false;
|
|
883
1055
|
lastCreditsFetchAt = 0;
|
|
884
1056
|
metaFetched = false;
|
|
1057
|
+
authExpiresAtMs = null;
|
|
885
1058
|
}
|
|
886
1059
|
|
|
887
1060
|
/** Commit per-turn pending capture into session state (after tees settle). */
|
|
@@ -1117,7 +1290,11 @@ function makeProviderConfig(models: JsonModel[] = currentModels) {
|
|
|
1117
1290
|
streamSimple: streamHypercharm,
|
|
1118
1291
|
oauth: {
|
|
1119
1292
|
name: PROVIDER_DISPLAY_NAME,
|
|
1120
|
-
login: (callbacks) =>
|
|
1293
|
+
login: async (callbacks) => {
|
|
1294
|
+
const credentials = await loginHypercharm(callbacks);
|
|
1295
|
+
onLoginComplete?.(credentials);
|
|
1296
|
+
return credentials;
|
|
1297
|
+
},
|
|
1121
1298
|
refreshToken: (credentials, signal) => refreshHypercharmToken(credentials, signal),
|
|
1122
1299
|
getApiKey: (credentials) => String(credentials.access ?? ""),
|
|
1123
1300
|
},
|
|
@@ -1139,6 +1316,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
1139
1316
|
|
|
1140
1317
|
pi.registerProvider(PROVIDER_ID, makeProviderConfig(staleModels));
|
|
1141
1318
|
|
|
1319
|
+
// Revalidate the catalog (quiet startup sync policy) and hot-swap on success.
|
|
1320
|
+
const syncModels = (epoch: number, signal: AbortSignal | undefined, force: boolean): Promise<void> =>
|
|
1321
|
+
revalidateModels(cachedApiKey, cachedSyncIdentity, embeddedModels, signal, force).then((freshBase) => {
|
|
1322
|
+
if (freshBase && epoch === statusEpoch && !signal?.aborted) {
|
|
1323
|
+
currentModels = buildModels(freshBase, customModels, patches);
|
|
1324
|
+
pi.registerProvider(PROVIDER_ID, makeProviderConfig());
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
|
|
1328
|
+
// A completed /login means new credentials: adopt them and force a
|
|
1329
|
+
// revalidation (still single-flight). Deferred so pi persists the
|
|
1330
|
+
// credential first; never blocks or fails the login itself.
|
|
1331
|
+
onLoginComplete = (credentials) => {
|
|
1332
|
+
const access = String(credentials.access ?? "");
|
|
1333
|
+
if (!access) return;
|
|
1334
|
+
cachedApiKey = access;
|
|
1335
|
+
cachedSyncIdentity = typeof credentials.refresh === "string" && credentials.refresh ? oauthIdentity(credentials.refresh) : access;
|
|
1336
|
+
// New account: team/device metadata and the throttle start over.
|
|
1337
|
+
metaFetched = false;
|
|
1338
|
+
lastCreditsFetchAt = 0;
|
|
1339
|
+
const epoch = statusEpoch;
|
|
1340
|
+
setTimeout(() => {
|
|
1341
|
+
void syncModels(epoch, revalidateAbort?.signal, true).catch(() => {});
|
|
1342
|
+
}, 0);
|
|
1343
|
+
};
|
|
1344
|
+
|
|
1142
1345
|
pi.registerCommand(STATUS_COMMAND, {
|
|
1143
1346
|
description: "Configure the HyperCharm footer status (session spend, balance, rate limits)",
|
|
1144
1347
|
handler: async (args, ctx) => {
|
|
@@ -1163,31 +1366,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
1163
1366
|
// over anything that touched provider registration during load.
|
|
1164
1367
|
pi.registerProvider(PROVIDER_ID, makeProviderConfig());
|
|
1165
1368
|
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
|
|
1369
|
+
// Fire-and-forget: never await network here — startup must not block on
|
|
1370
|
+
// the provider API. Nothing is awaited and every rejection is swallowed.
|
|
1371
|
+
// A key-resolution failure is surfaced (it used to vanish), then we keep
|
|
1372
|
+
// serving the embedded/cached catalog.
|
|
1373
|
+
void resolveApiKey(ctx.modelRegistry)
|
|
1170
1374
|
.catch((err) => {
|
|
1171
1375
|
notifier.warn(`Unable to resolve HyperCharm credentials: ${describeError(err)} — serving cached/embedded models.`);
|
|
1172
1376
|
})
|
|
1173
1377
|
.then(() => {
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
|
|
1185
|
-
if (freshBase && epoch === statusEpoch && !signal.aborted) {
|
|
1186
|
-
currentModels = buildModels(freshBase, customModels, patches);
|
|
1187
|
-
pi.registerProvider(PROVIDER_ID, makeProviderConfig());
|
|
1378
|
+
// A session replacement while the key resolved invalidated the
|
|
1379
|
+
// captured ctx (fast-resume, /new, /fork); nothing below may touch it.
|
|
1380
|
+
if (epoch !== statusEpoch) return;
|
|
1381
|
+
// Prefetch credits/team metadata only when a HyperCharm model is
|
|
1382
|
+
// active and there is a UI to show it (print mode and child agents
|
|
1383
|
+
// never render the footer), deduped across pi processes.
|
|
1384
|
+
if (ctx.hasUI && currentProviderId(ctx) === PROVIDER_ID) {
|
|
1385
|
+
const accountSync = startupAccountSync(cachedApiKey, cachedSyncIdentity, statusSignal);
|
|
1386
|
+
updateStatus(ctx); // renders an adopted snapshot right away
|
|
1387
|
+
updateStatusAfter(accountSync.catch(() => {}), ctx);
|
|
1188
1388
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1389
|
+
return syncModels(epoch, signal, false);
|
|
1390
|
+
})
|
|
1391
|
+
.catch(() => { /* keep serving the stale catalog */ });
|
|
1191
1392
|
});
|
|
1192
1393
|
|
|
1193
1394
|
pi.on("model_select", (event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hypercharm-provider",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.40",
|
|
4
4
|
"description": "HyperCharm provider extension for pi - Access DeepSeek, GLM, Kimi, Qwen, MiniMax, Gemma, and GPT-OSS models through the Charm Hyper API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"clean": "echo 'nothing to clean'",
|
|
9
|
+
"build": "echo 'nothing to build'",
|
|
10
|
+
"check": "tsc --noEmit && npm run smoke && npm test",
|
|
11
|
+
"smoke": "node tests/status.smoke.ts",
|
|
12
|
+
"test": "node --import jiti/register --test tests/identity.test.ts tests/prism.test.ts tests/notify.test.ts tests/provider.integration.test.ts && vitest run",
|
|
13
|
+
"update-models": "node scripts/update-models.js",
|
|
14
|
+
"test:pi": "node scripts/test-pi-compat.mjs"
|
|
15
|
+
},
|
|
7
16
|
"keywords": [
|
|
8
17
|
"pi",
|
|
9
18
|
"extension",
|
|
@@ -35,7 +44,8 @@
|
|
|
35
44
|
"@earendil-works/pi-tui": "1.1.0",
|
|
36
45
|
"@types/node": "^24.10.1",
|
|
37
46
|
"jiti": "2.7.0",
|
|
38
|
-
"typescript": "6.0.3"
|
|
47
|
+
"typescript": "6.0.3",
|
|
48
|
+
"vitest": "4.1.7"
|
|
39
49
|
},
|
|
40
50
|
"author": "",
|
|
41
51
|
"license": "MIT",
|
|
@@ -43,14 +53,5 @@
|
|
|
43
53
|
"extensions": [
|
|
44
54
|
"./index.ts"
|
|
45
55
|
]
|
|
46
|
-
},
|
|
47
|
-
"scripts": {
|
|
48
|
-
"clean": "echo 'nothing to clean'",
|
|
49
|
-
"build": "echo 'nothing to build'",
|
|
50
|
-
"check": "tsc --noEmit && npm run smoke && npm test",
|
|
51
|
-
"smoke": "node tests/status.smoke.ts",
|
|
52
|
-
"test": "node --import jiti/register --test tests/identity.test.ts tests/prism.test.ts tests/notify.test.ts tests/provider.integration.test.ts",
|
|
53
|
-
"update-models": "node scripts/update-models.js",
|
|
54
|
-
"test:pi": "node scripts/test-pi-compat.mjs"
|
|
55
56
|
}
|
|
56
|
-
}
|
|
57
|
+
}
|