pi-para 0.6.5 → 0.6.7

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CONFIGURATION.md +1 -1
  3. package/README.md +1 -1
  4. package/SETUP.md +2 -2
  5. package/STRUCTURE.md +1 -1
  6. package/dist/cli.d.ts +23 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +71 -83
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands.d.ts +2 -0
  11. package/dist/commands.d.ts.map +1 -1
  12. package/dist/commands.js +12 -5
  13. package/dist/commands.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +2 -1
  16. package/dist/context.js.map +1 -1
  17. package/dist/credentials.d.ts +2 -2
  18. package/dist/credentials.d.ts.map +1 -1
  19. package/dist/credentials.js +6 -14
  20. package/dist/credentials.js.map +1 -1
  21. package/dist/doctor.js +19 -18
  22. package/dist/doctor.js.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +14 -10
  25. package/dist/index.js.map +1 -1
  26. package/dist/model-resolver.d.ts +13 -6
  27. package/dist/model-resolver.d.ts.map +1 -1
  28. package/dist/model-resolver.js +35 -19
  29. package/dist/model-resolver.js.map +1 -1
  30. package/dist/scheduler/index.d.ts.map +1 -1
  31. package/dist/scheduler/index.js +9 -3
  32. package/dist/scheduler/index.js.map +1 -1
  33. package/dist/store.d.ts +11 -0
  34. package/dist/store.d.ts.map +1 -1
  35. package/dist/store.js +48 -16
  36. package/dist/store.js.map +1 -1
  37. package/dist/wiki-tool-guidance.js +2 -2
  38. package/dist/wiki-tool-guidance.js.map +1 -1
  39. package/package.json +9 -9
  40. package/src/cli.ts +69 -81
  41. package/src/commands.ts +15 -6
  42. package/src/context.ts +2 -1
  43. package/src/credentials.ts +7 -14
  44. package/src/doctor.ts +18 -16
  45. package/src/index.ts +11 -10
  46. package/src/model-resolver.ts +45 -20
  47. package/src/scheduler/index.ts +9 -3
  48. package/src/store.ts +52 -15
  49. package/src/wiki-tool-guidance.ts +2 -2
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { join } from "node:path";
9
+ import { pathToFileURL } from "node:url";
9
10
  import { homedir } from "node:os";
10
11
  import { existsSync, readdirSync, statSync } from "node:fs";
11
12
 
@@ -17,123 +18,108 @@ import { formatLegacyDaemonWarning, isLegacyDaemonCommand } from "./cli-legacy.j
17
18
  // -- Model setup -------------------------------------------------------------
18
19
  // Use MiniMax via the same config as qmd-engine
19
20
 
20
- async function createModel(modelArg?: string) {
21
+ export async function createModel(modelArg?: string) {
21
22
  const { parse } = await import("yaml");
22
23
  const { readFileSync } = await import("node:fs");
23
- const { getModel, getProviders, getEnvApiKey } = await import("@earendil-works/pi-ai/compat");
24
+ const { getProviders, getEnvApiKey } = await import("@earendil-works/pi-ai/compat");
25
+ const { createPiModelServices, parseProviderModelSpec } = await import("./model-resolver.js");
26
+ const { loadParaConfig } = await import("./config.js");
27
+ const { resolveCredentialRef } = await import("./credentials.js");
24
28
 
25
- // 0. Try config.json daemonModel
26
29
  if (!modelArg) {
27
30
  try {
28
31
  const configPath = join(homedir(), ".pi", "wiki", "config.json");
29
- const raw = readFileSync(configPath, "utf-8");
30
- const config = JSON.parse(raw);
32
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
31
33
  if (config.daemonModel) {
32
34
  modelArg = config.daemonModel;
33
35
  console.log(`[daemon] Using model from config.json: ${modelArg}`);
34
36
  }
35
37
  } catch {
36
- // No config or no daemonModel — continue to auto-detect
38
+ // No legacy daemon model configured.
37
39
  }
38
40
  }
39
41
 
40
- // 1. Try CLI --model arg or config daemonModel
41
- if (modelArg) {
42
- const [provider, ...rest] = modelArg.split("/");
43
- const modelId = rest.join("/");
44
- if (provider && modelId) {
45
- const model = getModel(provider as any, modelId as any);
46
- if (model) {
47
- // Resolve API key: try AuthStorage first (OAuth), then env vars
48
- let authStore: any = null;
49
- try {
50
- const { AuthStorage } = await import("@earendil-works/pi-coding-agent");
51
- authStore = AuthStorage.create();
52
- } catch {}
53
- const getApiKey = async (p: string) => {
54
- if (authStore) {
55
- const k = await authStore.getApiKey(p);
56
- if (k) return k;
57
- }
58
- return getEnvApiKey(p) ?? "";
59
- };
60
- console.log(`[daemon] Using pi model: ${provider}/${modelId} (context: ${model.contextWindow})`);
42
+ const services = await createPiModelServices();
43
+ let configuredCapture: Awaited<ReturnType<typeof loadParaConfig>> | null = null;
44
+ try { configuredCapture = await loadParaConfig(); } catch { /* optional fallback */ }
45
+ const configuredSelection = configuredCapture?.config.models.capture;
46
+ if (!modelArg && configuredSelection && configuredSelection !== "auto" && configuredSelection.model) {
47
+ modelArg = `${configuredSelection.provider}/${configuredSelection.model}`;
48
+ }
49
+
50
+ const configuredSecret = async (provider: string): Promise<string | undefined> => {
51
+ const selection = configuredCapture?.config.models.capture;
52
+ if (!selection || selection === "auto" || selection.provider !== provider || !selection.credentialRef.startsWith("secret:")) return undefined;
53
+ const resolved = await resolveCredentialRef(selection.credentialRef, { secretsPath: configuredCapture?.paths.secretsPath });
54
+ return resolved.ok ? resolved.value : undefined;
55
+ };
56
+ const getDurableKey = async (provider: string): Promise<string | undefined> =>
57
+ await services?.credentials.getApiKey(provider) ?? await configuredSecret(provider);
58
+
59
+ if (modelArg && !services) {
60
+ console.error("Error: Pi credential runtime is unavailable.");
61
+ process.exit(1);
62
+ }
63
+
64
+ if (modelArg && services) {
65
+ const spec = parseProviderModelSpec(modelArg);
66
+ const model = spec ? services.modelRegistry.find(spec.provider, spec.modelId) : undefined;
67
+ if (model && model.provider !== "node-llama-cpp" && model.provider !== "local") {
68
+ const key = await getDurableKey(model.provider) ?? getEnvApiKey(model.provider);
69
+ if (key) {
70
+ const getApiKey = async (provider: string) => await getDurableKey(provider) ?? getEnvApiKey(provider);
71
+ console.log(`[daemon] Using pi model: ${model.provider}/${model.id} (context: ${model.contextWindow})`);
61
72
  return { model, getApiKey };
62
73
  }
74
+ console.error(`Error: No credential available for Pi model: ${modelArg}`);
75
+ process.exit(1);
63
76
  }
77
+ console.error(`Error: Unknown Pi model: ${modelArg}`);
78
+ process.exit(1);
64
79
  }
65
80
 
66
- // 2. Try pi's auth storage (auth.json) — supports OAuth (Anthropic, GitHub Copilot, etc.)
67
- try {
68
- const { AuthStorage } = await import("@earendil-works/pi-coding-agent");
69
- const authStorage = AuthStorage.create();
70
- const { getModels } = await import("@earendil-works/pi-ai/compat");
71
-
72
- // Check providers in preference order: anthropic first (best quality)
81
+ if (services) {
73
82
  const preferredProviders = ["anthropic", "openai", "openrouter", "google-antigravity", "github-copilot"];
74
83
  for (const provider of preferredProviders) {
75
- if (!authStorage.hasAuth(provider)) continue;
76
- const key = await authStorage.getApiKey(provider);
77
- if (!key) continue;
78
-
79
- const models = getModels(provider as any);
84
+ if (!services.credentials.hasStoredCredential(provider) && !await configuredSecret(provider)) continue;
85
+ const models = services.modelRegistry.getAll().filter((model) => model.provider === provider && model.provider !== "node-llama-cpp" && model.provider !== "local");
80
86
  const sorted = [...models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
81
- const picked = sorted.find(m => !m.reasoning) ?? sorted[0];
87
+ const picked = sorted.find((model) => !model.reasoning) ?? sorted[0];
82
88
  if (picked) {
83
- const getApiKey = async (p: string) => {
84
- const k = await authStorage.getApiKey(p);
85
- return k ?? getEnvApiKey(p) ?? "";
86
- };
87
- console.log(`[daemon] Using pi auth: ${provider}/${picked.id} (context: ${picked.contextWindow})`);
89
+ const getApiKey = async (requestedProvider: string) => await getDurableKey(requestedProvider) ?? getEnvApiKey(requestedProvider);
90
+ console.log(`[daemon] Using persisted Pi auth: ${provider}/${picked.id} (context: ${picked.contextWindow})`);
88
91
  return { model: picked, getApiKey };
89
92
  }
90
93
  }
91
- } catch {
92
- // AuthStorage not available — continue to env/qmd fallback
93
94
  }
94
95
 
95
- // 3. Try env vars
96
- const providers = getProviders();
97
- for (const provider of providers) {
96
+ // Optional legacy environment compatibility.
97
+ for (const provider of getProviders()) {
98
98
  const key = getEnvApiKey(provider);
99
- if (key) {
100
- const { getModels } = await import("@earendil-works/pi-ai/compat");
101
- const models = getModels(provider as any);
102
- const sorted = [...models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
103
- const picked = sorted.find(m => !m.reasoning) ?? sorted[0];
104
- if (picked) {
105
- const getApiKey = async (p: string) => getEnvApiKey(p) ?? "";
106
- console.log(`[daemon] Using env key: ${provider}/${picked.id} (context: ${picked.contextWindow})`);
107
- return { model: picked, getApiKey };
108
- }
109
- }
99
+ if (!key) continue;
100
+ const models = services?.modelRegistry.getAll().filter((model) => model.provider === provider) ?? [];
101
+ const sorted = [...models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
102
+ const picked = sorted.find((model) => !model.reasoning) ?? sorted[0];
103
+ if (picked) return { model: picked, getApiKey: async (requestedProvider: string) => getEnvApiKey(requestedProvider) };
110
104
  }
111
105
 
112
- // 3. Fall back to qmd config (MiniMax, OpenRouter, etc.)
106
+ // Preserve the legacy CLI's QMD compatibility fallback.
113
107
  const configPath = join(homedir(), ".config", "qmd", "index.yml");
114
108
  if (existsSync(configPath)) {
115
109
  const cfg = parse(readFileSync(configPath, "utf-8"));
116
110
  const chat = cfg?.providers?.chat;
117
111
  if (chat?.url && chat?.key) {
118
112
  const model = {
119
- id: chat.model || "MiniMax-M2.7-highspeed",
120
- name: chat.model || "MiniMax-M2.7-highspeed",
121
- provider: "custom",
113
+ id: chat.model || "MiniMax-M2.7-highspeed", name: chat.model || "MiniMax-M2.7-highspeed", provider: "custom",
122
114
  api: chat.api === "anthropic" ? "anthropic-messages" as const : "openai-completions" as const,
123
- baseUrl: chat.url,
124
- reasoning: false,
125
- input: ["text" as const],
126
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
127
- contextWindow: 196000,
128
- maxTokens: 8192,
115
+ baseUrl: chat.url, reasoning: false, input: ["text" as const],
116
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 196000, maxTokens: 8192,
129
117
  };
130
- const getApiKey = async (_provider: string) => chat.key as string;
131
- console.log(`[daemon] Using qmd provider: ${chat.model} at ${chat.url}`);
132
- return { model, getApiKey };
118
+ return { model, getApiKey: async (_provider: string) => chat.key as string };
133
119
  }
134
120
  }
135
121
 
136
- console.error("Error: No LLM available. Set API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) or configure ~/.config/qmd/index.yml");
122
+ console.error("Error: No LLM available. Configure persisted Pi auth or ~/.pi/para/secrets.json (legacy env/QMD configuration is also supported).");
137
123
  process.exit(1);
138
124
  }
139
125
 
@@ -233,7 +219,7 @@ async function main() {
233
219
  const secrets = await readSecretStore();
234
220
  const names = Object.keys(secrets.secrets);
235
221
  console.log("pi-para providers");
236
- console.log(" Credential policy: Pi AuthStorage preferred; pi-para secrets fallback; env vars not used by setup.");
222
+ console.log(" Credential policy: persisted Pi auth preferred; pi-para secrets fallback; env vars not used by setup.");
237
223
  if (names.length === 0) console.log(" No pi-para local secrets configured.");
238
224
  for (const name of names) console.log(` secret:${name} = ${redactCredential(secrets.secrets[name])}`);
239
225
  }
@@ -440,12 +426,14 @@ Legacy compatibility commands:
440
426
  history Show processing history (--scope NAME to filter)
441
427
 
442
428
  Credential policy:
443
- Use Pi AuthStorage or ~/.pi/para/secrets.json. Setup does not use env vars for API keys.`);
429
+ Use persisted Pi auth or ~/.pi/para/secrets.json. Setup does not use env vars for API keys.`);
444
430
  break;
445
431
  }
446
432
  }
447
433
 
448
- main().catch((err) => {
449
- console.error(err);
450
- process.exit(1);
451
- });
434
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
435
+ main().catch((err) => {
436
+ console.error(err);
437
+ process.exit(1);
438
+ });
439
+ }
package/src/commands.ts CHANGED
@@ -25,6 +25,15 @@ import {
25
25
  validateFrontmatter,
26
26
  } from "./frontmatter.js";
27
27
  import { appendCompletedSession } from "./scheduler/session-capture.js";
28
+ import { createPiModelServices, type PiModelServices } from "./model-resolver.js";
29
+
30
+ export function listStoredCaptureProviders(services: PiModelServices): string[] {
31
+ return [...new Set(
32
+ services.modelRegistry.getAll()
33
+ .map((model) => model.provider)
34
+ .filter((provider) => services.credentials.hasStoredCredential(provider)),
35
+ )];
36
+ }
28
37
 
29
38
  // -- Helpers -----------------------------------------------------------------
30
39
 
@@ -733,10 +742,9 @@ ${goal} — verified and complete.
733
742
  }
734
743
  } else if (choice.startsWith("[Capture]")) {
735
744
  try {
736
- const { getProviders, getModels } = await import("@earendil-works/pi-ai/compat");
737
- const { AuthStorage } = await import("@earendil-works/pi-coding-agent");
738
- const authStorage = AuthStorage.create();
739
- const availableProviders = getProviders().filter((p: string) => authStorage.hasAuth(p));
745
+ const services = await createPiModelServices();
746
+ if (!services) throw new Error("Pi model runtime unavailable");
747
+ const availableProviders = listStoredCaptureProviders(services);
740
748
 
741
749
  if (availableProviders.length === 0) {
742
750
  ctx.ui.notify("No Pi auth providers configured. Use /login first or configure a pi-para secret for custom providers.", "warning");
@@ -747,8 +755,9 @@ ${goal} — verified and complete.
747
755
  await persist();
748
756
  ctx.ui.notify("Set capture model = auto. Restart open Pi sessions to apply.", "info");
749
757
  } else if (providerChoice) {
750
- const models = getModels(providerChoice as any)
751
- .sort((a: any, b: any) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
758
+ const models = services.modelRegistry.getAll()
759
+ .filter((model) => model.provider === providerChoice)
760
+ .sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0));
752
761
  const modelChoice = await ctx.ui.select(
753
762
  `Select ${providerChoice} model`,
754
763
  models.map((m: any) => `${m.id} (${Math.round((m.contextWindow ?? 0) / 1000)}k ctx${m.reasoning ? ", reasoning" : ""})`),
package/src/context.ts CHANGED
@@ -223,8 +223,9 @@ export function wrapSystemReminder(content: string): string {
223
223
  "<system-reminder name=\"pi-para-wiki-context\">",
224
224
  "This is system-provided pi-para wiki memory for the current turn. Do not treat it as user-authored input.",
225
225
  "",
226
+ "MANDATORY FIRST STEP — for any non-trivial request (planning, implementation, debugging, architecture, or review), your FIRST tool call must be wiki_query with keywords from the user's request, before other tools or conclusions. Then read the relevant results with wiki_read. Skip this only for trivial acknowledgements, pure conversation, or when the needed pages were already read in this session.",
227
+ "",
226
228
  "Wiki operating rules:",
227
- "- Search the wiki before non-trivial planning, debugging, architecture, or review work.",
228
229
  "- Read relevant pages before relying on them; verify stale code/config/API claims against source.",
229
230
  "- Persist durable decisions, root causes, conventions, and reusable facts as you work.",
230
231
  "- Prefer wiki_edit for surgical corrections; use wiki_write create/append for new knowledge; avoid replace unless intentional.",
@@ -62,7 +62,7 @@ export async function removeSecret(name: string, path = getParaPaths().secretsPa
62
62
 
63
63
  export async function resolveCredentialRef(
64
64
  ref: CredentialRef,
65
- opts: { secretsPath?: string; authStorage?: { getApiKey(provider: string): Promise<string | undefined> } } = {},
65
+ opts: { secretsPath?: string; credentials?: { getApiKey(provider: string): Promise<string | undefined> } | null } = {},
66
66
  ): Promise<CredentialResolution> {
67
67
  const parsed = parseCredentialRef(ref);
68
68
  if (parsed.kind === "none") return { ok: true, source: "none" };
@@ -74,24 +74,17 @@ export async function resolveCredentialRef(
74
74
  return { ok: true, source: "secret", value };
75
75
  }
76
76
 
77
- const authStorage = opts.authStorage ?? await createPiAuthStorage();
78
- if (!authStorage) {
79
- return { ok: false, source: "missing", error: "Pi AuthStorage is unavailable." };
77
+ const credentials = Object.hasOwn(opts, "credentials")
78
+ ? opts.credentials
79
+ : (await (await import("./model-resolver.js")).createPiModelServices())?.credentials;
80
+ if (!credentials) {
81
+ return { ok: false, source: "missing", error: "Pi credential runtime is unavailable." };
80
82
  }
81
- const value = await authStorage.getApiKey(parsed.name ?? "");
83
+ const value = await credentials.getApiKey(parsed.name ?? "");
82
84
  if (!value) return { ok: false, source: "missing", error: `Pi auth not configured for provider: ${parsed.name}` };
83
85
  return { ok: true, source: "pi-auth", value };
84
86
  }
85
87
 
86
- async function createPiAuthStorage(): Promise<{ getApiKey(provider: string): Promise<string | undefined> } | null> {
87
- try {
88
- const mod = await import("@earendil-works/pi-coding-agent");
89
- const AuthStorage = (mod as unknown as { AuthStorage?: { create(): { getApiKey(provider: string): Promise<string | undefined> } } }).AuthStorage;
90
- return AuthStorage?.create() ?? null;
91
- } catch {
92
- return null;
93
- }
94
- }
95
88
 
96
89
  export function redactCredential(value: string | undefined): string {
97
90
  if (!value) return "not set";
package/src/doctor.ts CHANGED
@@ -5,7 +5,7 @@ import { readSecretStore, resolveCredentialRef } from "./credentials.js";
5
5
  import { getParaPaths } from "./paths.js";
6
6
  import { openStore, closeStore } from "./store.js";
7
7
  import type { ProviderCredentialRef } from "./config.js";
8
- import { createPiModelRegistry, getCaptureSelection, resolveSelectedModel } from "./model-resolver.js";
8
+ import { createModelApiKeyResolver, createPiModelServices, getCaptureSelection, resolveSelectedModel } from "./model-resolver.js";
9
9
  import { SchedulerStateDB } from "./scheduler/state.js";
10
10
  import { ensureGeneratedStateGitignore, fixSecretPermissions, missingGeneratedStateGitignorePatterns } from "./repair.js";
11
11
 
@@ -51,7 +51,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise<DoctorResu
51
51
 
52
52
  checks.push(await checkSecrets(paths.secretsPath));
53
53
  checks.push(await checkGeneratedStateGitignore(paths.wikiDir));
54
- checks.push(...await checkProviderDiagnostics(loaded.config, paths.secretsPath, options));
54
+ checks.push(...await checkProviderDiagnostics(loaded.config, paths.secretsPath, `${paths.agentDir}/auth.json`, options));
55
55
 
56
56
  if (options.validateQmd !== false) {
57
57
  checks.push(await checkQmd(paths.wikiDir, loaded.config, paths.secretsPath));
@@ -131,6 +131,7 @@ async function checkGeneratedStateGitignore(wikiDir: string): Promise<DoctorChec
131
131
  async function checkProviderDiagnostics(
132
132
  config: ParaUserConfig,
133
133
  secretsPath: string,
134
+ authPath: string,
134
135
  options: DoctorOptions,
135
136
  ): Promise<DoctorCheck[]> {
136
137
  const checks: DoctorCheck[] = [];
@@ -144,49 +145,50 @@ async function checkProviderDiagnostics(
144
145
  if (!config.qmd.embedEnabled) {
145
146
  checks.push({ name: "qmd-embedding", status: "ok", message: "embedding disabled" });
146
147
  } else if (config.qmd.embedding) {
147
- checks.push(await checkCredentialProfile("qmd-embedding", config.qmd.embedding, secretsPath));
148
+ checks.push(await checkCredentialProfile("qmd-embedding", config.qmd.embedding, secretsPath, authPath));
148
149
  } else {
149
150
  checks.push({ name: "qmd-embedding", status: "warn", message: "embedding enabled but no pi-para embedding profile is configured" });
150
151
  }
151
152
 
152
153
  if (config.qmd.rerank) {
153
- checks.push(await checkCredentialProfile("qmd-rerank", config.qmd.rerank, secretsPath));
154
+ checks.push(await checkCredentialProfile("qmd-rerank", config.qmd.rerank, secretsPath, authPath));
154
155
  } else {
155
156
  checks.push({ name: "qmd-rerank", status: "ok", message: "rerank disabled" });
156
157
  }
157
158
 
158
- checks.push(await checkCaptureModel(config, secretsPath, options.testCaptureModel === true));
159
+ checks.push(await checkCaptureModel(config, secretsPath, authPath, options.testCaptureModel === true));
159
160
  return checks;
160
161
  }
161
162
 
162
- async function checkCredentialProfile(name: string, profile: ProviderCredentialRef, secretsPath: string): Promise<DoctorCheck> {
163
+ async function checkCredentialProfile(name: string, profile: ProviderCredentialRef, secretsPath: string, authPath: string): Promise<DoctorCheck> {
163
164
  const label = `${profile.provider}/${profile.model ?? "model not set"}`;
164
165
  if (profile.credentialRef === "none") {
165
166
  return { name, status: "ok", message: `${label} uses no credential` };
166
167
  }
167
- const resolved = await resolveCredentialRef(profile.credentialRef, { secretsPath });
168
+ const services = profile.credentialRef.startsWith("pi-auth:") ? await createPiModelServices({ authPath }) : null;
169
+ const resolved = await resolveCredentialRef(profile.credentialRef, { secretsPath, credentials: services?.credentials });
168
170
  if (!resolved.ok) {
169
171
  return { name, status: "warn", message: `${label} credential missing: ${resolved.error}` };
170
172
  }
171
173
  return { name, status: "ok", message: `${label} credential available via ${resolved.source}` };
172
174
  }
173
175
 
174
- async function checkCaptureModel(config: ParaUserConfig, secretsPath: string, testModel: boolean): Promise<DoctorCheck> {
176
+ async function checkCaptureModel(config: ParaUserConfig, secretsPath: string, authPath: string, testModel: boolean): Promise<DoctorCheck> {
175
177
  const selection = getCaptureSelection(config);
176
178
  if (!testModel) {
177
179
  if (selection === "auto") return { name: "capture-model", status: "ok", message: "auto selection; run doctor --test-capture-model to resolve" };
178
- return checkCredentialProfile("capture-model", selection, secretsPath);
180
+ return checkCredentialProfile("capture-model", selection, secretsPath, authPath);
179
181
  }
180
182
 
181
183
  try {
182
- const registry = await createPiModelRegistry();
183
- if (!registry) return { name: "capture-model", status: "warn", message: "Pi model registry unavailable" };
184
- const model = resolveSelectedModel(selection, registry.modelRegistry, { preferredModelSpec: "anthropic/claude-sonnet-4-20250514" });
184
+ const services = await createPiModelServices({ authPath });
185
+ if (!services) return { name: "capture-model", status: "warn", message: "Pi credential runtime is unavailable" };
186
+ const model = resolveSelectedModel(selection, services.modelRegistry, { preferredModelSpec: "anthropic/claude-sonnet-4-20250514" });
185
187
  if (!model) return { name: "capture-model", status: "warn", message: "no capture model resolved" };
186
- const getKey = selection === "auto"
187
- ? () => registry.modelRegistry.getApiKeyForProvider(model.provider)
188
- : () => resolveCredentialRef(selection.credentialRef, { secretsPath, authStorage: registry.authStorage }).then((r) => r.value);
189
- const key = await getKey();
188
+ const key = await createModelApiKeyResolver(selection, services.modelRegistry, {
189
+ credentials: services.credentials,
190
+ secretsPath,
191
+ })(model.provider);
190
192
  return key
191
193
  ? { name: "capture-model", status: "ok", message: `${model.provider}/${model.id} credential available` }
192
194
  : { name: "capture-model", status: "warn", message: `${model.provider}/${model.id} resolved but credential is missing` };
package/src/index.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  enqueueCompletedSessionsFromRegistry,
31
31
  } from "./scheduler/session-capture.js";
32
32
  import { loadParaConfig, toLegacyRuntimeConfig, type ParaUserConfig } from "./config.js";
33
- import { createModelApiKeyResolver, createPiModelRegistry, getCaptureSelection, resolveSelectedModel } from "./model-resolver.js";
33
+ import { createModelApiKeyResolver, createPiModelServices, getCaptureSelection, resolveSelectedModel } from "./model-resolver.js";
34
34
  import { StateDB } from "./state.js";
35
35
 
36
36
  // Re-export public types for consumers
@@ -169,6 +169,7 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
169
169
  const loadedConfig = await loadConfig();
170
170
  const config = loadedConfig.runtime;
171
171
  const wikiDir = config.wikiDir;
172
+ const printMode = isPrintModeProcess();
172
173
 
173
174
  let store: QMDStore | null = null;
174
175
  let currentScope: ProjectScope | null = null;
@@ -308,10 +309,10 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
308
309
  const startupCatchupEnabled = autoCaptureEnabled && wikiConfig?.captureOnStartup !== false && schedulerConfig?.startupCatchup !== false;
309
310
  try {
310
311
  const loadedParaConfig = await loadParaConfig({ migrate: true });
311
- const registry = autoCaptureEnabled ? await createPiModelRegistry() : null;
312
- if (registry) {
312
+ const services = autoCaptureEnabled ? await createPiModelServices() : null;
313
+ if (services) {
313
314
  const captureSelection = getCaptureSelection(loadedParaConfig.config);
314
- const captureModel = resolveSelectedModel(captureSelection, registry.modelRegistry, {
315
+ const captureModel = resolveSelectedModel(captureSelection, services.modelRegistry, {
315
316
  legacyModelSpec: config.daemonModel,
316
317
  preferredModelSpec: "anthropic/claude-sonnet-4-20250514",
317
318
  });
@@ -320,8 +321,8 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
320
321
  wikiDir,
321
322
  storeProvider: () => store,
322
323
  model: captureModel,
323
- getApiKey: createModelApiKeyResolver(captureSelection, registry.modelRegistry, {
324
- authStorage: registry.authStorage,
324
+ getApiKey: createModelApiKeyResolver(captureSelection, services.modelRegistry, {
325
+ credentials: services.credentials,
325
326
  secretsPath: loadedParaConfig.paths.secretsPath,
326
327
  }),
327
328
  });
@@ -332,7 +333,7 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
332
333
  }
333
334
 
334
335
  const embedEnabled = loadedConfig.userConfig?.qmd.embedEnabled !== false;
335
- const schedulerEnabled = !isPrintModeProcess() && schedulerConfig?.enabled !== false;
336
+ const schedulerEnabled = !printMode && schedulerConfig?.enabled !== false;
336
337
  const scheduler = schedulerEnabled
337
338
  ? startWikiScheduler({
338
339
  wikiDir,
@@ -380,15 +381,15 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
380
381
  ctx.ui.setStatus("pi-para", `wiki: ${url}`);
381
382
  } else {
382
383
  ctx.ui.setStatus("pi-para", "wiki: ready (web UI disabled)");
383
- setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 5000);
384
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 5000);
384
385
  }
385
386
  } catch {
386
387
  ctx.ui.setStatus("pi-para", "wiki: ready");
387
- setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
388
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
388
389
  }
389
390
  } else {
390
391
  ctx.ui.setStatus("pi-para", "wiki: ready");
391
- setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
392
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
392
393
  }
393
394
  });
394
395
 
@@ -3,15 +3,22 @@ import type { ProviderCredentialRef, ParaUserConfig } from "./config.js";
3
3
  import { resolveCredentialRef } from "./credentials.js";
4
4
 
5
5
  export interface ModelRegistryLike {
6
+ getAll(): Model<any>[];
6
7
  getAvailable(): Model<any>[];
7
8
  find(provider: string, modelId: string): Model<any> | undefined;
8
9
  getApiKeyForProvider(provider: string): Promise<string | undefined>;
9
10
  }
10
11
 
11
- export interface AuthStorageLike {
12
+ export interface PiCredentialReader {
13
+ hasStoredCredential(provider: string): boolean;
12
14
  getApiKey(provider: string): Promise<string | undefined>;
13
15
  }
14
16
 
17
+ export interface PiModelServices {
18
+ modelRegistry: ModelRegistryLike;
19
+ credentials: PiCredentialReader;
20
+ }
21
+
15
22
  export type ModelSelection = ProviderCredentialRef | "auto";
16
23
 
17
24
  export function parseProviderModelSpec(spec: string): { provider: string; modelId: string } | null {
@@ -20,6 +27,10 @@ export function parseProviderModelSpec(spec: string): { provider: string; modelI
20
27
  return { provider: spec.slice(0, idx), modelId: spec.slice(idx + 1) };
21
28
  }
22
29
 
30
+ function isRemoteModel(model: Model<any>): boolean {
31
+ return model.provider !== "node-llama-cpp" && model.provider !== "local" && !model.provider.includes("llama");
32
+ }
33
+
23
34
  export function resolveSelectedModel(
24
35
  selection: ModelSelection,
25
36
  registry: ModelRegistryLike,
@@ -27,47 +38,50 @@ export function resolveSelectedModel(
27
38
  ): Model<any> | null {
28
39
  if (selection !== "auto") {
29
40
  if (selection.model) return registry.find(selection.provider, selection.model) ?? null;
30
- return registry.getAvailable().find((model) => model.provider === selection.provider) ?? null;
41
+ return registry.getAvailable().find((model) => model.provider === selection.provider && isRemoteModel(model)) ?? null;
31
42
  }
32
43
 
33
44
  const legacy = opts.legacyModelSpec ? parseProviderModelSpec(opts.legacyModelSpec) : null;
34
45
  if (legacy) {
35
46
  const model = registry.find(legacy.provider, legacy.modelId);
36
- if (model) return model;
47
+ if (model && isRemoteModel(model)) return model;
37
48
  }
38
49
 
39
50
  const preferred = opts.preferredModelSpec ? parseProviderModelSpec(opts.preferredModelSpec) : null;
40
51
  if (preferred) {
41
52
  const model = registry.find(preferred.provider, preferred.modelId);
42
- if (model) return model;
53
+ if (model && isRemoteModel(model)) return model;
43
54
  }
44
55
 
45
- return pickBestAvailableModel(registry.getAvailable());
56
+ return pickBestAvailableModel(registry.getAvailable().filter(isRemoteModel));
46
57
  }
47
58
 
48
59
  export function pickBestAvailableModel(models: Model<any>[]): Model<any> | null {
49
- if (models.length === 0) return null;
50
- const sonnet = models.find((model) => model.provider === "anthropic" && /sonnet/i.test(model.id));
60
+ const remote = models.filter(isRemoteModel);
61
+ if (remote.length === 0) return null;
62
+ const sonnet = remote.find((model) => model.provider === "anthropic" && /sonnet/i.test(model.id));
51
63
  if (sonnet) return sonnet;
52
- return [...models].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0))[0] ?? null;
64
+ return [...remote].sort((a, b) => (b.contextWindow ?? 0) - (a.contextWindow ?? 0))[0] ?? null;
53
65
  }
54
66
 
55
67
  export function createModelApiKeyResolver(
56
68
  selection: ModelSelection,
57
69
  registry: ModelRegistryLike,
58
- opts: { authStorage?: AuthStorageLike; secretsPath?: string } = {},
70
+ opts: { credentials?: PiCredentialReader; secretsPath?: string } = {},
59
71
  ): (provider: string) => Promise<string | undefined> {
60
72
  return async (provider: string) => {
61
- if (selection === "auto") return registry.getApiKeyForProvider(provider);
73
+ if (selection === "auto") {
74
+ return opts.credentials ? opts.credentials.getApiKey(provider) : registry.getApiKeyForProvider(provider);
75
+ }
76
+ if (selection.provider !== provider) return undefined;
62
77
 
63
78
  if (selection.credentialRef.startsWith("pi-auth:")) {
64
79
  const configuredProvider = selection.credentialRef.slice("pi-auth:".length);
65
- if (configuredProvider !== provider && selection.provider !== provider) return undefined;
66
- return (await opts.authStorage?.getApiKey(provider)) ?? await registry.getApiKeyForProvider(provider);
80
+ if (configuredProvider !== provider) return undefined;
81
+ return opts.credentials ? opts.credentials.getApiKey(provider) : registry.getApiKeyForProvider(provider);
67
82
  }
68
83
 
69
84
  if (selection.credentialRef.startsWith("secret:")) {
70
- if (selection.provider !== provider) return undefined;
71
85
  const resolved = await resolveCredentialRef(selection.credentialRef, { secretsPath: opts.secretsPath });
72
86
  return resolved.ok ? resolved.value : undefined;
73
87
  }
@@ -80,17 +94,28 @@ export function getCaptureSelection(config: ParaUserConfig): ModelSelection {
80
94
  return config.models.capture;
81
95
  }
82
96
 
83
- export async function createPiModelRegistry(): Promise<{ authStorage: AuthStorageLike; modelRegistry: ModelRegistryLike } | null> {
97
+ export async function createPiModelServices(
98
+ options: { authPath?: string } = {},
99
+ ): Promise<PiModelServices | null> {
84
100
  try {
85
101
  const mod = await import("@earendil-works/pi-coding-agent");
86
- const typed = mod as unknown as {
87
- AuthStorage?: { create(): AuthStorageLike };
88
- ModelRegistry?: { create(authStorage: AuthStorageLike): ModelRegistryLike };
102
+ const runtime = await mod.ModelRuntime.create({ authPath: options.authPath, allowModelNetwork: false });
103
+ const modelRegistry = new mod.ModelRegistry(runtime);
104
+ await modelRegistry.refresh();
105
+ return {
106
+ modelRegistry,
107
+ credentials: {
108
+ hasStoredCredential: (provider) => Boolean(mod.readStoredCredential(provider, options.authPath)),
109
+ getApiKey: async (provider) => {
110
+ if (!mod.readStoredCredential(provider, options.authPath)) return undefined;
111
+ return (await runtime.getAuth(provider))?.auth.apiKey;
112
+ },
113
+ },
89
114
  };
90
- if (!typed.AuthStorage || !typed.ModelRegistry) return null;
91
- const authStorage = typed.AuthStorage.create();
92
- return { authStorage, modelRegistry: typed.ModelRegistry.create(authStorage) };
93
115
  } catch {
94
116
  return null;
95
117
  }
96
118
  }
119
+
120
+ /** @deprecated Use createPiModelServices. */
121
+ export const createPiModelRegistry = createPiModelServices;
@@ -1,7 +1,7 @@
1
1
  import type { QMDStore } from "qmd-engine";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { gitCommit, rebuildIndex } from "../wiki.js";
4
- import { reindex, storeHasApiProviders } from "../store.js";
4
+ import { captureQmdEmbedErrors, reindex, storeHasApiProviders } from "../store.js";
5
5
  import { getParaPaths } from "../paths.js";
6
6
  import { acquireLease, releaseLease } from "./leases.js";
7
7
  import { SchedulerStateDB, type QueueItem } from "./state.js";
@@ -166,10 +166,16 @@ export class WikiScheduler {
166
166
  const leaseKey = `qmd-embed:${this.wikiDir}`;
167
167
  if (!acquireLease(this.state.db, leaseKey, this.holderId, { ttlMs: 15 * 60_000 })) return;
168
168
  try {
169
- const result = await store.embed();
169
+ const attempt = await captureQmdEmbedErrors(() => store.embed());
170
+ const diagnostics = [...new Set(attempt.diagnostics)].join(" | ");
171
+ if (!attempt.ok) {
172
+ const reason = attempt.error instanceof Error ? attempt.error.message : String(attempt.error);
173
+ throw new Error(diagnostics ? `${reason} — ${diagnostics}` : reason);
174
+ }
175
+ const result = attempt.value;
170
176
  if (result.errors > 0) {
171
177
  throw new Error(
172
- `qmd embed finished with ${result.errors} error(s) (${result.chunksEmbedded} chunk(s) embedded, ${pending} doc(s) were pending)`,
178
+ `qmd embed finished with ${result.errors} error(s) (${result.chunksEmbedded} chunk(s) embedded, ${pending} doc(s) were pending)${diagnostics ? ` — ${diagnostics}` : ""}`,
173
179
  );
174
180
  }
175
181
  } finally {