pi-workbuddy 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 after the `pi-workbuddy` release is published:
11
+ Install the published npm package:
12
12
 
13
13
  ```sh
14
14
  pi install npm:pi-workbuddy
15
15
  ```
16
16
 
17
- Until the npm release is available, or to install directly from GitHub:
17
+ Or install directly from GitHub:
18
18
 
19
19
  ```sh
20
20
  pi install git:github.com/lutfi238/pi-workbuddy
@@ -50,15 +50,27 @@ Use `/reload` after updating a loaded extension. To sign out, run `/logout` and
50
50
  | `/workbuddy refresh` | Fetch the authenticated catalog, refreshing the token if needed |
51
51
  | `/model` | Refresh model catalogs in the background and select a model |
52
52
 
53
- Before discovery, the provider includes conservative fallback metadata for `default-model`. Models are not listed as available until authentication is configured. If discovery fails, the last known catalog is retained **in memory**; it is not persisted across restarts. Pi may invoke discovery during its refresh lifecycle, but the extension factory does not make network requests when loaded.
53
+ 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
54
 
55
55
  ### Automatic model discovery
56
56
 
57
57
  There is no hardcoded allowlist of WorkBuddy model IDs. The provider fetches `/v3/config` with the signed-in account and projects compatible model entries into Pi. In Pi 1.3.0, opening `/model` refreshes provider catalogs in the background and updates the picker when discovery finishes. Pi also refreshes catalogs after login; `/workbuddy refresh` is the explicit manual override.
58
58
 
59
- 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. Catalog visibility does not guarantee that your account can run every listed model.
59
+ 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
60
 
61
- This is refresh-based discovery, not a realtime push subscription or continuous idle polling. Reopen `/model` to check for additions during a long session. Offline mode does not fetch new catalogs. Extension updates may still be necessary if WorkBuddy changes its API, authentication, metadata schema, or wire protocol.
61
+ 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.
62
+
63
+ 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.
64
+
65
+ ### Pendant / VS Code stays on WorkBuddy Default (fallback)
66
+
67
+ 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.
68
+
69
+ 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.
70
+
71
+ ### Package published, but absent from gallery search
72
+
73
+ 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
74
 
63
75
  ### Login succeeds, but discovery fails or Codex remains selected
64
76
 
@@ -86,14 +98,14 @@ To select the provider, run `/model` and search for `workbuddy`; selecting a mod
86
98
  - **Chat:** `POST https://www.workbuddy.ai/v2/chat/completions`.
87
99
  - **Transport:** gzip requests, SSE framing and UTF-8 normalization, repeated or missing tool-call ID handling, usage tails, and cancellation.
88
100
  - **Message conversion and streaming:** delegated to Pi's built-in OpenAI Chat Completions implementation. Payload, response, and provider-event hooks are forwarded.
89
- - **Metadata:** live catalog metadata is supplemented with reviewed official vendor specifications for 24 named models. Context/output use the largest documented or advertised values, not smaller catalog defaults. Image support and reasoning efforts are additive. Routing aliases and newly discovered IDs remain catalog-driven; the reference table is not an allowlist. Disabled models, models without tool support, and custom model URLs, protocols, or API keys are excluded.
101
+ - **Metadata:** live catalog metadata is supplemented with reviewed official vendor specifications for 23 named models. Context/output use the largest documented or advertised values, not smaller catalog defaults. Image support and reasoning efforts are additive. Routing aliases and newly discovered IDs remain catalog-driven; the reference table is not an allowlist. Disabled models, models without tool support, and custom model URLs, protocols, or API keys are excluded.
90
102
  - **Pricing:** model prices are set to zero because USD token rates are unknown—**this does not mean usage is free**. WorkBuddy's `usage.credit` field is not included in Pi's cost accounting; token usage is still processed.
91
103
 
92
104
  ### Context and output budgets
93
105
 
94
106
  The default policy is **documented maximum capabilities**, not a conservative gateway-default policy. Context uses the largest positive safe integer among the catalog's input limit, configured context default, supported context lengths, and reviewed vendor context reference. Output similarly uses the larger catalog/vendor maximum, bounded by the shared context budget. Smaller catalog defaults do not reduce these maxima, and a larger live value is never reduced by an older vendor reference.
95
107
 
96
- For example, DeepSeek V4.1 Flash now advertises **1,048,576 context tokens**, **393,216 maximum output tokens**, image input, and **low/high/max** thinking. It no longer stays at the catalog's 300K default. Explicitly named regional/preview variants use their base-model references optimistically; identical gateway deployments are not guaranteed. Four routing aliases retain their own live metadata instead of inheriting an invented vendor identity.
108
+ For example, DeepSeek V4.1 Flash now advertises **1,048,576 context tokens**, **393,216 maximum output tokens**, image input, and **low/high/max** thinking. It no longer stays at the catalog's 300K default. Named preview variants use their base-model references optimistically; identical gateway deployments are not guaranteed. DeepSeek `-sg` variants are not offered. Four routing aliases retain their own live metadata instead of inheriting an invented vendor identity.
97
109
 
98
110
  Image support is enabled when either WorkBuddy or the reviewed vendor reference advertises it, even if gateway metadata disables multimodal input. Supported reasoning efforts combine both sources. Known mandatory-thinking models cannot turn thinking off; Kimi K2.6 uses a thinking toggle rather than an unsupported vendor `reasoning_effort` parameter. Pi exposes text/image input, not every vendor-native audio, video, or hosted-tool feature.
99
111
 
@@ -182,13 +194,23 @@ npm test
182
194
  node scripts/smoke-cli.mjs "C:/path/to/pi-coding-agent/dist/bundle/cli.js"
183
195
  ```
184
196
 
197
+ To additionally test against an installed Pendant bundle (no live credentials or inference):
198
+
199
+ ```powershell
200
+ $env:PI_SDK_TEST_PATH = "C:/path/to/pendant/dist/pi-sdk.cjs"
201
+ npm test
202
+ ```
203
+
204
+ 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.
205
+
185
206
  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
207
 
187
208
  Local investigation notes:
188
209
  [authentication](research/workbuddy-auth-findings.md) ·
189
210
  [chat protocol](research/workbuddy-protocol-findings.md) ·
190
211
  [error 12403](research/12403-findings.md) ·
191
- [context-limit audit](research/context-limit-audit.md).
212
+ [context-limit audit](research/context-limit-audit.md) ·
213
+ [Pendant recovery and gallery findings](research/pendant-recovery-findings.md).
192
214
 
193
215
  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
216
 
@@ -1,6 +1,7 @@
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";
4
5
 
5
6
  export default async function workbuddy(pi) {
6
7
  const mode = process.env.WORKBUDDY_MODE || "native";
@@ -12,4 +13,5 @@ export default async function workbuddy(pi) {
12
13
  const provider = createWorkbuddyProvider({ api: openAICompletionsApi(), gzip: process.env.WORKBUDDY_GZIP !== "0" });
13
14
  pi.registerProvider(provider);
14
15
  registerNativeCommands(pi, provider);
16
+ registerNativeLifecycle(pi, provider);
15
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workbuddy",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Native WorkBuddy account login and direct model provider for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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.1";
3
+ export const EXTENSION_VERSION = "0.2.3";
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;
@@ -12,9 +12,7 @@ const deepseek = {
12
12
  efforts: ["low", "high", "max"],
13
13
  source: "https://api-docs.deepseek.com/quick_start/pricing",
14
14
  };
15
- // Apply the named base model's documented abilities to the regional variant too.
16
- // This is an optimistic name mapping, not proof of identical gateway deployments.
17
- add(["deepseek-v4.1-flash", "deepseek-v4.1-flash-sg"], deepseek);
15
+ add(["deepseek-v4.1-flash"], deepseek);
18
16
  for (const id of ["gemini-3.5-flash", "gemini-3.8-flash"]) add([id], {
19
17
  contextWindow: 1048576, maxOutputTokens: 65536, images: true, reasoning: true,
20
18
  onlyReasoning: true,
@@ -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
+ }
@@ -15,6 +15,7 @@ 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.",
18
19
  "catalog-filtered": "No model passed the provider's compatibility/security filters.",
19
20
  };
20
21
  export class WorkbuddyError extends Error {
@@ -31,11 +32,11 @@ export function describeFailure(error) {
31
32
  for (let current = error, depth = 0; current && depth < 6 && !seen.has(current); current = current.cause, depth++) {
32
33
  seen.add(current);
33
34
  if (!(current instanceof WorkbuddyError) || !Object.hasOwn(REASONS, current.diagnosticCode)) continue;
34
- const { status, businessCode, total, accepted, endpoint, disabled, toolLess, unavailable, invalid, duplicate } = current.details;
35
+ const { status, businessCode, total, accepted, endpoint, disabled, toolLess, unavailable, invalid, duplicate, excluded } = current.details;
35
36
  const fields = [];
36
37
  if (Number.isInteger(status) && status >= 100 && status <= 599) fields.push("HTTP " + status);
37
38
  if (Number.isSafeInteger(businessCode)) fields.push("business code " + businessCode);
38
- for (const [name, value] of Object.entries({ total, accepted, endpoint, disabled, toolLess, unavailable, invalid, duplicate })) {
39
+ for (const [name, value] of Object.entries({ total, accepted, endpoint, disabled, toolLess, unavailable, invalid, duplicate, excluded })) {
39
40
  if (Number.isSafeInteger(value) && value >= 0) fields.push(name + "=" + value);
40
41
  }
41
42
  const hint = current.diagnosticCode === "http" && businessCode === 12403 ?
@@ -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
+ }
@@ -14,10 +14,12 @@ export function projectModels(config) {
14
14
  const seen = new Set();
15
15
  const models = [];
16
16
  const counts = { total: config.models.length, accepted: 0, invalid: 0, duplicate: 0,
17
- disabled: 0, toolLess: 0, endpoint: 0, unavailable: 0 };
17
+ disabled: 0, toolLess: 0, endpoint: 0, unavailable: 0, excluded: 0 };
18
18
  for (const item of config.models) {
19
19
  // Report only counts, never model URLs, API keys, remote instructions, or full catalog bodies.
20
20
  if (!validId(item?.id)) { counts.invalid++; continue; }
21
+ const normalizedId = item.id.toLowerCase();
22
+ if (normalizedId.startsWith("deepseek-") && normalizedId.endsWith("-sg")) { counts.excluded++; continue; }
21
23
  if (seen.has(item.id)) { counts.duplicate++; continue; }
22
24
  if (item.disabled === true) { counts.disabled++; continue; }
23
25
  if (item.supportsToolCall === false) { counts.toolLess++; continue; }
@@ -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
- // Generation-checked publication owned by Pi. No auth or remote instructions persisted.
58
- await context.publish({ update: () => {
59
- models = next; catalogSource = "remote"; lastCatalogError = undefined;
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: " +