pi-workbuddy 0.2.2 → 0.2.4
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 +64 -7
- package/extensions/workbuddy.js +7 -0
- package/integrations/pendant/README.md +174 -0
- package/integrations/pendant/prepare.mjs +86 -0
- package/package.json +3 -1
- package/src/client-identity.js +1 -1
- package/src/native-catalog-cache.js +38 -0
- package/src/native-client.js +33 -2
- package/src/native-credit-packages.js +117 -0
- package/src/native-credits.js +175 -0
- package/src/native-errors.js +6 -0
- package/src/native-lifecycle.js +52 -0
- package/src/native-provider.js +31 -5
- package/src/native-tooltip-controller.js +90 -0
- package/src/pendant-tooltip.js +105 -0
package/README.md
CHANGED
|
@@ -6,15 +6,15 @@ A **WorkBuddy AI** provider for [Pi](https://pi.dev), with browser-based account
|
|
|
6
6
|
|
|
7
7
|
## Getting started
|
|
8
8
|
|
|
9
|
-
Requires Node.js ≥22.19 and Pi with native provider support. Tested with Pi CLI **1.3.0**.
|
|
9
|
+
Requires Node.js ≥22.19 and Pi with native provider support. Tested with Pi CLI **1.3.0** and Pendant 0.41.0's bundled Pi SDK **1.2.0**.
|
|
10
10
|
|
|
11
|
-
Install the npm package
|
|
11
|
+
Install the published npm package:
|
|
12
12
|
|
|
13
13
|
```sh
|
|
14
14
|
pi install npm:pi-workbuddy
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Or install directly from GitHub:
|
|
18
18
|
|
|
19
19
|
```sh
|
|
20
20
|
pi install git:github.com/lutfi238/pi-workbuddy
|
|
@@ -48,9 +48,10 @@ Use `/reload` after updating a loaded extension. To sign out, run `/logout` and
|
|
|
48
48
|
| `/logout` → WorkBuddy AI | Remove Pi's local WorkBuddy credentials |
|
|
49
49
|
| `/workbuddy status` or `/workbuddy doctor` | Show local configuration, catalog source, active model, and the last sanitized catalog error; no network requests |
|
|
50
50
|
| `/workbuddy refresh` | Fetch the authenticated catalog, refreshing the token if needed |
|
|
51
|
+
| `/workbuddy-tooltip on/off/refresh/status` | Experimental opt-in credit relay; requires the separately reviewed Pendant host adapter |
|
|
51
52
|
| `/model` | Refresh model catalogs in the background and select a model |
|
|
52
53
|
|
|
53
|
-
Before discovery, the provider includes
|
|
54
|
+
Before discovery or cache restoration, the provider includes fallback metadata for `default-model`. Models are not available until authentication is configured. Successful catalogs are persisted through **Pi's model store** as projected public metadata, scoped by a hash of the account origin/user/domain/tenant (never access or refresh tokens). Cached models are revalidated on restoration, including removal of DeepSeek `-sg` variants. A failed refresh retains the last known catalog. The extension factory itself makes no network requests.
|
|
54
55
|
|
|
55
56
|
### Automatic model discovery
|
|
56
57
|
|
|
@@ -58,7 +59,19 @@ There is no hardcoded allowlist of WorkBuddy model IDs. The provider fetches `/v
|
|
|
58
59
|
|
|
59
60
|
A newly advertised model can therefore appear without changing the extension or adding its ID manually. The model must pass the existing compatibility and security filters: disabled models, models explicitly lacking tool support, and custom endpoints/protocols/API keys are excluded. Starting with 0.2.2, DeepSeek variants ending in `-sg` are intentionally excluded, including after catalog refresh; the regular DeepSeek models remain available. Catalog visibility does not guarantee that your account can run every listed model.
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
A `session_start` hook refreshes only WorkBuddy using the registry's default network policy and honors `PI_OFFLINE` (also set by Pi CLI `--offline`). This covers embedded hosts that re-register a provider but do not refetch an already-seen provider ID. Offline provider refreshes can restore a matching cached catalog without network access.
|
|
63
|
+
|
|
64
|
+
This is refresh-based discovery, not a realtime push subscription or continuous idle polling. Reopen `/model` to check for additions during a long session. Extension updates may still be necessary if WorkBuddy changes its API, authentication, metadata schema, or wire protocol.
|
|
65
|
+
|
|
66
|
+
### Pendant / VS Code stays on WorkBuddy Default (fallback)
|
|
67
|
+
|
|
68
|
+
Version **0.2.3** restores cached catalogs after provider re-registration and refreshes WorkBuddy at session startup. It also replaces an active bootstrap fallback with fresh metadata for the same ID. For resumed sessions, it reads only the latest model-change record; a removed DeepSeek `-sg` selection is restored to its exact non-SG counterpart if available. It does not change another provider, overwrite a newer observed selection, edit global defaults, or send a chat request. `/workbuddy refresh` also repairs stale active-model metadata.
|
|
69
|
+
|
|
70
|
+
After the 0.2.3 release is published, update the package used by the host and reload the session. Restarting an old installed copy will not pick up repository changes. `/workbuddy doctor` reports the loaded extension version and `catalog=fallback`, `cache`, or `remote`; share only this sanitized diagnostic, never credentials. A genuine network/authentication error with no usable cache still requires resolving the error; no fabricated catalog is substituted.
|
|
71
|
+
|
|
72
|
+
### Package published, but absent from gallery search
|
|
73
|
+
|
|
74
|
+
The [direct package page](https://pi.dev/packages/pi-workbuddy) can work before the searchable gallery includes the package. npm publication and Pi gallery discovery are separate. Confirm the public npm version and the `pi-package` keyword before assuming a publishing failure. See the [reported npm-search discovery issue](https://github.com/earendil-works/pi/issues/7885). Do not repeatedly publish identical releases just to try to trigger gallery indexing.
|
|
62
75
|
|
|
63
76
|
### Login succeeds, but discovery fails or Codex remains selected
|
|
64
77
|
|
|
@@ -105,6 +118,40 @@ Smaller explicit output requests remain supported; sampling and payload hooks ca
|
|
|
105
118
|
|
|
106
119
|
See the current [capability policy](research/capability-policy.md), [vendor references](research/vendor-capability-comparison.md), and historical [context-limit audit](research/context-limit-audit.md). No global Pi compaction settings are changed by the extension.
|
|
107
120
|
|
|
121
|
+
### Read-only credit-reader backend
|
|
122
|
+
|
|
123
|
+
Version **0.2.4** adds `createCreditsReader()` in `src/native-credits.js` for the verified personal-account endpoint `POST /billing/meter/get-user-resource-summary` with body `{}`. The reader itself does not run automatically. An opt-in experimental Pendant relay/adapter is available separately below; no installed host is modified automatically.
|
|
124
|
+
|
|
125
|
+
The reader returns `creditsTotal`, `creditsRemaining`, `creditsUsed`, `packageCount`, and `fetchedAt`; optional `planCode`, `planName`, and `isPaidUser` are included only when supplied with the expected types. Invalid/missing numeric fields stay `null`, not zero. The default client also returns immutable `packageDetails` (`state`, `count`, `items`) from the verified paid/free package read routes. Each resource has a reviewed kind, precise total/remaining/used credits, and its own supplied expiry. Different grants of the same product stay separate. Missing dates stay unknown; offset-less dates retain an explicit server-time label. `packageCount` is the legacy summary-group count, not necessarily the number of individual grants. No subscription renewal date, USD value, or allowance is inferred. The account balance is not a separate allowance for each model.
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
import { createCreditsReader } from "./src/native-credits.js";
|
|
129
|
+
|
|
130
|
+
const reader = createCreditsReader();
|
|
131
|
+
// credential is the current WorkBuddy OAuth credential supplied by the caller's Pi auth flow.
|
|
132
|
+
const result = await reader.read(credential, { signal });
|
|
133
|
+
// result.source: "remote" or "cache"; result.stale explicitly marks old/failed-refresh data.
|
|
134
|
+
await reader.read(credential, { signal, force: true });
|
|
135
|
+
await reader.read(credential, { allowNetwork: false });
|
|
136
|
+
reader.clear(); // Required when a future UI/controller logs out or disposes this reader.
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The default cache TTL is **60 seconds**, with at most **8 account scopes** and **8 concurrent underlying reads**. Cache keys use hashed account identity/domain, never tokens. Token rotation retains the same account cache. Cache entries are memory-only and are never added to the persistent model catalog. Callers receive independent snapshots; repeated concurrent reads share one bounded read job. A fresh job reads the summary and relevant paid/free package pages, at most ten pages of 200 records per route, under a shared detail deadline. Detail failures are explicitly partial/unavailable and do not make old details look fresh. Cancelling one caller leaves other subscribers running; cancelling the last caller or clearing the reader aborts the request and prevents late cache writes.
|
|
140
|
+
|
|
141
|
+
Transient failures may return last-good data with `stale: true` and a sanitized `error`. Authentication/business denials discard the balance rather than returning stale data. Offline reads and `PI_OFFLINE` never trigger a fetch; without a matching cached value they report unavailable. `force` bypasses freshness, not offline policy. `ttlMs` and `maxEntries` can be configured when constructing the reader.
|
|
142
|
+
|
|
143
|
+
This layer never performs login or token refresh itself: its caller must supply current Pi-owned credentials. It never claims rewards, buys credits, changes subscriptions, or sends chat. The enterprise usage route has not been verified and is deliberately not substituted with a potentially misleading personal balance; enterprise-scoped calls report unsupported for now. See [endpoint verification](research/credits-endpoint-verification.md) and [tooltip findings](research/model-tooltip-findings.md) for integration boundaries.
|
|
144
|
+
|
|
145
|
+
### Optional Pendant credit tooltip (experimental)
|
|
146
|
+
|
|
147
|
+
The exact native model-hover integration requires a host adapter in Pendant **0.41.0**, not just provider metadata. See [the preparation/review guide](integrations/pendant/README.md). The included `prepare.mjs` validates the inspected host version and hash, checks JavaScript syntax, and produces separate modified renderer/broker copies and a manifest. It **never applies** that copy to your installed VS Code extension. Generated vendor bundles are not shipped in npm or committed to Git.
|
|
148
|
+
|
|
149
|
+
After an explicitly approved adapter installation, `/workbuddy-tooltip on` enables a session-scoped credit relay over Pi's normal status API. The adapter consumes that reserved payload inside the existing native model tooltip instead of displaying an extra status badge. `/workbuddy-tooltip refresh`, `status`, and `off` manage the relay. It is disabled by default; normal CLI behavior is unchanged. Do not enable it in an unpatched host. Persistent opt-in after installing the adapter uses `WORKBUDDY_PENDANT_TOOLTIP=1` in the host process environment.
|
|
150
|
+
|
|
151
|
+
Adapter v2 also fixes Pendant's default `extensionStatus.surface=off` transport gate: it forwards only the reserved WorkBuddy payload while leaving other status visibility and user settings unchanged. It hides the encoded transport from VS Code's separate status bar. Install both prepared host bundles and use **Developer: Reload Window**; Pi `/reload` alone does not reload the host broker. `/workbuddy-tooltip status` reports whether the relay payload is waiting, loading, ready, or unavailable without querying credits.
|
|
152
|
+
|
|
153
|
+
Rows show a remaining-credit summary, then separate base/bonus/top-up grants with `remaining / total left` and each grant's expiry, plus update time, optional plan name, and stale/partial markers. At most four grants are previewed (base/daily first, then earliest known expiry); omitted loaded grants have an explicit count. Without usable details, aggregate used/total and credit-group count remain as a labeled fallback. Fresh remaining credits, including each grant, reuse the native OpenAI color thresholds: green at ≥50%, yellow at 15–<50%, red below 15%. Unknown or stale balances are not painted as current healthy quota; other model/account rows stay neutral. No ChatGPT five-hour/weekly allowances, renewal dates, percentages, or USD values are fabricated. Read events use the private reader's cache and cancellation; hovering does not send a chat or start repeated network requests. Tokens and account identity never cross into the webview. Isolated tests cover the patched renderer and the actual shipped SDK transport. The operator also confirmed the native hover, colors, individual grants, and expiry dates visually in the backed-up local Pendant 0.41.0 preview. That confirmation does not establish compatibility with other host versions.
|
|
154
|
+
|
|
108
155
|
### Native configuration
|
|
109
156
|
|
|
110
157
|
```powershell
|
|
@@ -124,7 +171,7 @@ The `.env.example` file is a reference only: Pi and this extension do **not** au
|
|
|
124
171
|
|
|
125
172
|
- Pi stores credentials in its agent profile (`auth.json`), not in the project. Protect that file; never commit or share it.
|
|
126
173
|
- The extension does not read the desktop application's credentials, databases, cookies, or sessions.
|
|
127
|
-
- Authentication, discovery, and chat requests target only the HTTPS origin `www.workbuddy.ai`; HTTP redirects are rejected. Recognized production WorkBuddy/CodeBuddy account domains are used only as header values, not destination URLs.
|
|
174
|
+
- Authentication, discovery, credit reads, and chat requests target only the HTTPS origin `www.workbuddy.ai`; HTTP redirects are rejected. Recognized production WorkBuddy/CodeBuddy account domains are used only as header values, not destination URLs.
|
|
128
175
|
- Browser login URLs must use recognized production WorkBuddy/CodeBuddy origins. Login URLs can contain sensitive state; do not share them or screenshots containing them.
|
|
129
176
|
- Remote configuration cannot import prompts, tools, commands, credentials, or model URLs into this extension. Discovery does not send a list of repositories.
|
|
130
177
|
- `X-Private-Data: true` is a conservative privacy default, **not a guarantee** of the server's retention policy.
|
|
@@ -182,13 +229,23 @@ npm test
|
|
|
182
229
|
node scripts/smoke-cli.mjs "C:/path/to/pi-coding-agent/dist/bundle/cli.js"
|
|
183
230
|
```
|
|
184
231
|
|
|
232
|
+
To additionally test against an installed Pendant bundle (no live credentials or inference):
|
|
233
|
+
|
|
234
|
+
```powershell
|
|
235
|
+
$env:PI_SDK_TEST_PATH = "C:/path/to/pendant/dist/pi-sdk.cjs"
|
|
236
|
+
npm test
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Pendant integration tests instantiate its actual `ModelRuntime`, resource services, session manager, and `session_start` bindings. They cover provider re-registration, offline cache restoration in a fresh runtime, startup fallback repair, and migration of a saved DeepSeek SG selection. Cache tests cover account isolation, secret/endpoint stripping, malformed entries, failed refreshes, and generation rejection.
|
|
240
|
+
|
|
185
241
|
Host tests cover coordinated login, storage and refresh, tool-call/result round trips, reasoning, Unicode, usage tails, instrumentation, empty and truncated streams, errors, and cancellation. The CLI smoke test uses an offline temporary profile with synthetic credentials and removes it afterward. **None of these automated tests sends a prompt to the live WorkBuddy service.**
|
|
186
242
|
|
|
187
243
|
Local investigation notes:
|
|
188
244
|
[authentication](research/workbuddy-auth-findings.md) ·
|
|
189
245
|
[chat protocol](research/workbuddy-protocol-findings.md) ·
|
|
190
246
|
[error 12403](research/12403-findings.md) ·
|
|
191
|
-
[context-limit audit](research/context-limit-audit.md)
|
|
247
|
+
[context-limit audit](research/context-limit-audit.md) ·
|
|
248
|
+
[Pendant recovery and gallery findings](research/pendant-recovery-findings.md).
|
|
192
249
|
|
|
193
250
|
These notes are not included in the npm tarball. Raw third-party snapshots and generated evidence remain local and are excluded from Git; they are preserved verbatim rather than translated so their provenance and hashes remain valid. The authored documentation, code comments, user-facing messages, and test fixtures use English. Emoji in test fixtures intentionally exercise Unicode streaming.
|
|
194
251
|
|
package/extensions/workbuddy.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
|
|
2
2
|
import bridge from "../src/bridge-extension.js";
|
|
3
3
|
import { createWorkbuddyProvider, registerNativeCommands } from "../src/native-provider.js";
|
|
4
|
+
import { registerNativeLifecycle } from "../src/native-lifecycle.js";
|
|
5
|
+
import { registerPendantTooltip } from "../src/native-tooltip-controller.js";
|
|
4
6
|
|
|
5
7
|
export default async function workbuddy(pi) {
|
|
6
8
|
const mode = process.env.WORKBUDDY_MODE || "native";
|
|
@@ -9,7 +11,12 @@ export default async function workbuddy(pi) {
|
|
|
9
11
|
if (process.env.WORKBUDDY_GZIP && !["0", "1"].includes(process.env.WORKBUDDY_GZIP)) {
|
|
10
12
|
throw new Error("WORKBUDDY_GZIP must be 0 or 1.");
|
|
11
13
|
}
|
|
14
|
+
if (process.env.WORKBUDDY_PENDANT_TOOLTIP && !["0", "1"].includes(process.env.WORKBUDDY_PENDANT_TOOLTIP)) {
|
|
15
|
+
throw new Error("WORKBUDDY_PENDANT_TOOLTIP must be 0 or 1.");
|
|
16
|
+
}
|
|
12
17
|
const provider = createWorkbuddyProvider({ api: openAICompletionsApi(), gzip: process.env.WORKBUDDY_GZIP !== "0" });
|
|
13
18
|
pi.registerProvider(provider);
|
|
14
19
|
registerNativeCommands(pi, provider);
|
|
20
|
+
registerNativeLifecycle(pi, provider);
|
|
21
|
+
registerPendantTooltip(pi, { enabled: process.env.WORKBUDDY_PENDANT_TOOLTIP === "1" });
|
|
15
22
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Optional Pendant model-tooltip adapter (experimental)
|
|
2
|
+
|
|
3
|
+
This integration prepares **WorkBuddy credit rows in Pendant's existing native
|
|
4
|
+
model-hover grid**, without disguising credits as OpenAI five-hour/weekly limits.
|
|
5
|
+
It is not an official Pendant API or an upstream-supported integration.
|
|
6
|
+
|
|
7
|
+
## Important boundary
|
|
8
|
+
|
|
9
|
+
- The Pi extension alone cannot add arbitrary model-hover rows in Pendant 0.41.0.
|
|
10
|
+
- Adapter v2 prepares modified **copies** of the webview and host status-broker bundles. Preparation
|
|
11
|
+
never overwrites the installed host and does not imply that the visible UI has
|
|
12
|
+
changed. Installing that copy is a separate, explicitly approved step.
|
|
13
|
+
- It supports only `cdervis.vscode-pi` **0.41.0** with the exact inspected original
|
|
14
|
+
webview SHA-256:
|
|
15
|
+
`88c02dc6b1474e83fb5b8762ef1f26ae2d07abe04145b1b04f0ba519039bc38c`.
|
|
16
|
+
Its original `dist/extension.js` must also match
|
|
17
|
+
`8c449489dfba9f04fb8e96c2f09640b7ddf69900f6ca0e88ca1a4389b43714d6`.
|
|
18
|
+
- Other versions, changed hashes, duplicate patching, and an output path inside
|
|
19
|
+
the host installation are rejected. Updates require a new review, not disabling
|
|
20
|
+
the checks. Generated vendor bundles are excluded from Git and npm publication.
|
|
21
|
+
|
|
22
|
+
## Architecture
|
|
23
|
+
|
|
24
|
+
1. The opt-in WorkBuddy controller resolves credentials through Pi's existing
|
|
25
|
+
`modelRegistry.getApiKeyAndHeaders()` flow, then calls the private in-memory
|
|
26
|
+
credit reader. Pi may refresh expired OAuth credentials through its normal
|
|
27
|
+
auth lock; the controller does not read `auth.json` or manage tokens itself.
|
|
28
|
+
2. Only a whitelisted credit snapshot and session ID cross Pi's standard
|
|
29
|
+
`setStatus` transport under the reserved key `workbuddy.credits.v1`.
|
|
30
|
+
Credentials, user IDs, emails, and raw errors are never serialized.
|
|
31
|
+
3. The host broker forwards the reserved entry even when the ordinary extension-
|
|
32
|
+
status surface is off (Pendant's default). Other entries retain their existing
|
|
33
|
+
display policy. It does not enable a panel/status bar or change user settings.
|
|
34
|
+
The reserved entry is excluded from the separate VS Code status-bar badge.
|
|
35
|
+
4. The webview consumes the reserved entry instead of showing encoded text in the
|
|
36
|
+
ordinary extension-status badge/panel.
|
|
37
|
+
5. Its renderer appends rows only for an available WorkBuddy model in the exact
|
|
38
|
+
matching active session. Non-WorkBuddy tooltips keep their original rows.
|
|
39
|
+
Session mismatch, missing model/auth availability, duplicate entries, and
|
|
40
|
+
malformed/oversized payloads hide the credit rows rather than guessing.
|
|
41
|
+
6. Status changes refresh an already-open tooltip. No global frontend balance
|
|
42
|
+
cache, new worker request kind, local HTTP server, or credential-bearing
|
|
43
|
+
webview request is introduced.
|
|
44
|
+
|
|
45
|
+
The controller is **disabled by default**. Normal CLI behavior remains unchanged.
|
|
46
|
+
It refreshes on session start, model selection, and completed agent runs only
|
|
47
|
+
while explicitly enabled and on a WorkBuddy model. The reader's 60-second cache
|
|
48
|
+
and request deduplication apply. There is no hover-triggered network request or
|
|
49
|
+
idle polling; old snapshots are explicitly marked `Cached / stale`. Use the
|
|
50
|
+
refresh command when a newer balance is needed without sending a chat.
|
|
51
|
+
|
|
52
|
+
Observed provider/session changes cancel outdated publication. Moving away from
|
|
53
|
+
WorkBuddy, disabling the relay, or shutting down clears its private reader. The
|
|
54
|
+
host hides balances for models no longer present in its available-model list.
|
|
55
|
+
Account changes must reach the normal Pi auth/session lifecycle or be followed
|
|
56
|
+
by an explicit refresh; this is not a separate account-monitoring service.
|
|
57
|
+
|
|
58
|
+
## Prepare, without applying
|
|
59
|
+
|
|
60
|
+
From the repository (the output directory must not already exist):
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
node integrations/pendant/prepare.mjs `
|
|
64
|
+
"C:/Users/YOUR_USER/.vscode/extensions/cdervis.vscode-pi-0.41.0" `
|
|
65
|
+
"integrations/pendant/generated"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The command validates the version/hash and generated JavaScript syntax, then
|
|
69
|
+
writes only:
|
|
70
|
+
|
|
71
|
+
- `generated/webview.js`: the optional prepared renderer copy.
|
|
72
|
+
- `generated/extension.js`: the optional prepared broker copy.
|
|
73
|
+
- `generated/manifest.json`: original/prepared hashes for both and `applied: false`.
|
|
74
|
+
|
|
75
|
+
If the output already exists, preserve/review it or choose a different new
|
|
76
|
+
output directory. Do not use preparation to overwrite an installed bundle.
|
|
77
|
+
|
|
78
|
+
The preparation utility is included in the npm package at
|
|
79
|
+
`integrations/pendant/prepare.mjs`; paths in the command above assume a repository
|
|
80
|
+
checkout. It requires only Node.js and the supplied installed host directory.
|
|
81
|
+
|
|
82
|
+
## Before any installation
|
|
83
|
+
|
|
84
|
+
Review the manifest and diff. Prefer testing in a disposable VS Code profile.
|
|
85
|
+
Applying v2 requires explicit backups of the original `dist/webview.js` and
|
|
86
|
+
`dist/extension.js`, original-hash checks, and reversible replacements of both. This
|
|
87
|
+
utility deliberately has **no apply command**. No installed Pendant file has
|
|
88
|
+
been modified by implementing or preparing this integration.
|
|
89
|
+
|
|
90
|
+
A local modification is not guaranteed to survive extension updates and may
|
|
91
|
+
interact with host integrity/CSP behavior that isolated tests cannot prove.
|
|
92
|
+
Keep the backup and restore the original before upgrading or troubleshooting
|
|
93
|
+
unrelated host problems. Do not publish modified vendor bundles as this project.
|
|
94
|
+
|
|
95
|
+
## After an approved adapter installation
|
|
96
|
+
|
|
97
|
+
Load the updated `pi-workbuddy` extension, then in the patched Pendant session:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
/workbuddy-tooltip on
|
|
101
|
+
/workbuddy-tooltip refresh
|
|
102
|
+
/workbuddy-tooltip status
|
|
103
|
+
/workbuddy-tooltip off
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The command is a Pi extension command, not a chat prompt. It sends only a
|
|
107
|
+
bounded read-only credit requests when enabled (summary and relevant detail pages); it never invokes inference. Do not enable
|
|
108
|
+
it in an unmodified host: an ordinary status surface might display the encoded
|
|
109
|
+
transport rather than a native tooltip. It refuses the normal TUI mode.
|
|
110
|
+
|
|
111
|
+
For persistent opt-in **after installing the adapter**, launch the host process
|
|
112
|
+
with `WORKBUDDY_PENDANT_TOOLTIP=1`. The `.env.example` is documentation only; it is
|
|
113
|
+
not automatically loaded. If `pendant.extensionStatus.keys` is configured as an
|
|
114
|
+
allowlist, include `workbuddy.credits.v1`; the integration does not bypass that
|
|
115
|
+
user-controlled filter.
|
|
116
|
+
|
|
117
|
+
## Displayed fields
|
|
118
|
+
|
|
119
|
+
The native model header, provider, context, reasoning and input rows remain.
|
|
120
|
+
Additional credit rows use only verified data:
|
|
121
|
+
|
|
122
|
+
- Plan name, if supplied by the credit summary (no guessed Free/Pro label).
|
|
123
|
+
- Credits remaining.
|
|
124
|
+
- Separate base/daily/bonus/top-up grants, each with `remaining / total left`.
|
|
125
|
+
- Each grant's supplied expiry; never substitute an ordinary renewal/reset date.
|
|
126
|
+
- A maximum of four preview grants (base/daily first, then earliest known expiry),
|
|
127
|
+
with an explicit count of further loaded grants. Partial detail reads are labeled.
|
|
128
|
+
- Aggregate used/total and summary-group count only when details are unavailable.
|
|
129
|
+
- Last successful update.
|
|
130
|
+
- Explicit cached/stale indicator when applicable.
|
|
131
|
+
|
|
132
|
+
`Credits remaining` and each grant's remaining balance reuse Pendant's native OpenAI semantic tones: green at
|
|
133
|
+
50% or more remaining, yellow from 15% to below 50%, and red below 15% (including
|
|
134
|
+
an explicitly known zero). A positive balance with an unknown/inconsistent total
|
|
135
|
+
stays neutral; stale balances are not shown as current healthy/depleted quota.
|
|
136
|
+
The percentage only selects a tone, not a new displayed allowance. Other rows
|
|
137
|
+
stay neutral; package count is not a banked-reset count. Absolute expiry timestamps
|
|
138
|
+
render in the browser's local timezone. Offset-less dates remain unchanged under
|
|
139
|
+
`Expires (server time)`; unknown dates show `Unknown`, not `Never`. The stale marker and
|
|
140
|
+
unavailable state use the host's warning tone. No custom RGB/CSS palette is added.
|
|
141
|
+
|
|
142
|
+
Missing numbers show `Unknown`, not zero. Loading/unavailable replaces the
|
|
143
|
+
credit values. No expiry, renewal time, percentage, or USD conversion is invented.
|
|
144
|
+
The balance belongs to the account, not independently to each model. Only the
|
|
145
|
+
verified personal-account route is currently supported.
|
|
146
|
+
|
|
147
|
+
## Validation and remaining verification
|
|
148
|
+
|
|
149
|
+
If the installed host is already adapted, keep its original backup as the test
|
|
150
|
+
fixture instead of weakening the original-hash check:
|
|
151
|
+
|
|
152
|
+
```powershell
|
|
153
|
+
$env:PI_PENDANT_FIXTURE_PATH = "C:/path/to/backup/pendant-original"
|
|
154
|
+
# This fixture contains original package.json, dist/webview.js and dist/extension.js.
|
|
155
|
+
# PI_SDK_TEST_PATH still points to the real installed dist/pi-sdk.cjs.
|
|
156
|
+
npm test
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Tests cover payload redaction and bounds, account/session cancellation, Pi's
|
|
160
|
+
actual bundled SDK auth/status APIs with synthetic credentials, and the patched
|
|
161
|
+
native renderer methods extracted from the exact shipped bundle. Preparation
|
|
162
|
+
also checks that the original file is unchanged and the output parses as ESM.
|
|
163
|
+
These are not a browser screenshot or a test of an applied VS Code installation.
|
|
164
|
+
The operator separately confirmed the native hover, colors, per-grant balances
|
|
165
|
+
and expiry dates visually in the local 0.41.0 deployment. New deployments or host
|
|
166
|
+
versions still need their own visible hover check.
|
|
167
|
+
In particular, the regression suite now checks the default `off` status surface;
|
|
168
|
+
renderer-only v1 omitted that broker gate and could not receive the payload.
|
|
169
|
+
Use **Developer: Reload Window** after installing v2, not only Pi `/reload`.
|
|
170
|
+
|
|
171
|
+
For upstream support, the preferable long-term change is a documented generic
|
|
172
|
+
provider-details API with text rows and session/account scoping. The current
|
|
173
|
+
version-pinned adapter is an explicit compatibility bridge, not a claim that
|
|
174
|
+
Pendant already implements that public API.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Optional, version-pinned adapter preparation. Never writes to the installed host.
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { resolve, dirname, relative, isAbsolute, join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { createTooltipAdapter, TOOLTIP_STATUS_KEY } from "../../src/pendant-tooltip.js";
|
|
8
|
+
|
|
9
|
+
export const PENDANT_VERSION = "0.41.0";
|
|
10
|
+
export const ORIGINAL_SHA256 = "88c02dc6b1474e83fb5b8762ef1f26ae2d07abe04145b1b04f0ba519039bc38c";
|
|
11
|
+
export const HOST_EXTENSION_SHA256 = "8c449489dfba9f04fb8e96c2f09640b7ddf69900f6ca0e88ca1a4389b43714d6";
|
|
12
|
+
const hash = value => createHash("sha256").update(value).digest("hex");
|
|
13
|
+
function replaceOnce(source, before, after) {
|
|
14
|
+
const index = source.indexOf(before);
|
|
15
|
+
if (index < 0 || source.indexOf(before, index + before.length) >= 0) throw new Error("Pendant adapter anchor is absent or ambiguous.");
|
|
16
|
+
return source.slice(0, index) + after + source.slice(index + before.length);
|
|
17
|
+
}
|
|
18
|
+
export function patchWebview(source) {
|
|
19
|
+
if (hash(source) !== ORIGINAL_SHA256) throw new Error("Unsupported Pendant webview hash; refusing to patch.");
|
|
20
|
+
const modelStart = source.indexOf("renderModelTooltipFor(e){"), modelEnd = source.indexOf("prepareModelTooltipFor(e){", modelStart);
|
|
21
|
+
const originalModel = source.slice(modelStart, modelEnd);
|
|
22
|
+
let model = replaceOnce(originalModel, "return s$(xn(this.options.getModels(),e.provider,e.modelId),",
|
|
23
|
+
"let __wbModel=xn(this.options.getModels(),e.provider,e.modelId);let __wbBase=s$(__wbModel,");
|
|
24
|
+
model = replaceOnce(model, ",n.error)}", ",n.error);return __piWorkbuddyTooltip.append(__wbBase,this.__piWorkbuddyEntries??[],{provider:e.provider,sessionId:this.options.getState().sessionId,modelAvailable:!!__wbModel},Date.now())}");
|
|
25
|
+
let output = replaceOnce(source, originalModel, model);
|
|
26
|
+
const statusStart = source.indexOf("setExtensionStatus(e){"), statusEnd = source.indexOf("setExtensionStatusSurface(e){", statusStart);
|
|
27
|
+
const originalStatus = source.slice(statusStart, statusEnd);
|
|
28
|
+
let status = replaceOnce(originalStatus, "setExtensionStatus(e){this.extensionStatus.entries=e.map(",
|
|
29
|
+
"setExtensionStatus(e){this.__piWorkbuddyEntries=e.filter(n=>n.key===" + JSON.stringify(TOOLTIP_STATUS_KEY) +
|
|
30
|
+
");this.extensionStatus.entries=e.filter(n=>n.key!==" + JSON.stringify(TOOLTIP_STATUS_KEY) + ").map(");
|
|
31
|
+
status = replaceOnce(status, "this.ui.footerTooltipController.refresh(this.options.extensionStatusButton,t)}",
|
|
32
|
+
"this.ui.footerTooltipController.refresh(this.options.extensionStatusButton,t);this.ui.footerTooltipController.refreshActive()}");
|
|
33
|
+
output = replaceOnce(output, originalStatus, status);
|
|
34
|
+
// This is a module-local helper; no global account cache, network API, or credentials enter the webview.
|
|
35
|
+
return "// pi-workbuddy optional Pendant tooltip adapter v1\nconst __piWorkbuddyTooltip=(" + createTooltipAdapter.toString() + ")();\n" + output;
|
|
36
|
+
}
|
|
37
|
+
export function patchHostExtension(source) {
|
|
38
|
+
if (hash(source) !== HOST_EXTENSION_SHA256) throw new Error("Unsupported Pendant broker hash; refusing to patch.");
|
|
39
|
+
const start = source.indexOf("broadcastExtensionStatus(){"), end = source.indexOf("waitForSidebarWebviewReady(", start);
|
|
40
|
+
const original = source.slice(start, end);
|
|
41
|
+
// A hidden status surface must not drop the private tooltip transport. Other entries stay hidden.
|
|
42
|
+
const replacement = "broadcastExtensionStatus(){let visible=this.extensionStatusSurface===\"panel\"||this.extensionStatusSurface===\"both\";let targets=[...this.view?[{source:\"sidebar\"}]:[],...[...this.editorSurfaces.values()].map(n=>({source:\"editor\",surfaceId:n.id}))];for(let target of targets){let entries=this.extensionUiHost.getStatusSnapshot(B(target));if(!visible)entries=entries.filter(entry=>entry.key===" + JSON.stringify(TOOLTIP_STATUS_KEY) + ");this.postMessage({type:\"extension_status\",entries},target)}}";
|
|
43
|
+
let output = replaceOnce(source, original, replacement);
|
|
44
|
+
// Never expose the encoded transport in VS Code's separate status bar either.
|
|
45
|
+
output = replaceOnce(output, "refreshStatusBar(){let e=this.getStatusSnapshot(),n=e.flatMap(",
|
|
46
|
+
"refreshStatusBar(){let e=this.getStatusSnapshot().filter(entry=>entry.key!==" + JSON.stringify(TOOLTIP_STATUS_KEY) + "),n=e.flatMap(");
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
function within(child, parent) {
|
|
50
|
+
const path = relative(parent, child);
|
|
51
|
+
return path === "" || !path.startsWith("..") && !isAbsolute(path);
|
|
52
|
+
}
|
|
53
|
+
export function prepareAdapter(sourceDirectory, outputDirectory) {
|
|
54
|
+
const sourceRoot = realpathSync(sourceDirectory);
|
|
55
|
+
const parent = realpathSync(dirname(resolve(outputDirectory)));
|
|
56
|
+
const output = join(parent, resolve(outputDirectory).split(/[\\/]/).at(-1));
|
|
57
|
+
if (within(output, sourceRoot)) throw new Error("Output must not be inside the installed Pendant directory.");
|
|
58
|
+
const pkg = JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8"));
|
|
59
|
+
if (pkg.publisher !== "cdervis" || pkg.name !== "vscode-pi" || pkg.version !== PENDANT_VERSION) {
|
|
60
|
+
throw new Error("Only cdervis.vscode-pi 0.41.0 is supported by this experimental adapter.");
|
|
61
|
+
}
|
|
62
|
+
const original = readFileSync(join(sourceRoot, "dist", "webview.js"));
|
|
63
|
+
const brokerOriginal = readFileSync(join(sourceRoot, "dist", "extension.js"));
|
|
64
|
+
const patched = patchWebview(original.toString("utf8"));
|
|
65
|
+
const brokerPatched = patchHostExtension(brokerOriginal.toString("utf8"));
|
|
66
|
+
for (const [source, esm] of [[patched, true], [brokerPatched, false]]) {
|
|
67
|
+
const checked = spawnSync(process.execPath, [...(esm ? ["--input-type=module"] : []), "--check"], { input: source, encoding: "utf8", timeout: 15000 });
|
|
68
|
+
if (checked.error || checked.status !== 0) throw new Error("Prepared adapter failed syntax validation; nothing was written.");
|
|
69
|
+
}
|
|
70
|
+
const manifest = { adapter: "pi-workbuddy-tooltip-v2", hostVersion: pkg.version, originalSha256: hash(original),
|
|
71
|
+
preparedSha256: hash(patched), target: "dist/webview.js", brokerOriginalSha256: hash(brokerOriginal),
|
|
72
|
+
brokerPreparedSha256: hash(brokerPatched), brokerTarget: "dist/extension.js", applied: false };
|
|
73
|
+
mkdirSync(output); // Fail if output already exists; never overwrite a previous artifact.
|
|
74
|
+
writeFileSync(join(output, "webview.js"), patched, { flag: "wx" });
|
|
75
|
+
writeFileSync(join(output, "extension.js"), brokerPatched, { flag: "wx" });
|
|
76
|
+
writeFileSync(join(output, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" });
|
|
77
|
+
return manifest;
|
|
78
|
+
}
|
|
79
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
80
|
+
try {
|
|
81
|
+
if (process.argv.length !== 4) throw new Error("Usage: node integrations/pendant/prepare.mjs PENDANT_DIRECTORY NEW_OUTPUT_DIRECTORY");
|
|
82
|
+
const manifest = prepareAdapter(process.argv[2], process.argv[3]);
|
|
83
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
84
|
+
console.log("Prepared only. Installed Pendant files have NOT been changed.");
|
|
85
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-workbuddy",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Native WorkBuddy account login and direct model provider for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"extensions",
|
|
22
22
|
"src",
|
|
23
|
+
"integrations/pendant/prepare.mjs",
|
|
24
|
+
"integrations/pendant/README.md",
|
|
23
25
|
"README.md",
|
|
24
26
|
"LICENSE"
|
|
25
27
|
],
|
package/src/client-identity.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Compatibility baseline: the operator-supplied WorkBuddy AI 5.8.4 artifact.
|
|
2
2
|
// This is a protocol/version marker, not an assertion that Pi is the official desktop app.
|
|
3
|
-
export const EXTENSION_VERSION = "0.2.
|
|
3
|
+
export const EXTENSION_VERSION = "0.2.4";
|
|
4
4
|
export const WORKBUDDY_PROTOCOL_VERSION = "5.8.4";
|
|
5
5
|
export const USER_AGENT = "CLI/unknown WorkBuddy/" + WORKBUDDY_PROTOCOL_VERSION +
|
|
6
6
|
" pi-workbuddy/" + EXTENSION_VERSION;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { BASE_URL, ORIGIN } from "./native-client.js";
|
|
3
|
+
import { projectModels } from "./native-models.js";
|
|
4
|
+
import { WorkbuddyError } from "./native-errors.js";
|
|
5
|
+
|
|
6
|
+
/** Local cache validator only; never sent as an HTTP ETag. Tokens are not hashed or stored. */
|
|
7
|
+
export function catalogCacheKey(credential) {
|
|
8
|
+
if (credential?.type !== "oauth" || credential.origin !== ORIGIN || !credential.userId) return undefined;
|
|
9
|
+
const parts = [ORIGIN, credential.userId, credential.domain || "www.workbuddy.ai", credential.enterpriseId || ""];
|
|
10
|
+
if (parts.some(value => typeof value !== "string" || value.length > 2048 || /[\r\n\0]/.test(value))) return undefined;
|
|
11
|
+
return "workbuddy-catalog-v1:" + createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Reproject a public metadata whitelist, never cached auth, endpoints, headers or instructions. */
|
|
15
|
+
export function restoreCatalog(entry, key) {
|
|
16
|
+
if (!key || entry?.etag !== key) return undefined;
|
|
17
|
+
if (!Array.isArray(entry.models) || entry.models.length > 2000) {
|
|
18
|
+
throw new WorkbuddyError("catalog-cache", "Cached WorkBuddy model metadata is invalid.");
|
|
19
|
+
}
|
|
20
|
+
const models = entry.models.filter(model => model?.provider === "workbuddy" && model.api === "openai-completions" &&
|
|
21
|
+
model.baseUrl === BASE_URL).map(model => ({
|
|
22
|
+
id: model.id, name: model.name,
|
|
23
|
+
maxInputTokens: model.contextWindow, maxOutputTokens: model.maxTokens,
|
|
24
|
+
supportsReasoning: model.reasoning === true,
|
|
25
|
+
supportsImages: Array.isArray(model.input) && model.input.includes("image"),
|
|
26
|
+
onlyReasoning: model.thinkingLevelMap?.off === null,
|
|
27
|
+
reasoning: { supportedEfforts: model.thinkingLevelMap && typeof model.thinkingLevelMap === "object" ?
|
|
28
|
+
Object.entries(model.thinkingLevelMap).filter(([level, value]) => level !== "off" && typeof value === "string").map(([level]) => level) : [] },
|
|
29
|
+
temperature: model.samplingParams?.temperature, top_p: model.samplingParams?.top_p,
|
|
30
|
+
}));
|
|
31
|
+
try { return projectModels({ models }); }
|
|
32
|
+
catch { throw new WorkbuddyError("catalog-cache", "Cached WorkBuddy model metadata is invalid."); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function catalogCacheEntry(models, key) {
|
|
36
|
+
// Models originate from projectModels(), which emits only bounded public metadata.
|
|
37
|
+
return { models, checkedAt: Date.now(), etag: key };
|
|
38
|
+
}
|
package/src/native-client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { WorkbuddyError } from "./native-errors.js";
|
|
3
3
|
import { USER_AGENT } from "./client-identity.js";
|
|
4
|
+
import { readCreditPackages } from "./native-credit-packages.js";
|
|
4
5
|
|
|
5
6
|
export const ORIGIN = "https://www.workbuddy.ai";
|
|
6
7
|
export const BASE_URL = ORIGIN + "/v2";
|
|
@@ -88,7 +89,7 @@ export function createNativeClient({
|
|
|
88
89
|
fetch: fetchImpl = globalThis.fetch, requestTimeoutMs = 15000,
|
|
89
90
|
loginTimeoutMs = 300000, pollIntervalMs = 1000, now = Date.now,
|
|
90
91
|
} = {}) {
|
|
91
|
-
async function request(path, { method = "GET", credential, signal, pendingCode, headers = {} } = {}) {
|
|
92
|
+
async function request(path, { method = "GET", credential, signal, pendingCode, headers = {}, body = {} } = {}) {
|
|
92
93
|
const url = new URL(path, ORIGIN);
|
|
93
94
|
if (url.origin !== ORIGIN || url.username || url.password) throw new WorkbuddyError("endpoint", "Untrusted WorkBuddy endpoint.");
|
|
94
95
|
const timed = AbortSignal.timeout(requestTimeoutMs);
|
|
@@ -104,7 +105,7 @@ export function createNativeClient({
|
|
|
104
105
|
try {
|
|
105
106
|
response = await fetchImpl(url, {
|
|
106
107
|
method, redirect: "error", signal: activeSignal, headers: requestHeaders,
|
|
107
|
-
...(method === "POST" ? { body:
|
|
108
|
+
...(method === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
108
109
|
});
|
|
109
110
|
} catch {
|
|
110
111
|
if (signal?.aborted) throw new WorkbuddyError("cancelled", "WorkBuddy operation cancelled or timed out.");
|
|
@@ -179,5 +180,35 @@ export function createNativeClient({
|
|
|
179
180
|
async catalog(credential, signal) {
|
|
180
181
|
return request("/v3/config", { credential, signal });
|
|
181
182
|
},
|
|
183
|
+
async credits(credential, signal) {
|
|
184
|
+
accountHeaders(credential);
|
|
185
|
+
if (credential.enterpriseId) {
|
|
186
|
+
throw new WorkbuddyError("credits-scope", "The verified credits route supports personal accounts only.");
|
|
187
|
+
}
|
|
188
|
+
return request("/billing/meter/get-user-resource-summary", { method: "POST", credential, signal });
|
|
189
|
+
},
|
|
190
|
+
async creditPackages(credential, signal, summary) {
|
|
191
|
+
accountHeaders(credential);
|
|
192
|
+
if (credential.enterpriseId) throw new WorkbuddyError("credits-scope", "Credit-package details support personal accounts only.");
|
|
193
|
+
const deadline = AbortSignal.timeout(requestTimeoutMs);
|
|
194
|
+
const activeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
195
|
+
try {
|
|
196
|
+
return await readCreditPackages({ summary, signal: activeSignal,
|
|
197
|
+
fetchPage: (group, body) => {
|
|
198
|
+
const today = new Date(now()), pad = value => String(value).padStart(2, "0");
|
|
199
|
+
const day = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
|
|
200
|
+
return request("/billing/meter/get-user-resource-" + group + "-packages", {
|
|
201
|
+
method: "POST", credential, signal: activeSignal, headers: { "Accept-Language": "en" },
|
|
202
|
+
body: { ...body, ...(group === "paid" ? { NeedRenewInfo: true } : {
|
|
203
|
+
SlicePeriodStartTime: day + " 00:00:00", SlicePeriodEndTime: day + " 23:59:59",
|
|
204
|
+
}) },
|
|
205
|
+
});
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
} catch {
|
|
209
|
+
if (signal?.aborted) throw new WorkbuddyError("cancelled", "WorkBuddy credit read cancelled.");
|
|
210
|
+
return { accounts: [], state: "unavailable" };
|
|
211
|
+
}
|
|
212
|
+
},
|
|
182
213
|
};
|
|
183
214
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { WorkbuddyError } from "./native-errors.js";
|
|
2
|
+
|
|
3
|
+
const MAX_PACKAGES = 2000, PAGE_SIZE = 200, MAX_PAGES = 10;
|
|
4
|
+
// Public product codes reviewed in WorkBuddy 5.8.4; unknown summary codes are queried on both read routes.
|
|
5
|
+
const products = {
|
|
6
|
+
TCACA_code_001_PqouKr6QWV: ["free", "daily"],
|
|
7
|
+
TCACA_code_002_AkiJS3ZHF5: ["paid", "base"],
|
|
8
|
+
TCACA_code_005_maRGyrHhw1: ["paid", "base"],
|
|
9
|
+
TCACA_code_003_FAnt7lcmRT: ["paid", "base"],
|
|
10
|
+
TCACA_code_006_DbXS0lrypC: ["free", "bonus"],
|
|
11
|
+
TCACA_code_007_nzdH5h4Nl0: ["free", "bonus"],
|
|
12
|
+
TCACA_code_008_cfWoLwvjU4: ["free", "base"],
|
|
13
|
+
TCACA_code_009_0XmEQc2xOf: ["paid", "topup"],
|
|
14
|
+
TCACA_code_023_4xbGhMrE6q: ["paid", "base"],
|
|
15
|
+
TCACA_code_026_BaESVICNoi: ["paid", "base"],
|
|
16
|
+
TCACA_code_027_0FCGVA6vSa: ["paid", "base"],
|
|
17
|
+
TCACA_code_028_NtpWi0jzXs: ["free", "bonus"],
|
|
18
|
+
TCACA_code_029_6wCGEWquYy: ["free", "bonus"],
|
|
19
|
+
TCACA_code_030_BjSt89qTvr: ["free", "bonus"],
|
|
20
|
+
TCACA_code_035_ArVxJcGDsm: ["free", "base"],
|
|
21
|
+
TCACA_code_036_lupO5WgNdG: ["paid", "topup"],
|
|
22
|
+
TCACA_code_037_WxOD3MpI2o: ["free", "bonus"],
|
|
23
|
+
TCACA_code_038_OhvqZtiPKr: ["paid", "topup"],
|
|
24
|
+
TCACA_code_039_KRcQj7wUat: ["free", "base"],
|
|
25
|
+
TCACA_code_040_mi9rCYg46x: ["free", "base"],
|
|
26
|
+
};
|
|
27
|
+
const codeOf = value => typeof value === "string" && /^[\w-]{1,128}$/.test(value) ? value : undefined;
|
|
28
|
+
const cancelled = () => new WorkbuddyError("cancelled", "WorkBuddy credit read cancelled.");
|
|
29
|
+
|
|
30
|
+
/** Bounded, read-only detail discovery. Never claims rewards or changes renewal/purchases. */
|
|
31
|
+
export async function readCreditPackages({ summary, fetchPage, signal }) {
|
|
32
|
+
if (!Array.isArray(summary?.Packages) || summary.Packages.length > MAX_PACKAGES ||
|
|
33
|
+
summary.Packages.some(item => !item || typeof item !== "object" || Array.isArray(item))) return { accounts: [], state: "unavailable" };
|
|
34
|
+
const codes = [...new Set((summary?.Packages ?? []).map(item => codeOf(item?.PackageCode)).filter(Boolean))];
|
|
35
|
+
if (!codes.length) return { accounts: [], state: summary?.Packages?.length === 0 ? "complete" : "unavailable" };
|
|
36
|
+
const missingCode = summary.Packages.some(item => !codeOf(item?.PackageCode));
|
|
37
|
+
const groups = await Promise.allSettled(["paid", "free"].map(async group => {
|
|
38
|
+
const selected = codes.filter(code => !Object.hasOwn(products, code) || products[code][0] === group);
|
|
39
|
+
if (!selected.length) return { accounts: [], complete: true };
|
|
40
|
+
const accounts = [];
|
|
41
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
42
|
+
if (signal?.aborted) throw cancelled();
|
|
43
|
+
let data;
|
|
44
|
+
try {
|
|
45
|
+
data = await fetchPage(group, { PageNumber: page, PageSize: PAGE_SIZE, PackageCodes: selected,
|
|
46
|
+
Status: [0, 3], OnlyValidPeriod: true, IsDisplayTotalInfo: true });
|
|
47
|
+
} catch {
|
|
48
|
+
if (signal?.aborted) throw cancelled();
|
|
49
|
+
return { accounts, complete: false };
|
|
50
|
+
}
|
|
51
|
+
if (!Array.isArray(data?.Accounts) || data.Accounts.length > PAGE_SIZE ||
|
|
52
|
+
!Number.isSafeInteger(data.TotalCount) || data.TotalCount < 0 ||
|
|
53
|
+
data.Accounts.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
|
54
|
+
return { accounts, complete: false };
|
|
55
|
+
}
|
|
56
|
+
// Some servers return unrelated products; never project them as matching this summary.
|
|
57
|
+
const matching = data.Accounts.filter(item => selected.includes(item.PackageCode));
|
|
58
|
+
accounts.push(...matching);
|
|
59
|
+
if (matching.length !== data.Accounts.length) return { accounts, complete: false };
|
|
60
|
+
if (accounts.length >= data.TotalCount) return { accounts, complete: accounts.length === data.TotalCount };
|
|
61
|
+
if (!data.Accounts.length) return { accounts, complete: false };
|
|
62
|
+
}
|
|
63
|
+
return { accounts, complete: false };
|
|
64
|
+
}));
|
|
65
|
+
if (signal?.aborted) throw cancelled();
|
|
66
|
+
const accounts = [], seen = new Set(); let complete = !missingCode;
|
|
67
|
+
for (const result of groups) {
|
|
68
|
+
if (result.status !== "fulfilled") { complete = false; continue; }
|
|
69
|
+
complete &&= result.value.complete;
|
|
70
|
+
for (const item of result.value.accounts) {
|
|
71
|
+
const id = typeof item.ResourceId === "string" && item.ResourceId || Number.isSafeInteger(item.ResourceId) && String(item.ResourceId);
|
|
72
|
+
if (id && seen.has(id)) continue;
|
|
73
|
+
if (id) seen.add(id);
|
|
74
|
+
if (accounts.length < MAX_PACKAGES) accounts.push(item); else complete = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const returnedCodes = new Set(accounts.map(item => item.PackageCode));
|
|
78
|
+
if (codes.some(code => !returnedCodes.has(code))) complete = false;
|
|
79
|
+
return { accounts, state: complete ? "complete" : accounts.length ? "partial" : "unavailable" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function capacity(value) {
|
|
83
|
+
if (typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value.trim())) value = Number(value);
|
|
84
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
|
85
|
+
}
|
|
86
|
+
function date(value) {
|
|
87
|
+
// The verified numeric field is Unix milliseconds; do not guess seconds or a timezone for wall-clock strings.
|
|
88
|
+
if (typeof value === "string" && /^\d+$/.test(value)) value = Number(value);
|
|
89
|
+
if (typeof value === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?(?:Z|[+-]\d\d:\d\d)$/.test(value) && wallClock(value.slice(0, 19))) value = Date.parse(value);
|
|
90
|
+
return Number.isSafeInteger(value) && value >= Date.UTC(2000, 0, 1) && value <= 8640000000000000 ? value : null;
|
|
91
|
+
}
|
|
92
|
+
function wallClock(value) {
|
|
93
|
+
if (typeof value !== "string" || !/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d$/.test(value)) return null;
|
|
94
|
+
const normalized = value.replace(" ", "T"), stamp = Date.parse(normalized + "Z");
|
|
95
|
+
return Number.isFinite(stamp) && new Date(stamp).toISOString().slice(0, 19) === normalized ? normalized.replace("T", " ") : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whitelist only display metrics and expiry. No account/resource/order IDs or upstream names leave this module. */
|
|
99
|
+
export function normalizeCreditPackages(details, summary) {
|
|
100
|
+
if (!details || !["complete", "partial"].includes(details.state) || !Array.isArray(details.accounts) || details.accounts.length > MAX_PACKAGES ||
|
|
101
|
+
details.accounts.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
|
102
|
+
return Object.freeze({ state: "unavailable", count: 0, items: Object.freeze([]) });
|
|
103
|
+
}
|
|
104
|
+
const items = details.accounts.map(item => {
|
|
105
|
+
const code = codeOf(item.PackageCode), kind = code && code === summary.SubscriptionPackageCode ? "base" :
|
|
106
|
+
code && Object.hasOwn(products, code) ? products[code][1] : "other";
|
|
107
|
+
const slice = Number(item.CapacityType) === 4 ? item.SlicePeriodUsageDetails?.[0] : undefined;
|
|
108
|
+
const total = capacity(slice ? slice.SlicePeriodCapacitySizePrecise : item.CycleCapacitySizePrecise);
|
|
109
|
+
const remaining = capacity(slice ? slice.SlicePeriodCapacityRemainPrecise : item.CycleCapacityRemainPrecise);
|
|
110
|
+
const used = total !== null && remaining !== null && total >= remaining ? total - remaining : null;
|
|
111
|
+
const candidates = kind === "daily" ? [item.CycleEndTime] : [item.DeductionEndTime, item.ExpiredTime];
|
|
112
|
+
const expiresAt = candidates.map(date).find(value => value !== null) ?? null;
|
|
113
|
+
const expiresText = expiresAt === null ? candidates.map(wallClock).find(Boolean) ?? null : null;
|
|
114
|
+
return Object.freeze({ kind, total, remaining, used, expiresAt, expiresText });
|
|
115
|
+
});
|
|
116
|
+
return Object.freeze({ state: details.state, count: items.length, items: Object.freeze(items) });
|
|
117
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createNativeClient, accountHeaders } from "./native-client.js";
|
|
2
|
+
import { catalogCacheKey } from "./native-catalog-cache.js";
|
|
3
|
+
import { WorkbuddyError, describeFailure } from "./native-errors.js";
|
|
4
|
+
import { normalizeCreditPackages } from "./native-credit-packages.js";
|
|
5
|
+
|
|
6
|
+
const MAX_PACKAGES = 2000;
|
|
7
|
+
function capacity(value) {
|
|
8
|
+
if (typeof value === "string") {
|
|
9
|
+
if (!/^\d+(?:\.\d+)?$/.test(value.trim())) return null;
|
|
10
|
+
value = Number(value);
|
|
11
|
+
}
|
|
12
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function text(value) {
|
|
15
|
+
if (typeof value !== "string") return undefined;
|
|
16
|
+
const result = value.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 128);
|
|
17
|
+
return result || undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Personal-account credits only; never infer a plan label, expiry, USD cost, or percentage. */
|
|
21
|
+
export function normalizeCredits(data, fetchedAt) {
|
|
22
|
+
if (!Number.isSafeInteger(fetchedAt) || fetchedAt < 0 || !data || !Array.isArray(data.Packages) || data.Packages.length > MAX_PACKAGES ||
|
|
23
|
+
data.Packages.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
|
|
24
|
+
throw new WorkbuddyError("credits-shape", "Unsupported WorkBuddy credit summary.");
|
|
25
|
+
}
|
|
26
|
+
const total = field => {
|
|
27
|
+
let result = 0;
|
|
28
|
+
for (const item of data.Packages) {
|
|
29
|
+
const value = capacity(item[field]);
|
|
30
|
+
if (value === null || result + value > Number.MAX_SAFE_INTEGER) return null;
|
|
31
|
+
result += value;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
const planCode = text(data.SubscriptionPackageCode), planName = text(data.SubscriptionPackageName);
|
|
36
|
+
return {
|
|
37
|
+
creditsTotal: total("CycleTotalCapacity"),
|
|
38
|
+
creditsRemaining: total("CycleRemainCapacity"),
|
|
39
|
+
creditsUsed: total("CycleUsedCapacity"),
|
|
40
|
+
packageCount: data.Packages.length, fetchedAt,
|
|
41
|
+
...(typeof data.IsPaidUser === "boolean" ? { isPaidUser: data.IsPaidUser } : {}),
|
|
42
|
+
...(planCode ? { planCode } : {}), ...(planName ? { planName } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cancelled() { return new WorkbuddyError("cancelled", "WorkBuddy credit read cancelled."); }
|
|
47
|
+
function safeError(error) {
|
|
48
|
+
const message = describeFailure(error);
|
|
49
|
+
if (error instanceof WorkbuddyError && !message.startsWith("[unknown]")) {
|
|
50
|
+
const details = {};
|
|
51
|
+
if (Number.isInteger(error.details.status)) details.status = error.details.status;
|
|
52
|
+
if (Number.isSafeInteger(error.details.businessCode)) details.businessCode = error.details.businessCode;
|
|
53
|
+
// Do not expose upstream/custom error text, causes, or arbitrary diagnostic fields.
|
|
54
|
+
return new WorkbuddyError(error.diagnosticCode, message, details);
|
|
55
|
+
}
|
|
56
|
+
return new WorkbuddyError("credits-request", "WorkBuddy credits could not be refreshed.");
|
|
57
|
+
}
|
|
58
|
+
function permitsStale(error) {
|
|
59
|
+
if (["cancelled", "credentials", "account-domain", "metadata", "endpoint", "credits-scope"].includes(error.diagnosticCode)) return false;
|
|
60
|
+
if (error.diagnosticCode !== "http") return true;
|
|
61
|
+
const status = error.details.status;
|
|
62
|
+
return status === 408 || status === 429 || status >= 500 && status <= 599;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** No timers, persistence, automatic login/refresh, or network work until read() is explicitly called. */
|
|
66
|
+
export function createCreditsReader({ client = createNativeClient(), ttlMs = 60000, maxEntries = 8, now = Date.now } = {}) {
|
|
67
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 0 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > 128 ||
|
|
68
|
+
typeof now !== "function" || typeof client?.credits !== "function") throw new Error("Invalid WorkBuddy credit-reader configuration.");
|
|
69
|
+
const cache = new Map(), pending = new Map();
|
|
70
|
+
const timestamp = () => {
|
|
71
|
+
const value = now();
|
|
72
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error("WorkBuddy credit-reader clock must return a nonnegative safe integer.");
|
|
73
|
+
return value;
|
|
74
|
+
};
|
|
75
|
+
function keyFor(credential) {
|
|
76
|
+
accountHeaders(credential);
|
|
77
|
+
if (credential.enterpriseId) throw new WorkbuddyError("credits-scope", "The verified credits route supports personal accounts only.");
|
|
78
|
+
const key = catalogCacheKey(credential); // Reuse only the hashed account scope, never the model store.
|
|
79
|
+
if (!key) throw new WorkbuddyError("credentials", "Invalid WorkBuddy account scope.");
|
|
80
|
+
return key;
|
|
81
|
+
}
|
|
82
|
+
function fresh(snapshot) {
|
|
83
|
+
const age = timestamp() - snapshot.fetchedAt;
|
|
84
|
+
return age >= 0 && age < ttlMs;
|
|
85
|
+
}
|
|
86
|
+
function remember(key, snapshot) {
|
|
87
|
+
cache.delete(key); cache.set(key, snapshot);
|
|
88
|
+
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
|
|
89
|
+
}
|
|
90
|
+
function start(key, credential) {
|
|
91
|
+
if (pending.size >= maxEntries) throw new WorkbuddyError("credits-busy", "Too many WorkBuddy credit reads.");
|
|
92
|
+
const job = { controller: new AbortController(), users: 0, settled: false };
|
|
93
|
+
// Defer execution until the job has been registered, including synchronous client failures.
|
|
94
|
+
job.promise = Promise.resolve().then(async () => {
|
|
95
|
+
try {
|
|
96
|
+
if (job.controller.signal.aborted) throw cancelled();
|
|
97
|
+
const raw = await client.credits(credential, job.controller.signal);
|
|
98
|
+
if (job.controller.signal.aborted) throw cancelled();
|
|
99
|
+
const metrics = normalizeCredits(raw, timestamp());
|
|
100
|
+
let packageDetails;
|
|
101
|
+
if (typeof client.creditPackages === "function") {
|
|
102
|
+
let details;
|
|
103
|
+
try { details = await client.creditPackages(credential, job.controller.signal, raw); }
|
|
104
|
+
catch { if (job.controller.signal.aborted) throw cancelled(); }
|
|
105
|
+
packageDetails = normalizeCreditPackages(details, raw);
|
|
106
|
+
}
|
|
107
|
+
if (job.controller.signal.aborted) throw cancelled();
|
|
108
|
+
// All fields share the start-of-read timestamp, rather than making older metrics look newer.
|
|
109
|
+
const snapshot = Object.freeze({ ...metrics, ...(packageDetails ? { packageDetails } : {}) });
|
|
110
|
+
remember(key, snapshot);
|
|
111
|
+
return snapshot;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
const failure = job.controller.signal.aborted ? cancelled() : safeError(error);
|
|
114
|
+
if (!permitsStale(failure) && failure.diagnosticCode !== "cancelled") cache.delete(key);
|
|
115
|
+
throw failure;
|
|
116
|
+
}
|
|
117
|
+
}).finally(() => {
|
|
118
|
+
job.settled = true;
|
|
119
|
+
if (pending.get(key) === job) pending.delete(key);
|
|
120
|
+
});
|
|
121
|
+
pending.set(key, job);
|
|
122
|
+
return job;
|
|
123
|
+
}
|
|
124
|
+
function join(key, job, signal) {
|
|
125
|
+
job.users++;
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
let active = true;
|
|
128
|
+
const leave = () => {
|
|
129
|
+
active = false;
|
|
130
|
+
signal?.removeEventListener("abort", abort);
|
|
131
|
+
job.controller.signal.removeEventListener("abort", abort);
|
|
132
|
+
if (--job.users === 0 && !job.settled) {
|
|
133
|
+
if (pending.get(key) === job) pending.delete(key);
|
|
134
|
+
job.controller.abort();
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const abort = () => { if (active) { leave(); reject(cancelled()); } };
|
|
138
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
139
|
+
job.controller.signal.addEventListener("abort", abort, { once: true });
|
|
140
|
+
job.promise.then(value => { if (active) { leave(); resolve(value); } }, error => { if (active) { leave(); reject(error); } });
|
|
141
|
+
if (signal?.aborted || job.controller.signal.aborted) abort();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
async read(credential, { signal, force = false, allowNetwork = true } = {}) {
|
|
146
|
+
if (typeof force !== "boolean" || typeof allowNetwork !== "boolean" || signal !== undefined &&
|
|
147
|
+
(typeof signal?.aborted !== "boolean" || typeof signal.addEventListener !== "function" || typeof signal.removeEventListener !== "function")) {
|
|
148
|
+
throw new Error("Invalid WorkBuddy credit-read options.");
|
|
149
|
+
}
|
|
150
|
+
if (signal?.aborted) throw cancelled();
|
|
151
|
+
const key = keyFor(credential), saved = cache.get(key);
|
|
152
|
+
const network = allowNetwork && process.env.PI_OFFLINE === undefined;
|
|
153
|
+
if (saved && (!network || !force && fresh(saved))) {
|
|
154
|
+
remember(key, saved);
|
|
155
|
+
return { ...saved, source: "cache", stale: !fresh(saved) };
|
|
156
|
+
}
|
|
157
|
+
if (!network) throw new WorkbuddyError("credits-offline", "No cached WorkBuddy credits available offline.");
|
|
158
|
+
try {
|
|
159
|
+
const snapshot = await join(key, pending.get(key) ?? start(key, credential), signal);
|
|
160
|
+
return { ...snapshot, source: "remote", stale: false };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
const failure = safeError(error), previous = cache.get(key);
|
|
163
|
+
if (previous && permitsStale(failure)) return { ...previous, source: "cache", stale: true, error: failure.message };
|
|
164
|
+
throw failure;
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
clear(credential) {
|
|
168
|
+
const key = credential === undefined ? undefined : keyFor(credential);
|
|
169
|
+
for (const [id, job] of pending) if (key === undefined || id === key) {
|
|
170
|
+
pending.delete(id); job.controller.abort();
|
|
171
|
+
}
|
|
172
|
+
if (key === undefined) cache.clear(); else cache.delete(key);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/native-errors.js
CHANGED
|
@@ -15,6 +15,12 @@ const REASONS = {
|
|
|
15
15
|
"envelope": "The JSON response envelope was unexpected.",
|
|
16
16
|
"catalog-shape": "The response did not contain a supported models array.",
|
|
17
17
|
"catalog-empty": "The server returned an empty model list.",
|
|
18
|
+
"catalog-cache": "Cached model metadata was rejected; refresh to rebuild the catalog.",
|
|
19
|
+
"credits-shape": "WorkBuddy returned unsupported credit-summary metadata.",
|
|
20
|
+
"credits-request": "WorkBuddy credits could not be refreshed.",
|
|
21
|
+
"credits-offline": "No cached WorkBuddy credits are available while offline.",
|
|
22
|
+
"credits-scope": "The verified credit reader supports personal WorkBuddy accounts only.",
|
|
23
|
+
"credits-busy": "Too many WorkBuddy credit reads are in progress; retry shortly.",
|
|
18
24
|
"catalog-filtered": "No model passed the provider's compatibility/security filters.",
|
|
19
25
|
};
|
|
20
26
|
export class WorkbuddyError extends Error {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describeFailure } from "./native-errors.js";
|
|
2
|
+
|
|
3
|
+
function definition(model) {
|
|
4
|
+
return JSON.stringify([model?.provider, model?.id, model?.name, model?.contextWindow,
|
|
5
|
+
model?.maxTokens, model?.reasoning, model?.input, model?.thinkingLevelMap]);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function recoverSelectedModel(pi, provider, ctx, selected = ctx.model) {
|
|
9
|
+
// Never switch away from another provider, race a user selection, or alter a running turn.
|
|
10
|
+
if (!selected || selected.provider !== "workbuddy" || ctx.model !== selected || ctx.isIdle?.() === false) return;
|
|
11
|
+
let previousId = selected.id;
|
|
12
|
+
const placeholder = selected.name === "WorkBuddy Default (fallback)";
|
|
13
|
+
if (placeholder) {
|
|
14
|
+
// On resume Pi can replace a missing selection before session_start; the latest
|
|
15
|
+
// model-change record still identifies it. Never scan past a newer valid choice.
|
|
16
|
+
const previous = ctx.sessionManager?.getBranch?.().findLast(entry => entry.type === "model_change");
|
|
17
|
+
if (previous?.provider === "workbuddy" && typeof previous.modelId === "string") previousId = previous.modelId;
|
|
18
|
+
}
|
|
19
|
+
const id = /^deepseek-/i.test(previousId) && /-sg$/i.test(previousId) ? previousId.slice(0, -3) : previousId;
|
|
20
|
+
const replacement = provider.getModels().find(model => model.id === id) ??
|
|
21
|
+
(placeholder ? provider.getModels().find(model => model.id === selected.id) : undefined);
|
|
22
|
+
if (!replacement || definition(selected) === definition(replacement)) return;
|
|
23
|
+
if (await pi.setModel(replacement) === false) {
|
|
24
|
+
ctx.ui?.notify?.("WorkBuddy catalog is ready; select an available model with /model.", "warning");
|
|
25
|
+
} else if (id !== selected.id && replacement.id === id) {
|
|
26
|
+
ctx.ui?.notify?.("WorkBuddy restored the available model " + replacement.id + ". No chat request sent.", "info");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Embedded hosts can re-register providers without refreshing an already-seen provider ID. */
|
|
31
|
+
export function registerNativeLifecycle(pi, provider) {
|
|
32
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
33
|
+
const selected = ctx.model;
|
|
34
|
+
try {
|
|
35
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0])) return;
|
|
36
|
+
const result = await ctx.modelRegistry.refresh({
|
|
37
|
+
providers: ["workbuddy"], force: true, signal: ctx.signal,
|
|
38
|
+
// Honor PI_OFFLINE; otherwise inherit the registry's default network policy.
|
|
39
|
+
...(process.env.PI_OFFLINE !== undefined ? { allowNetwork: false } : {}),
|
|
40
|
+
});
|
|
41
|
+
if (result.aborted) return;
|
|
42
|
+
if (result.errors.has("workbuddy")) {
|
|
43
|
+
ctx.ui?.notify?.("WorkBuddy catalog refresh failed: " + describeFailure(result.errors.get("workbuddy")) +
|
|
44
|
+
" Cached models, if available, were retained. No chat request sent.", "warning");
|
|
45
|
+
}
|
|
46
|
+
await recoverSelectedModel(pi, provider, ctx, selected);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
ctx.ui?.notify?.("WorkBuddy startup recovery failed: " + describeFailure(error) +
|
|
49
|
+
" Use /workbuddy refresh to retry. No chat request sent.", "warning");
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/native-provider.js
CHANGED
|
@@ -3,12 +3,16 @@ import { fallbackModels, projectModels } from "./native-models.js";
|
|
|
3
3
|
import { createChatFetch } from "./native-transport.js";
|
|
4
4
|
import { describeFailure } from "./native-errors.js";
|
|
5
5
|
import { resolveOutputLimit, capPayloadOutput } from "./native-limits.js";
|
|
6
|
+
import { catalogCacheKey, catalogCacheEntry, restoreCatalog } from "./native-catalog-cache.js";
|
|
7
|
+
import { recoverSelectedModel } from "./native-lifecycle.js";
|
|
8
|
+
import { EXTENSION_VERSION } from "./client-identity.js";
|
|
6
9
|
|
|
7
10
|
export function createWorkbuddyProvider({ api, client = createNativeClient(), fetch: fetchImpl, gzip = true } = {}) {
|
|
8
11
|
if (!api?.stream || !api?.streamSimple) throw new Error("Pi OpenAI streaming API is required.");
|
|
9
12
|
let models = fallbackModels();
|
|
10
13
|
let catalogSource = "fallback";
|
|
11
14
|
let lastCatalogError;
|
|
15
|
+
let catalogAccountKey;
|
|
12
16
|
function prepareRequest(model, options = {}) {
|
|
13
17
|
let outputLimit = resolveOutputLimit(model, options.maxTokens);
|
|
14
18
|
const modelSampling = model.samplingParams ? { ...model.samplingParams } : undefined;
|
|
@@ -51,13 +55,33 @@ export function createWorkbuddyProvider({ api, client = createNativeClient(), fe
|
|
|
51
55
|
getModels: () => models,
|
|
52
56
|
getCatalogStatus: () => ({ source: catalogSource, lastError: lastCatalogError }),
|
|
53
57
|
async refreshModels(context) {
|
|
58
|
+
const key = catalogCacheKey(context.credential);
|
|
59
|
+
if (catalogAccountKey && catalogAccountKey !== key) {
|
|
60
|
+
if (await context.publish({ update: () => {
|
|
61
|
+
models = fallbackModels(); catalogSource = "fallback"; catalogAccountKey = undefined; lastCatalogError = undefined;
|
|
62
|
+
} }) === false) return;
|
|
63
|
+
}
|
|
64
|
+
if (catalogSource === "fallback" && context.stored) {
|
|
65
|
+
try {
|
|
66
|
+
const restored = restoreCatalog(context.stored, key);
|
|
67
|
+
if (restored && await context.publish({ update: () => {
|
|
68
|
+
models = restored; catalogSource = "cache"; catalogAccountKey = key; lastCatalogError = undefined;
|
|
69
|
+
} }) === false) return;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// Invalid or fully filtered caches must not block a fresh authenticated catalog fetch.
|
|
72
|
+
lastCatalogError = describeFailure(error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
54
75
|
if (!context.allowNetwork || context.credential?.type !== "oauth") return;
|
|
55
76
|
try {
|
|
56
77
|
const next = projectModels(await client.catalog(context.credential, context.signal));
|
|
57
|
-
//
|
|
58
|
-
await context.publish({
|
|
59
|
-
|
|
60
|
-
|
|
78
|
+
// Pi owns persistence and generation checks. Persist only projected public metadata.
|
|
79
|
+
await context.publish({
|
|
80
|
+
...(key ? { persist: catalogCacheEntry(next, key) } : {}),
|
|
81
|
+
update: () => {
|
|
82
|
+
models = next; catalogSource = "remote"; catalogAccountKey = key; lastCatalogError = undefined;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
61
85
|
} catch (error) {
|
|
62
86
|
lastCatalogError = describeFailure(error);
|
|
63
87
|
throw error;
|
|
@@ -83,11 +107,12 @@ export function registerNativeCommands(pi, provider) {
|
|
|
83
107
|
const ready = ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0]);
|
|
84
108
|
const catalog = provider.getCatalogStatus?.();
|
|
85
109
|
const active = ctx.model ? ctx.model.provider + "/" + ctx.model.id : "unknown";
|
|
86
|
-
ctx.ui.notify("WorkBuddy native (experimental): " + (ready ? "account configured" : "not logged in; use /login") +
|
|
110
|
+
ctx.ui.notify("WorkBuddy native " + EXTENSION_VERSION + " (experimental): " + (ready ? "account configured" : "not logged in; use /login") +
|
|
87
111
|
". " + provider.getModels().length + " known models; catalog=" + (catalog?.source ?? "unknown") +
|
|
88
112
|
". Active model: " + active + "." + (catalog?.lastError ? " Last catalog failure: " + catalog.lastError : "") +
|
|
89
113
|
" No chat request sent.", "info");
|
|
90
114
|
} else if (action === "refresh") {
|
|
115
|
+
const selected = ctx.model;
|
|
91
116
|
try {
|
|
92
117
|
if (!ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0])) {
|
|
93
118
|
ctx.ui.notify("Sign in to WorkBuddy using /login first.", "warning"); return;
|
|
@@ -102,6 +127,7 @@ export function registerNativeCommands(pi, provider) {
|
|
|
102
127
|
" Existing catalog retained. No chat request sent.", "warning");
|
|
103
128
|
return;
|
|
104
129
|
}
|
|
130
|
+
await recoverSelectedModel(pi, provider, ctx, selected);
|
|
105
131
|
ctx.ui.notify("WorkBuddy catalog refreshed: " + provider.getModels().length + " models. Select with /model.", "info");
|
|
106
132
|
} catch (error) {
|
|
107
133
|
ctx.ui.notify("WorkBuddy catalog refresh failed: " +
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createCreditsReader } from "./native-credits.js";
|
|
2
|
+
import { ORIGIN } from "./native-client.js";
|
|
3
|
+
import { createTooltipAdapter, TOOLTIP_STATUS_KEY } from "./pendant-tooltip.js";
|
|
4
|
+
|
|
5
|
+
/** Opt-in status transport for the optional Pendant adapter, not a native Pi usage hook. */
|
|
6
|
+
export function registerPendantTooltip(pi, { reader = createCreditsReader(), enabled = false } = {}) {
|
|
7
|
+
const wire = createTooltipAdapter();
|
|
8
|
+
let active = enabled, generation = 0, pending, lastContext, published = false;
|
|
9
|
+
let payloadState = enabled ? "waiting" : "disabled";
|
|
10
|
+
const clear = ctx => {
|
|
11
|
+
try { if (published) ctx?.ui?.setStatus?.(TOOLTIP_STATUS_KEY, undefined); } catch { /* Unsupported UI must not break a turn. */ }
|
|
12
|
+
published = false;
|
|
13
|
+
};
|
|
14
|
+
const session = ctx => ctx.sessionManager?.getSessionId?.();
|
|
15
|
+
function stop(ctx) {
|
|
16
|
+
generation++;
|
|
17
|
+
pending?.controller.abort(); pending = undefined;
|
|
18
|
+
reader.clear(); clear(ctx ?? lastContext);
|
|
19
|
+
payloadState = active ? "waiting" : "disabled";
|
|
20
|
+
}
|
|
21
|
+
async function refresh(ctx, force = false) {
|
|
22
|
+
lastContext = ctx;
|
|
23
|
+
if (!active || ctx.mode !== "rpc" || ctx.model?.provider !== "workbuddy") { stop(ctx); return; }
|
|
24
|
+
const sessionId = session(ctx);
|
|
25
|
+
if (typeof sessionId !== "string" || !/^[\w-]{1,128}$/.test(sessionId)) { stop(ctx); return; }
|
|
26
|
+
const identity = sessionId + "/" + ctx.model.id;
|
|
27
|
+
if (pending?.identity === identity && !force) return pending.promise;
|
|
28
|
+
generation++;
|
|
29
|
+
pending?.controller.abort();
|
|
30
|
+
const ticket = generation, controller = new AbortController();
|
|
31
|
+
const job = { identity, controller };
|
|
32
|
+
pending = job;
|
|
33
|
+
const current = () => active && generation === ticket && !controller.signal.aborted && session(ctx) === sessionId && ctx.model?.provider === "workbuddy";
|
|
34
|
+
const publish = (state, data = {}) => {
|
|
35
|
+
if (current()) {
|
|
36
|
+
try {
|
|
37
|
+
ctx.ui.setStatus(TOOLTIP_STATUS_KEY, wire.encode({ ...data, v: 1, provider: "workbuddy", sessionId, state }));
|
|
38
|
+
published = true; payloadState = state;
|
|
39
|
+
} catch { active = false; stop(ctx); }
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
job.promise = Promise.resolve().then(async () => {
|
|
43
|
+
publish("loading"); // Remove old balances while the current auth scope is being resolved.
|
|
44
|
+
if (!current()) { if (pending === job) pending = undefined; return; }
|
|
45
|
+
try {
|
|
46
|
+
const model = ctx.model;
|
|
47
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
48
|
+
if (!current()) return;
|
|
49
|
+
if (!auth?.ok) throw new Error("Account unavailable");
|
|
50
|
+
if (auth.baseUrl && new URL(auth.baseUrl).origin !== ORIGIN) throw new Error("Untrusted account origin");
|
|
51
|
+
const headers = new Headers(auth.headers);
|
|
52
|
+
const access = auth.apiKey || headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
|
|
53
|
+
const credential = { type: "oauth", origin: ORIGIN, access, userId: headers.get("X-User-Id"),
|
|
54
|
+
domain: headers.get("X-Domain") || "www.workbuddy.ai",
|
|
55
|
+
...(headers.get("X-Enterprise-Id") ? { enterpriseId: headers.get("X-Enterprise-Id") } : {}),
|
|
56
|
+
};
|
|
57
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, controller.signal]) : controller.signal;
|
|
58
|
+
const data = await reader.read(credential, { force, signal, allowNetwork: process.env.PI_OFFLINE === undefined });
|
|
59
|
+
publish("ready", data);
|
|
60
|
+
} catch {
|
|
61
|
+
if (current()) { reader.clear(); publish("unavailable"); }
|
|
62
|
+
} finally {
|
|
63
|
+
if (pending === job) pending = undefined;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
return job.promise;
|
|
67
|
+
}
|
|
68
|
+
pi.registerCommand("workbuddy-tooltip", {
|
|
69
|
+
description: "Experimental Pendant tooltip relay: on/off/refresh/status (requires the optional host adapter)",
|
|
70
|
+
async handler(args, ctx) {
|
|
71
|
+
const action = args.trim() || "status";
|
|
72
|
+
if (action === "off") { active = false; stop(ctx); ctx.ui.notify("WorkBuddy tooltip relay disabled.", "info"); return; }
|
|
73
|
+
if (action === "status") {
|
|
74
|
+
ctx.ui.notify("WorkBuddy tooltip relay: " + (active ? "enabled" : "disabled") + "; payload=" + payloadState +
|
|
75
|
+
". Requires the optional Pendant adapter v2 (renderer + broker). No chat request sent.", "info"); return;
|
|
76
|
+
}
|
|
77
|
+
if (!["on", "refresh"].includes(action)) { ctx.ui.notify("Usage: /workbuddy-tooltip [on|off|refresh|status]", "warning"); return; }
|
|
78
|
+
if (ctx.mode !== "rpc") { ctx.ui.notify("The experimental tooltip relay is for Pendant/RPC, not the CLI footer.", "warning"); return; }
|
|
79
|
+
if (action === "on") {
|
|
80
|
+
active = true;
|
|
81
|
+
ctx.ui.notify("Experimental WorkBuddy tooltip relay enabled. The separate Pendant adapter must already be installed; this command does not patch the host.", "info");
|
|
82
|
+
}
|
|
83
|
+
if (!active) { ctx.ui.notify("Enable the optional relay with /workbuddy-tooltip on first.", "warning"); return; }
|
|
84
|
+
await refresh(ctx, action === "refresh");
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
for (const event of ["session_start", "model_select", "agent_end"]) pi.on(event, async (_event, ctx) => { await refresh(ctx); });
|
|
88
|
+
pi.on("session_shutdown", async (_event, ctx) => { active = false; stop(ctx); });
|
|
89
|
+
return { refresh, dispose() { active = false; stop(lastContext); } };
|
|
90
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export const TOOLTIP_STATUS_KEY = "workbuddy.credits.v1";
|
|
2
|
+
|
|
3
|
+
/** Self-contained so the optional host adapter can embed exactly the same validator/renderer. */
|
|
4
|
+
export function createTooltipAdapter(statusKey = "workbuddy.credits.v1") {
|
|
5
|
+
const number = value => value === null || typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER;
|
|
6
|
+
const text = value => typeof value === "string" ? value.replace(/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, "").trim().slice(0, 128) : "";
|
|
7
|
+
const instant = value => value === null || Number.isSafeInteger(value) && value >= 0 && value <= 8640000000000000;
|
|
8
|
+
const wallClock = value => value === null || typeof value === "string" && /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(value) &&
|
|
9
|
+
Number.isFinite(Date.parse(value.replace(" ", "T") + "Z")) && new Date(value.replace(" ", "T") + "Z").toISOString().slice(0, 19) === value.replace(" ", "T");
|
|
10
|
+
const tone = (remaining, total, stale) => {
|
|
11
|
+
if (stale || remaining === null) return undefined;
|
|
12
|
+
if (remaining === 0) return "danger";
|
|
13
|
+
if (total > 0 && remaining <= total) {
|
|
14
|
+
const percent = remaining / total * 100;
|
|
15
|
+
return percent < 15 ? "danger" : percent < 50 ? "warning" : "good";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
function validate(input, maxItems = 4) {
|
|
19
|
+
if (!input || input.v !== 1 || input.provider !== "workbuddy" || typeof input.sessionId !== "string" || !/^[\w-]{1,128}$/.test(input.sessionId) ||
|
|
20
|
+
!["loading", "ready", "unavailable"].includes(input.state)) return undefined;
|
|
21
|
+
const packet = { v: 1, provider: "workbuddy", sessionId: input.sessionId, state: input.state };
|
|
22
|
+
if (input.state !== "ready") return packet;
|
|
23
|
+
if (![input.creditsTotal, input.creditsRemaining, input.creditsUsed].every(number) ||
|
|
24
|
+
!Number.isSafeInteger(input.packageCount) || input.packageCount < 0 || input.packageCount > 2000 ||
|
|
25
|
+
!Number.isSafeInteger(input.fetchedAt) || input.fetchedAt < 0 || input.fetchedAt > 8640000000000000 ||
|
|
26
|
+
!["cache", "remote"].includes(input.source) || typeof input.stale !== "boolean") return undefined;
|
|
27
|
+
Object.assign(packet, { creditsTotal: input.creditsTotal, creditsRemaining: input.creditsRemaining,
|
|
28
|
+
creditsUsed: input.creditsUsed, packageCount: input.packageCount, fetchedAt: input.fetchedAt,
|
|
29
|
+
source: input.source, stale: input.stale });
|
|
30
|
+
if (input.packageDetails !== undefined) {
|
|
31
|
+
const details = input.packageDetails;
|
|
32
|
+
if (!details || !["complete", "partial", "unavailable"].includes(details.state) ||
|
|
33
|
+
!Number.isSafeInteger(details.count) || details.count < 0 || details.count > 2000 || !Array.isArray(details.items) ||
|
|
34
|
+
details.items.length > maxItems || details.items.length > details.count ||
|
|
35
|
+
details.state === "unavailable" && (details.count !== 0 || details.items.length !== 0)) return undefined;
|
|
36
|
+
const items = [];
|
|
37
|
+
for (const item of details.items) {
|
|
38
|
+
if (!item || !["base", "bonus", "topup", "daily", "other"].includes(item.kind) ||
|
|
39
|
+
![item.total, item.remaining, item.used].every(number) || !instant(item.expiresAt) || !wallClock(item.expiresText) ||
|
|
40
|
+
item.expiresAt !== null && item.expiresText !== null) return undefined;
|
|
41
|
+
items.push({ kind: item.kind, total: item.total, remaining: item.remaining, used: item.used,
|
|
42
|
+
expiresAt: item.expiresAt, expiresText: item.expiresText });
|
|
43
|
+
}
|
|
44
|
+
packet.packageDetails = { state: details.state, count: details.count, items };
|
|
45
|
+
}
|
|
46
|
+
const name = text(input.planName);
|
|
47
|
+
if (name) packet.planName = name;
|
|
48
|
+
return packet;
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
encode(input) {
|
|
52
|
+
const packet = validate(input, 2000);
|
|
53
|
+
if (!packet) throw new Error("Invalid WorkBuddy tooltip payload.");
|
|
54
|
+
if (packet.packageDetails) {
|
|
55
|
+
const priority = item => item.kind === "base" ? 0 : item.kind === "daily" ? 1 : 2;
|
|
56
|
+
packet.packageDetails.items.sort((a, b) => priority(a) - priority(b) || (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
|
|
57
|
+
packet.packageDetails.items = packet.packageDetails.items.slice(0, 4);
|
|
58
|
+
}
|
|
59
|
+
return JSON.stringify(packet);
|
|
60
|
+
},
|
|
61
|
+
append(base, entries, context, now = Date.now()) {
|
|
62
|
+
if (base?.kind !== "grid" || !Array.isArray(base.rows) || context?.provider !== "workbuddy" ||
|
|
63
|
+
context.modelAvailable !== true || !context.sessionId || !Array.isArray(entries)) return base;
|
|
64
|
+
const candidates = entries.filter(entry => entry?.key === statusKey);
|
|
65
|
+
if (candidates.length !== 1 || typeof candidates[0].text !== "string" || candidates[0].text.length > 4096) return base;
|
|
66
|
+
let packet;
|
|
67
|
+
try { packet = validate(JSON.parse(candidates[0].text)); } catch { return base; }
|
|
68
|
+
if (!packet || packet.sessionId !== context.sessionId) return base;
|
|
69
|
+
const rows = [];
|
|
70
|
+
if (packet.state !== "ready") rows.push({ label: "WorkBuddy credits", value: packet.state === "loading" ? "Loading…" : "Unavailable",
|
|
71
|
+
...(packet.state === "unavailable" ? { valueTone: "warning" } : {}) });
|
|
72
|
+
else {
|
|
73
|
+
const format = value => value === null ? "Unknown" : value.toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
74
|
+
const stale = packet.stale || !Number.isFinite(now) || now < packet.fetchedAt || now - packet.fetchedAt >= 60000;
|
|
75
|
+
const remainingTone = tone(packet.creditsRemaining, packet.creditsTotal, stale);
|
|
76
|
+
if (packet.planName) rows.push({ label: "Plan", value: packet.planName });
|
|
77
|
+
rows.push({ label: "Credits remaining", value: format(packet.creditsRemaining), ...(remainingTone ? { valueTone: remainingTone } : {}) });
|
|
78
|
+
const details = packet.packageDetails;
|
|
79
|
+
if (details?.items.length) {
|
|
80
|
+
const counts = {}, names = { base: "Base credits", daily: "Daily credits", bonus: "Bonus", topup: "Top-up", other: "Credit pack" };
|
|
81
|
+
for (const item of details.items) {
|
|
82
|
+
counts[item.kind] = (counts[item.kind] ?? 0) + 1;
|
|
83
|
+
const indexed = !["base", "daily"].includes(item.kind) || details.items.filter(other => other.kind === item.kind).length > 1;
|
|
84
|
+
const valueTone = tone(item.remaining, item.total, stale);
|
|
85
|
+
rows.push({ label: names[item.kind] + (indexed ? " " + counts[item.kind] : ""),
|
|
86
|
+
value: format(item.remaining) + " / " + format(item.total) + " left", ...(valueTone ? { valueTone } : {}) });
|
|
87
|
+
rows.push({ label: item.expiresText ? "Expires (server time)" : "Expires", value: item.expiresAt !== null ?
|
|
88
|
+
new Date(item.expiresAt).toLocaleString() : item.expiresText ?? "Unknown" });
|
|
89
|
+
}
|
|
90
|
+
if (details.count > details.items.length) rows.push({ label: "More packages", value: String(details.count - details.items.length) + " not shown" });
|
|
91
|
+
} else {
|
|
92
|
+
rows.push({ label: "Credits used / total", value: format(packet.creditsUsed) + " / " + format(packet.creditsTotal) });
|
|
93
|
+
rows.push({ label: "Credit groups", value: String(packet.packageCount) });
|
|
94
|
+
}
|
|
95
|
+
if (details && details.state !== "complete") rows.push({ label: "Package details",
|
|
96
|
+
value: details.state === "partial" ? "Partial" : "Unavailable", valueTone: "warning" });
|
|
97
|
+
rows.push({ label: "Updated", value: new Date(packet.fetchedAt).toLocaleString() });
|
|
98
|
+
if (stale) {
|
|
99
|
+
rows.push({ label: "Credit data", value: "Cached / stale", valueTone: "warning" });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { ...base, rows: [...base.rows, ...rows] };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|