pi-workbuddy 0.2.2 → 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,7 +50,7 @@ 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
 
@@ -58,7 +58,19 @@ There is no hardcoded allowlist of WorkBuddy model IDs. The provider fetches `/v
58
58
 
59
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
 
@@ -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.2",
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.2";
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;
@@ -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 {
@@ -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
+ }
@@ -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: " +