pi-para 0.6.4 → 0.6.6

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 (47) hide show
  1. package/CHANGELOG.md +26 -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/credentials.d.ts +2 -2
  15. package/dist/credentials.d.ts.map +1 -1
  16. package/dist/credentials.js +6 -14
  17. package/dist/credentials.js.map +1 -1
  18. package/dist/doctor.js +19 -18
  19. package/dist/doctor.js.map +1 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -11
  22. package/dist/index.js.map +1 -1
  23. package/dist/link-utils.d.ts +4 -12
  24. package/dist/link-utils.d.ts.map +1 -1
  25. package/dist/link-utils.js +35 -16
  26. package/dist/link-utils.js.map +1 -1
  27. package/dist/lint.d.ts.map +1 -1
  28. package/dist/lint.js +51 -35
  29. package/dist/lint.js.map +1 -1
  30. package/dist/model-resolver.d.ts +13 -6
  31. package/dist/model-resolver.d.ts.map +1 -1
  32. package/dist/model-resolver.js +35 -19
  33. package/dist/model-resolver.js.map +1 -1
  34. package/dist/tools.d.ts +4 -1
  35. package/dist/tools.d.ts.map +1 -1
  36. package/dist/tools.js +11 -5
  37. package/dist/tools.js.map +1 -1
  38. package/package.json +9 -9
  39. package/src/cli.ts +69 -81
  40. package/src/commands.ts +15 -6
  41. package/src/credentials.ts +7 -14
  42. package/src/doctor.ts +18 -16
  43. package/src/index.ts +20 -11
  44. package/src/link-utils.ts +40 -16
  45. package/src/lint.ts +49 -33
  46. package/src/model-resolver.ts +45 -20
  47. package/src/tools.ts +16 -4
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;
@@ -246,7 +247,15 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
246
247
  });
247
248
 
248
249
  // Register tools, context injection, commands
249
- registerTools(pi, wikiDir, storeProxy, getScope, markContextDirty, () => config.searchGraphBoost);
250
+ registerTools(
251
+ pi,
252
+ wikiDir,
253
+ storeProxy,
254
+ getScope,
255
+ markContextDirty,
256
+ () => config.searchGraphBoost,
257
+ () => loadedConfig.userConfig?.lint ?? { autoFix: true, staleDays: 90 },
258
+ );
250
259
  setupContextInjection(pi, wikiDir, storeProxy, () => currentScope, getConfig);
251
260
  registerCommands(pi, wikiDir, storeProxy, getScope, setScope);
252
261
 
@@ -300,10 +309,10 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
300
309
  const startupCatchupEnabled = autoCaptureEnabled && wikiConfig?.captureOnStartup !== false && schedulerConfig?.startupCatchup !== false;
301
310
  try {
302
311
  const loadedParaConfig = await loadParaConfig({ migrate: true });
303
- const registry = autoCaptureEnabled ? await createPiModelRegistry() : null;
304
- if (registry) {
312
+ const services = autoCaptureEnabled ? await createPiModelServices() : null;
313
+ if (services) {
305
314
  const captureSelection = getCaptureSelection(loadedParaConfig.config);
306
- const captureModel = resolveSelectedModel(captureSelection, registry.modelRegistry, {
315
+ const captureModel = resolveSelectedModel(captureSelection, services.modelRegistry, {
307
316
  legacyModelSpec: config.daemonModel,
308
317
  preferredModelSpec: "anthropic/claude-sonnet-4-20250514",
309
318
  });
@@ -312,8 +321,8 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
312
321
  wikiDir,
313
322
  storeProvider: () => store,
314
323
  model: captureModel,
315
- getApiKey: createModelApiKeyResolver(captureSelection, registry.modelRegistry, {
316
- authStorage: registry.authStorage,
324
+ getApiKey: createModelApiKeyResolver(captureSelection, services.modelRegistry, {
325
+ credentials: services.credentials,
317
326
  secretsPath: loadedParaConfig.paths.secretsPath,
318
327
  }),
319
328
  });
@@ -324,7 +333,7 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
324
333
  }
325
334
 
326
335
  const embedEnabled = loadedConfig.userConfig?.qmd.embedEnabled !== false;
327
- const schedulerEnabled = !isPrintModeProcess() && schedulerConfig?.enabled !== false;
336
+ const schedulerEnabled = !printMode && schedulerConfig?.enabled !== false;
328
337
  const scheduler = schedulerEnabled
329
338
  ? startWikiScheduler({
330
339
  wikiDir,
@@ -372,15 +381,15 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
372
381
  ctx.ui.setStatus("pi-para", `wiki: ${url}`);
373
382
  } else {
374
383
  ctx.ui.setStatus("pi-para", "wiki: ready (web UI disabled)");
375
- setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 5000);
384
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 5000);
376
385
  }
377
386
  } catch {
378
387
  ctx.ui.setStatus("pi-para", "wiki: ready");
379
- setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
388
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
380
389
  }
381
390
  } else {
382
391
  ctx.ui.setStatus("pi-para", "wiki: ready");
383
- setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
392
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
384
393
  }
385
394
  });
386
395
 
package/src/link-utils.ts CHANGED
@@ -50,10 +50,14 @@ function findProtectedRanges(body: string): Array<[number, number]> {
50
50
  ranges.push([m.index, m.index + m[0].length]);
51
51
  }
52
52
 
53
- // Inline code
54
- const inlineCodeRe = /`[^`]+`/g;
53
+ // Inline code. Keep spans on one line and skip fenced regions already
54
+ // recorded above. The old /`[^`]+`/ pattern could start at a closing fence
55
+ // backtick and protect hundreds of characters of ordinary Markdown.
56
+ const inlineCodeRe = /`+[^`\n]+`+/g;
55
57
  while ((m = inlineCodeRe.exec(body)) !== null) {
56
- ranges.push([m.index, m.index + m[0].length]);
58
+ const start = m.index;
59
+ const end = m.index + m[0].length;
60
+ if (!isProtected(start, end, ranges)) ranges.push([start, end]);
57
61
  }
58
62
 
59
63
  // Existing [[wikilinks]]
@@ -89,19 +93,40 @@ function isProtected(pos: number, end: number, ranges: Array<[number, number]>):
89
93
  return false;
90
94
  }
91
95
 
96
+ /** Find plain-text slug mentions that autoLinkSlugs can actually fix. */
97
+ export function findUnlinkedSlugs(
98
+ body: string,
99
+ allSlugs: Set<string>,
100
+ ownSlug?: string,
101
+ ): string[] {
102
+ if (allSlugs.size === 0) return [];
103
+ const bodyLinks = new Set(extractWikilinks(body));
104
+ const protectedRanges = findProtectedRanges(body);
105
+ const unlinked: string[] = [];
106
+
107
+ const sortedSlugs = [...allSlugs]
108
+ .filter((slug) => slug !== ownSlug && !bodyLinks.has(slug))
109
+ .sort((a, b) => b.length - a.length);
110
+
111
+ for (const slug of sortedSlugs) {
112
+ const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113
+ const re = new RegExp(`(?<![a-z0-9-])${escaped}(?![a-z0-9-])`, "g");
114
+ let match: RegExpExecArray | null;
115
+ while ((match = re.exec(body)) !== null) {
116
+ const start = match.index;
117
+ const end = start + match[0].length;
118
+ if (!isProtected(start, end, protectedRanges)) {
119
+ unlinked.push(slug);
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ return unlinked;
125
+ }
126
+
92
127
  /**
93
- * Auto-link page slug mentions in body text with [[wikilinks]].
94
- *
95
- * - Wraps unlinked slug mentions in [[slug]]
96
- * - Skips code blocks, URLs, existing [[wikilinks]], headings
97
- * - Matches longest slugs first to avoid partial matches
98
- * - Uses word-boundary matching (won't match "pi-para" inside "pi-para-daemon")
99
- * - Only links each slug once per page (first occurrence) to avoid noise
100
- *
101
- * @param body - Page body markdown
102
- * @param allSlugs - Set of all existing page slugs
103
- * @param ownSlug - This page's own slug (excluded from auto-linking)
104
- * @returns Updated body with [[wikilinks]] inserted
128
+ * Auto-link page slug mentions while skipping code, URLs, headings, and
129
+ * existing wikilinks. Only the first fixable occurrence of each slug is linked.
105
130
  */
106
131
  export function autoLinkSlugs(
107
132
  body: string,
@@ -115,7 +140,6 @@ export function autoLinkSlugs(
115
140
  .filter(s => s !== ownSlug)
116
141
  .sort((a, b) => b.length - a.length);
117
142
 
118
- const protectedRanges = findProtectedRanges(body);
119
143
  const linkedSlugs = new Set(extractWikilinks(body));
120
144
 
121
145
  let result = body;
package/src/lint.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  extractWikilinks,
25
25
  removeWikilink,
26
26
  autoLinkSlugs,
27
+ findUnlinkedSlugs,
27
28
  syncFrontmatterLinks,
28
29
  } from "./link-utils.js";
29
30
  import { normalizeTags, normalizeScopes } from "./tag-registry.js";
@@ -415,22 +416,9 @@ function checkLinkSync(
415
416
  ): LintIssue[] {
416
417
  const issues: LintIssue[] = [];
417
418
  for (const p of pages) {
418
- // Find slugs mentioned in body but not wrapped in [[...]]
419
- const bodyLinks = new Set(extractWikilinks(p.body));
420
- const sortedSlugs = [...allSlugs]
421
- .filter(s => s !== p.slug)
422
- .sort((a, b) => b.length - a.length);
423
-
424
- const unlinked: string[] = [];
425
- for (const slug of sortedSlugs) {
426
- if (bodyLinks.has(slug)) continue;
427
- // Check if slug appears as plain text in body (word-boundary match)
428
- const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
429
- const re = new RegExp(`(?<![a-z0-9-])${escaped}(?![a-z0-9-])`);
430
- if (re.test(p.body)) {
431
- unlinked.push(slug);
432
- }
433
- }
419
+ // Use the exact same protected-range semantics as the fixer so every
420
+ // auto-fixable issue is actionable (no code-block/heading false positives).
421
+ const unlinked = findUnlinkedSlugs(p.body, allSlugs, p.slug);
434
422
 
435
423
  if (unlinked.length > 0) {
436
424
  issues.push({
@@ -603,7 +591,9 @@ async function fixSecrets(
603
591
  updated: new Date().toISOString(),
604
592
  },
605
593
  };
606
- await writePage(wikiDir, fixedPage);
594
+ p.body = fixedPage.body;
595
+ p.frontmatter = fixedPage.frontmatter;
596
+ await writePage(wikiDir, p);
607
597
 
608
598
  fixed.push({
609
599
  severity: "error",
@@ -650,7 +640,9 @@ async function fixLinkSync(
650
640
  updated: new Date().toISOString(),
651
641
  },
652
642
  };
653
- await writePage(wikiDir, fixedPage);
643
+ p.body = fixedPage.body;
644
+ p.frontmatter = fixedPage.frontmatter;
645
+ await writePage(wikiDir, p);
654
646
 
655
647
  if (addedLinks.length > 0) {
656
648
  fixed.push({
@@ -729,7 +721,9 @@ async function fixBrokenLinks(
729
721
  body: newBody,
730
722
  frontmatter: { ...p.frontmatter, links: newFmLinks },
731
723
  };
732
- await writePage(wikiDir, fixedPage);
724
+ p.body = fixedPage.body;
725
+ p.frontmatter = fixedPage.frontmatter;
726
+ await writePage(wikiDir, p);
733
727
 
734
728
  for (const link of broken) {
735
729
  fixed.push({
@@ -931,11 +925,10 @@ export async function lintWiki(
931
925
 
932
926
  // Load all pages
933
927
  const refs = await listPages(wikiDir);
934
- const pages: WikiPage[] = [];
935
- for (const ref of refs) {
936
- const page = await readPage(wikiDir, ref.category, ref.slug);
937
- if (page) pages.push(page);
938
- }
928
+ const loadedPages = await Promise.all(
929
+ refs.map((ref) => readPage(wikiDir, ref.category, ref.slug)),
930
+ );
931
+ const pages = loadedPages.filter((page): page is WikiPage => page !== null);
939
932
 
940
933
  // Build slug set
941
934
  const allSlugs = new Set(pages.map((p) => p.slug));
@@ -978,15 +971,38 @@ export async function lintWiki(
978
971
  // Auto-fix if enabled
979
972
  const fixed: LintIssue[] = [];
980
973
  if (autoFix) {
981
- // Fix secrets first — highest priority
982
- fixed.push(...(await fixSecrets(wikiDir, pages)));
983
- fixed.push(...(await fixBrokenLinks(wikiDir, pages, allSlugs)));
984
- fixed.push(...(await fixLinkSync(wikiDir, pages, allSlugs)));
985
- fixed.push(...(await fixTagHealth(wikiDir, pages)));
986
- fixed.push(...(await fixIndexDrift(wikiDir, pages, indexContent)));
987
- fixed.push(...(await fixFrontmatter(wikiDir, pages)));
988
- fixed.push(...(await fixScopeDrift(wikiDir, pages)));
989
- fixed.push(...(await fixSchemaVersion(wikiDir, pages)));
974
+ // Only run a fixer against pages its check identified. Previously every
975
+ // fixer rescanned all P pages (and link sync scanned all S slugs per page)
976
+ // even when that category had zero issues.
977
+ const affectedPages = (category: LintIssue["category"]): WikiPage[] => {
978
+ const paths = new Set(
979
+ issues
980
+ .filter((issue) => issue.category === category && issue.autoFixable && issue.page)
981
+ .map((issue) => issue.page!),
982
+ );
983
+ return paths.size === 0
984
+ ? []
985
+ : pages.filter((page) => paths.has(`${page.category}/${page.slug}`));
986
+ };
987
+ const hasIssue = (category: LintIssue["category"]): boolean =>
988
+ issues.some((issue) => issue.category === category && issue.autoFixable);
989
+
990
+ // Fix secrets first — highest priority.
991
+ const secretPages = affectedPages("secrets");
992
+ if (secretPages.length > 0) fixed.push(...(await fixSecrets(wikiDir, secretPages)));
993
+ const brokenPages = affectedPages("broken-link");
994
+ if (brokenPages.length > 0) fixed.push(...(await fixBrokenLinks(wikiDir, brokenPages, allSlugs)));
995
+ const linkPages = affectedPages("link-sync");
996
+ if (linkPages.length > 0) fixed.push(...(await fixLinkSync(wikiDir, linkPages, allSlugs)));
997
+ const tagPages = affectedPages("tag-health");
998
+ if (tagPages.length > 0) fixed.push(...(await fixTagHealth(wikiDir, tagPages)));
999
+ if (hasIssue("index-drift")) fixed.push(...(await fixIndexDrift(wikiDir, pages, indexContent)));
1000
+ const frontmatterPages = affectedPages("frontmatter");
1001
+ if (frontmatterPages.length > 0) fixed.push(...(await fixFrontmatter(wikiDir, frontmatterPages)));
1002
+ const scopePages = affectedPages("scope-drift");
1003
+ if (scopePages.length > 0) fixed.push(...(await fixScopeDrift(wikiDir, scopePages)));
1004
+ const schemaPages = affectedPages("schema-version");
1005
+ if (schemaPages.length > 0) fixed.push(...(await fixSchemaVersion(wikiDir, schemaPages)));
990
1006
  }
991
1007
 
992
1008
  // Recount broken links for stats
@@ -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;
package/src/tools.ts CHANGED
@@ -860,13 +860,23 @@ function createMoveExecute(
860
860
 
861
861
  // -- Factory: wiki_lint execute ----------------------------------------------
862
862
 
863
- function createLintExecute(wikiDir: string) {
863
+ function createLintExecute(
864
+ wikiDir: string,
865
+ store: QMDStore,
866
+ markDirty: () => void,
867
+ getLintConfig?: () => { autoFix: boolean; staleDays: number },
868
+ ) {
864
869
  return async (
865
870
  params: { autoFix?: boolean },
866
871
  ): Promise<AgentToolResult<WikiLintDetails>> => {
872
+ const configured = getLintConfig?.();
867
873
  const report = await lintWiki(wikiDir, {
868
- autoFix: params.autoFix ?? true,
874
+ autoFix: params.autoFix ?? configured?.autoFix ?? true,
875
+ staleDays: configured?.staleDays ?? 90,
869
876
  });
877
+ if (report.fixed.length > 0) {
878
+ scheduleWikiMaintenance(wikiDir, store, markDirty);
879
+ }
870
880
 
871
881
  const { issues, fixed, stats } = report;
872
882
 
@@ -1057,6 +1067,7 @@ export function registerTools(
1057
1067
  getScope: () => ProjectScope,
1058
1068
  markDirty: () => void,
1059
1069
  getGraphBoost?: () => boolean,
1070
+ getLintConfig?: () => { autoFix: boolean; staleDays: number },
1060
1071
  ): void {
1061
1072
  const ingestExec = createIngestExecute(wikiDir, store, getScope);
1062
1073
  const queryExec = createQueryExecute(wikiDir, store, getScope, getGraphBoost);
@@ -1064,7 +1075,7 @@ export function registerTools(
1064
1075
  const editExec = createEditExecute(wikiDir, store, markDirty);
1065
1076
  const readExec = createReadExecute(wikiDir);
1066
1077
  const moveExec = createMoveExecute(wikiDir, store, markDirty);
1067
- const lintExec = createLintExecute(wikiDir);
1078
+ const lintExec = createLintExecute(wikiDir, store, markDirty, getLintConfig);
1068
1079
  const migrateExec = createMigrateExecute(wikiDir);
1069
1080
  const summarizeExec = createSummarizeExecute(wikiDir, getScope);
1070
1081
 
@@ -1306,7 +1317,8 @@ export function registerTools(
1306
1317
  try { return await lintExec(params); } finally { ctx.ui.setStatus("pi-para", undefined); }
1307
1318
  },
1308
1319
  renderCall(args, theme) {
1309
- const mode = args.autoFix === false ? "report-only" : "auto-fix";
1320
+ const configuredAutoFix = getLintConfig?.().autoFix ?? true;
1321
+ const mode = (args.autoFix ?? configuredAutoFix) ? "auto-fix" : "report-only";
1310
1322
  return new Text(
1311
1323
  theme.fg("toolTitle", theme.bold("wiki_lint ")) +
1312
1324
  theme.fg("muted", `[${mode}]`),