pi-para 0.6.3 → 0.6.5

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/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
@@ -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 } from "../store.js";
4
+ import { 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";
@@ -155,6 +155,9 @@ export class WikiScheduler {
155
155
  private async runQmdEmbed(): Promise<void> {
156
156
  const store = this.storeProvider();
157
157
  if (!store) return;
158
+ // No real embedding provider configured — embed calls would hit the inert
159
+ // shim endpoint (slow TCP timeouts on Windows) and always fail. Skip.
160
+ if (!storeHasApiProviders(store)) return;
158
161
 
159
162
  const status = await store.getStatus();
160
163
  const pending = status.needsEmbedding ?? 0;
package/src/status.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import Database from "better-sqlite3";
2
3
  import { loadParaConfig } from "./config.js";
3
4
  import { getParaPaths } from "./paths.js";
4
5
  import { listPages, type ParaCategory } from "./wiki.js";
@@ -20,6 +21,8 @@ export interface PiParaStatusResult {
20
21
  };
21
22
  scheduler: Record<QueueStatus, number>;
22
23
  qmdDbExists: boolean;
24
+ /** Read-only probe of the QMD DB (null when the DB doesn't exist/can't be read). */
25
+ embedding: { hasVectorIndex: boolean; needsEmbedding: number } | null;
23
26
  warnings: string[];
24
27
  }
25
28
 
@@ -46,6 +49,7 @@ export async function getPiParaStatus(options: PiParaStatusOptions = {}): Promis
46
49
  pages,
47
50
  scheduler,
48
51
  qmdDbExists: existsSync(paths.qmdDbPath),
52
+ embedding: getQmdEmbeddingStats(paths.qmdDbPath, warnings),
49
53
  warnings,
50
54
  };
51
55
  }
@@ -58,6 +62,9 @@ export function formatPiParaStatus(status: PiParaStatusResult): string {
58
62
  ` Pages: ${status.pages.total} total (${formatCategoryCounts(status.pages.byCategory)})`,
59
63
  ` Scheduler: ${status.scheduler.queued} queued, ${status.scheduler.running} running, ${status.scheduler.failed} failed`,
60
64
  ` QMD SDK DB: ${status.qmdDbExists ? status.qmdDbPath : "not initialized"}`,
65
+ ...(status.embedding
66
+ ? [` Embeddings: vector index ${status.embedding.hasVectorIndex ? "yes" : "no"}, pending ${status.embedding.needsEmbedding}`]
67
+ : []),
61
68
  ];
62
69
 
63
70
  if (status.warnings.length > 0) {
@@ -110,6 +117,43 @@ function getSchedulerCounts(dbPath: string, warnings: string[]): Record<QueueSta
110
117
  return counts;
111
118
  }
112
119
 
120
+ /**
121
+ * Cheap read-only probe of the QMD SQLite DB for embedding state — avoids
122
+ * opening the full store (~1s). Mirrors qmd-engine's getHashesNeedingEmbedding
123
+ * query (documents ⋈ content_vectors), which is stable across qmd 2.x.
124
+ */
125
+ function getQmdEmbeddingStats(
126
+ dbPath: string,
127
+ warnings: string[],
128
+ ): { hasVectorIndex: boolean; needsEmbedding: number } | null {
129
+ if (!existsSync(dbPath)) return null;
130
+ try {
131
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
132
+ try {
133
+ const tables = new Set(
134
+ (db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table','view')").all() as Array<{ name: string }>)
135
+ .map((row) => row.name),
136
+ );
137
+ if (!tables.has("documents") || !tables.has("content_vectors")) {
138
+ return { hasVectorIndex: false, needsEmbedding: 0 };
139
+ }
140
+ const vectors = db.prepare("SELECT COUNT(*) AS c FROM content_vectors").get() as { c: number };
141
+ const pending = db.prepare(`
142
+ SELECT COUNT(DISTINCT d.hash) AS c
143
+ FROM documents d
144
+ LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
145
+ WHERE d.active = 1 AND v.hash IS NULL
146
+ `).get() as { c: number };
147
+ return { hasVectorIndex: vectors.c > 0, needsEmbedding: pending.c };
148
+ } finally {
149
+ db.close();
150
+ }
151
+ } catch (err) {
152
+ warnings.push(`could not read QMD embedding stats: ${err instanceof Error ? err.message : String(err)}`);
153
+ return null;
154
+ }
155
+ }
156
+
113
157
  function formatCategoryCounts(counts: Record<ParaCategory, number>): string {
114
158
  return `projects ${counts.projects}, areas ${counts.areas}, resources ${counts.resources}, archives ${counts.archives}`;
115
159
  }
package/src/store.ts CHANGED
@@ -73,6 +73,21 @@ const RAW_CONTEXT = "Immutable source material: articles, documents, notes. Not
73
73
 
74
74
  const pendingEmbeds = new WeakMap<QMDStore, Promise<void>>();
75
75
 
76
+ // -- Inert-provider tracking ---------------------------------------------------
77
+
78
+ /** Stores opened with the NO_LOCAL_PROVIDERS shim (no real API provider). */
79
+ const inertProviderStores = new WeakSet<QMDStore>();
80
+
81
+ /**
82
+ * Whether the store was opened with a real (configured) embedding provider.
83
+ * When false, embedding calls would hit the inert shim endpoint and fail —
84
+ * slowly on platforms where localhost connects to closed ports time out
85
+ * (Windows firewall drops) — so background embedding must be skipped.
86
+ */
87
+ export function storeHasApiProviders(store: QMDStore): boolean {
88
+ return !inertProviderStores.has(store);
89
+ }
90
+
76
91
  // -- qmd provider config -----------------------------------------------------
77
92
 
78
93
  /**
@@ -132,6 +147,7 @@ export async function openStore(wikiDir: string, opts: OpenStoreOptions = {}): P
132
147
  })
133
148
  : loadQmdProviders();
134
149
  const providers = configuredProviders ?? NO_LOCAL_PROVIDERS;
150
+ const usingInertShim = !configuredProviders;
135
151
 
136
152
  const store = await createStore({
137
153
  dbPath: join(wikiDir, ".qmd.sqlite"),
@@ -152,6 +168,8 @@ export async function openStore(wikiDir: string, opts: OpenStoreOptions = {}): P
152
168
  },
153
169
  });
154
170
 
171
+ if (usingInertShim) inertProviderStores.add(store);
172
+
155
173
  // Add PARA category contexts to the wiki collection
156
174
  for (const [prefix, description] of Object.entries(PARA_CONTEXTS)) {
157
175
  await store.addContext("wiki", prefix, description);
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}]`),