pi-para 0.6.6 → 0.7.0

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 (140) hide show
  1. package/ARCHITECTURE.md +4 -6
  2. package/CHANGELOG.md +22 -0
  3. package/CONFIGURATION.md +1 -16
  4. package/README.md +2 -13
  5. package/SETUP.md +1 -7
  6. package/STRUCTURE.md +7 -18
  7. package/dist/cli-legacy.d.ts +2 -1
  8. package/dist/cli-legacy.d.ts.map +1 -1
  9. package/dist/cli-legacy.js +13 -25
  10. package/dist/cli-legacy.js.map +1 -1
  11. package/dist/cli.d.ts +2 -24
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +29 -271
  14. package/dist/cli.js.map +1 -1
  15. package/dist/commands.d.ts.map +1 -1
  16. package/dist/commands.js +1 -28
  17. package/dist/commands.js.map +1 -1
  18. package/dist/config.d.ts +1 -5
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +0 -5
  21. package/dist/config.js.map +1 -1
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/context.js +2 -1
  24. package/dist/context.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -64
  27. package/dist/index.js.map +1 -1
  28. package/dist/link-utils.d.ts +1 -1
  29. package/dist/link-utils.js +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/settings.d.ts +0 -3
  34. package/dist/settings.d.ts.map +1 -1
  35. package/dist/settings.js +0 -11
  36. package/dist/settings.js.map +1 -1
  37. package/dist/store.d.ts +11 -0
  38. package/dist/store.d.ts.map +1 -1
  39. package/dist/store.js +48 -16
  40. package/dist/store.js.map +1 -1
  41. package/dist/tools/edit-read.d.ts +18 -0
  42. package/dist/tools/edit-read.d.ts.map +1 -0
  43. package/dist/tools/edit-read.js +161 -0
  44. package/dist/tools/edit-read.js.map +1 -0
  45. package/dist/tools/ingest.d.ts +12 -0
  46. package/dist/tools/ingest.d.ts.map +1 -0
  47. package/dist/tools/ingest.js +48 -0
  48. package/dist/tools/ingest.js.map +1 -0
  49. package/dist/tools/lint.d.ts +10 -0
  50. package/dist/tools/lint.d.ts.map +1 -0
  51. package/dist/tools/lint.js +57 -0
  52. package/dist/tools/lint.js.map +1 -0
  53. package/dist/tools/migrate.d.ts +6 -0
  54. package/dist/tools/migrate.d.ts.map +1 -0
  55. package/dist/tools/migrate.js +36 -0
  56. package/dist/tools/migrate.js.map +1 -0
  57. package/dist/tools/move.d.ts +9 -0
  58. package/dist/tools/move.d.ts.map +1 -0
  59. package/dist/tools/move.js +61 -0
  60. package/dist/tools/move.js.map +1 -0
  61. package/dist/tools/query.d.ts +12 -0
  62. package/dist/tools/query.d.ts.map +1 -0
  63. package/dist/tools/query.js +47 -0
  64. package/dist/tools/query.js.map +1 -0
  65. package/dist/tools/schemas.d.ts +109 -0
  66. package/dist/tools/schemas.d.ts.map +1 -0
  67. package/dist/tools/schemas.js +80 -0
  68. package/dist/tools/schemas.js.map +1 -0
  69. package/dist/tools/shared.d.ts +14 -0
  70. package/dist/tools/shared.d.ts.map +1 -0
  71. package/dist/tools/shared.js +25 -0
  72. package/dist/tools/shared.js.map +1 -0
  73. package/dist/tools/summarize.d.ts +8 -0
  74. package/dist/tools/summarize.d.ts.map +1 -0
  75. package/dist/tools/summarize.js +82 -0
  76. package/dist/tools/summarize.js.map +1 -0
  77. package/dist/tools/write.d.ts +23 -0
  78. package/dist/tools/write.d.ts.map +1 -0
  79. package/dist/tools/write.js +215 -0
  80. package/dist/tools/write.js.map +1 -0
  81. package/dist/tools.d.ts +1 -8
  82. package/dist/tools.d.ts.map +1 -1
  83. package/dist/tools.js +13 -795
  84. package/dist/tools.js.map +1 -1
  85. package/dist/wiki-tool-guidance.js +2 -2
  86. package/dist/wiki-tool-guidance.js.map +1 -1
  87. package/package.json +2 -6
  88. package/src/cli-legacy.ts +14 -26
  89. package/src/cli.ts +29 -293
  90. package/src/commands.ts +0 -21
  91. package/src/config.ts +1 -6
  92. package/src/context.ts +2 -1
  93. package/src/index.ts +5 -66
  94. package/src/link-utils.ts +1 -1
  95. package/src/scheduler/index.ts +9 -3
  96. package/src/settings.ts +0 -14
  97. package/src/store.ts +52 -15
  98. package/src/tools/edit-read.ts +193 -0
  99. package/src/tools/ingest.ts +63 -0
  100. package/src/tools/lint.ts +74 -0
  101. package/src/tools/migrate.ts +44 -0
  102. package/src/tools/move.ts +79 -0
  103. package/src/tools/query.ts +63 -0
  104. package/src/tools/schemas.ts +157 -0
  105. package/src/tools/shared.ts +35 -0
  106. package/src/tools/summarize.ts +89 -0
  107. package/src/tools/write.ts +252 -0
  108. package/src/tools.ts +18 -1046
  109. package/src/wiki-tool-guidance.ts +2 -2
  110. package/dist/daemon.d.ts +0 -49
  111. package/dist/daemon.d.ts.map +0 -1
  112. package/dist/daemon.js +0 -226
  113. package/dist/daemon.js.map +0 -1
  114. package/dist/link-discovery.d.ts +0 -40
  115. package/dist/link-discovery.d.ts.map +0 -1
  116. package/dist/link-discovery.js +0 -122
  117. package/dist/link-discovery.js.map +0 -1
  118. package/dist/maintainer.d.ts +0 -24
  119. package/dist/maintainer.d.ts.map +0 -1
  120. package/dist/maintainer.js +0 -358
  121. package/dist/maintainer.js.map +0 -1
  122. package/dist/watcher.d.ts +0 -25
  123. package/dist/watcher.d.ts.map +0 -1
  124. package/dist/watcher.js +0 -84
  125. package/dist/watcher.js.map +0 -1
  126. package/dist/webui/client/dist/index.html +0 -1885
  127. package/dist/webui/server.d.ts +0 -31
  128. package/dist/webui/server.d.ts.map +0 -1
  129. package/dist/webui/server.js +0 -566
  130. package/dist/webui/server.js.map +0 -1
  131. package/scripts/build-assets.mjs +0 -17
  132. package/src/daemon.ts +0 -288
  133. package/src/link-discovery.ts +0 -170
  134. package/src/maintainer.ts +0 -404
  135. package/src/python/__pycache__/minimax_lm.cpython-312.pyc +0 -0
  136. package/src/python/minimax_lm.py +0 -198
  137. package/src/python/rlm_capture.py +0 -215
  138. package/src/watcher.ts +0 -101
  139. package/src/webui/client/dist/index.html +0 -1885
  140. package/src/webui/server.ts +0 -765
package/src/commands.ts CHANGED
@@ -653,9 +653,6 @@ ${goal} — verified and complete.
653
653
  toggleLintAutoFix,
654
654
  setCaptureModelAuto,
655
655
  setCaptureModel,
656
- setWebWikiEnabled,
657
- setWebWikiHost,
658
- setWebWikiPort,
659
656
  ensureEmbeddingProfile,
660
657
  ensureRerankProfile,
661
658
  disableRerank,
@@ -689,7 +686,6 @@ ${goal} — verified and complete.
689
686
  `[Search] Limit: ${config.context.searchLimit}, Graph boost: ${config.context.searchGraphBoost}`,
690
687
  `[Lint] Auto-fix: ${config.lint.autoFix}, Stale days: ${config.lint.staleDays}`,
691
688
  `[Capture] Model: ${modelSelectionLabel(config.models.capture)}`,
692
- `[WebWiki] ${config.webWiki.enabled ? `Enabled at http://${config.webWiki.host}:${config.webWiki.port}` : "Disabled"}`,
693
689
  `[Embedding] ${providerProfileLabel(config.qmd.embedding)}`,
694
690
  `[Rerank] ${providerProfileLabel(config.qmd.rerank)}`,
695
691
  `[Secrets] ${secretsLabel}`,
@@ -783,23 +779,6 @@ ${goal} — verified and complete.
783
779
  await persist();
784
780
  ctx.ui.notify(`Set capture model = ${modelSelectionLabel(config.models.capture)}. Restart open Pi sessions to apply.`, "info");
785
781
  }
786
- } else if (choice.startsWith("[WebWiki]")) {
787
- const sub = await ctx.ui.select("Web Wiki Settings", [
788
- `Enabled: ${config.webWiki.enabled}`,
789
- `Host: ${config.webWiki.host}`,
790
- `Port: ${config.webWiki.port}`,
791
- ]);
792
- if (sub?.startsWith("Enabled")) {
793
- setWebWikiEnabled(config, !config.webWiki.enabled);
794
- await persist();
795
- ctx.ui.notify(`Web Wiki ${config.webWiki.enabled ? "enabled" : "disabled"}. Restart open Pi sessions to apply.`, "info");
796
- } else if (sub?.startsWith("Host")) {
797
- const val = await ctx.ui.input("Host:", config.webWiki.host);
798
- if (val) { setWebWikiHost(config, val); await persist(); }
799
- } else if (sub?.startsWith("Port")) {
800
- const val = await ctx.ui.input("Port:", String(config.webWiki.port));
801
- if (val) { setWebWikiPort(config, val); await persist(); }
802
- }
803
782
  } else if (choice.startsWith("[Secrets]")) {
804
783
  const entries = Object.entries(secretStore.secrets);
805
784
  const sub = await ctx.ui.select(
package/src/config.ts CHANGED
@@ -52,6 +52,7 @@ export interface ParaUserConfig {
52
52
  autoFix: boolean;
53
53
  staleDays: number;
54
54
  };
55
+ /** Deprecated compatibility input. Preserved when loading but ignored by the runtime. */
55
56
  webWiki: {
56
57
  enabled: boolean;
57
58
  host: string;
@@ -71,7 +72,6 @@ export interface LegacyParaRuntimeConfig {
71
72
  searchIncludeArchives: boolean;
72
73
  searchGraphBoost: boolean;
73
74
  daemonModel: string | null;
74
- webWiki: { enabled: boolean; host: string; port: number };
75
75
  }
76
76
 
77
77
  export interface LoadedParaConfig {
@@ -292,11 +292,6 @@ export function toLegacyRuntimeConfig(config: ParaUserConfig): LegacyParaRuntime
292
292
  searchIncludeArchives: false,
293
293
  searchGraphBoost: config.context.searchGraphBoost,
294
294
  daemonModel,
295
- webWiki: {
296
- enabled: config.webWiki.enabled,
297
- host: config.webWiki.host,
298
- port: config.webWiki.port,
299
- },
300
295
  };
301
296
  }
302
297
 
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.",
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { join } from "node:path";
12
- import { homedir, networkInterfaces } from "node:os";
12
+ import { homedir } from "node:os";
13
13
 
14
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
15
  import type { QMDStore } from "qmd-engine";
@@ -22,7 +22,6 @@ import { registerTools } from "./tools.js";
22
22
  import { setupContextInjection, markContextDirty } from "./context.js";
23
23
  import type { ContextConfig } from "./context.js";
24
24
  import { registerCommands } from "./commands.js";
25
- import { createServer } from "node:http";
26
25
  import { startWikiScheduler, stopWikiScheduler } from "./scheduler/index.js";
27
26
  import {
28
27
  appendCompletedSession,
@@ -59,12 +58,6 @@ interface ParaConfig {
59
58
  /** Legacy capture LLM: "provider/model-id" (e.g. "anthropic/claude-sonnet-4").
60
59
  * New config stores this as models.capture; legacy field remains for migration. */
61
60
  daemonModel: string | null;
62
- /** Web wiki UI settings */
63
- webWiki: {
64
- enabled: boolean;
65
- host: string;
66
- port: number;
67
- };
68
61
  }
69
62
 
70
63
  function getDefaultConfig(): ParaConfig {
@@ -79,11 +72,6 @@ function getDefaultConfig(): ParaConfig {
79
72
  searchIncludeArchives: false,
80
73
  searchGraphBoost: true,
81
74
  daemonModel: null,
82
- webWiki: {
83
- enabled: false,
84
- host: "0.0.0.0",
85
- port: 10973,
86
- },
87
75
  };
88
76
  }
89
77
 
@@ -107,43 +95,10 @@ async function loadConfig(): Promise<LoadedRuntimeConfig> {
107
95
  }
108
96
  }
109
97
 
110
- // -- LAN IP detection --------------------------------------------------------
111
-
112
- /** Check if the optional web wiki server is alive on the given port. */
113
- function checkWebServerAlive(port: number): Promise<boolean> {
114
- return new Promise((resolve) => {
115
- const req = createServer().listen(port, "127.0.0.1");
116
- req.on("error", (err: NodeJS.ErrnoException) => {
117
- req.close();
118
- // EADDRINUSE means something is already listening — daemon is running
119
- resolve(err.code === "EADDRINUSE");
120
- });
121
- req.on("listening", () => {
122
- // Port is free — no daemon web server
123
- req.close();
124
- resolve(false);
125
- });
126
- });
127
- }
128
-
129
98
  function isPrintModeProcess(argv = process.argv): boolean {
130
99
  return argv.includes("-p") || argv.includes("--print") || argv.includes("--mode") && argv[argv.indexOf("--mode") + 1] === "json";
131
100
  }
132
101
 
133
- function getLanIp(): string | null {
134
- const interfaces = networkInterfaces();
135
- for (const name of Object.keys(interfaces)) {
136
- const addrs = interfaces[name];
137
- if (!addrs) continue;
138
- for (const addr of addrs) {
139
- if (addr.family === "IPv4" && !addr.internal) {
140
- return addr.address;
141
- }
142
- }
143
- }
144
- return null;
145
- }
146
-
147
102
  // -- Session state -----------------------------------------------------------
148
103
 
149
104
  interface ParaSessionState {
@@ -370,27 +325,11 @@ export default async function piPara(pi: ExtensionAPI): Promise<void> {
370
325
  }
371
326
  }
372
327
 
373
- // Check if web wiki server is running (don't start our own yet)
374
- if (config.webWiki.enabled) {
375
- const port = config.webWiki.port;
376
- try {
377
- const alive = await checkWebServerAlive(port);
378
- if (alive) {
379
- const lanIp = getLanIp();
380
- const url = lanIp ? `http://${lanIp}:${port}` : `http://localhost:${port}`;
381
- ctx.ui.setStatus("pi-para", `wiki: ${url}`);
382
- } else {
383
- ctx.ui.setStatus("pi-para", "wiki: ready (web UI disabled)");
384
- if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 5000);
385
- }
386
- } catch {
387
- ctx.ui.setStatus("pi-para", "wiki: ready");
388
- if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
389
- }
390
- } else {
391
- ctx.ui.setStatus("pi-para", "wiki: ready");
392
- if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
328
+ if (loadedConfig.userConfig?.webWiki.enabled && !printMode) {
329
+ ctx.ui.notify("webWiki configuration is ignored because Web Wiki was removed in 0.7.", "warning");
393
330
  }
331
+ ctx.ui.setStatus("pi-para", "wiki: ready");
332
+ if (!printMode) setTimeout(() => ctx.ui.setStatus("pi-para", undefined), 3000);
394
333
  });
395
334
 
396
335
  // -- session_tree ----------------------------------------------------------
package/src/link-utils.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Shared wikilink utilities — extraction, auto-linking, and sync.
3
3
  *
4
4
  * Centralizes the [[wikilink]] logic that was duplicated across
5
- * tools.ts, lint.ts, processor.ts, and webui/server.ts.
5
+ * tools.ts, lint.ts, and processor.ts.
6
6
  */
7
7
 
8
8
  // -- Wikilink extraction ----------------------------------------------------
@@ -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 {
package/src/settings.ts CHANGED
@@ -54,20 +54,6 @@ export function setCaptureModel(config: ParaUserConfig, provider: string, model:
54
54
  return profile;
55
55
  }
56
56
 
57
- export function setWebWikiEnabled(config: ParaUserConfig, enabled: boolean): void {
58
- config.webWiki.enabled = enabled;
59
- }
60
-
61
- export function setWebWikiHost(config: ParaUserConfig, host: string): void {
62
- config.webWiki.host = host.trim() || config.webWiki.host;
63
- }
64
-
65
- export function setWebWikiPort(config: ParaUserConfig, value: string): number {
66
- const parsed = parsePositiveInteger(value, config.webWiki.port);
67
- config.webWiki.port = parsed;
68
- return parsed;
69
- }
70
-
71
57
  export function setProviderProfileField(
72
58
  profile: ProviderCredentialRef,
73
59
  field: "provider" | "baseUrl" | "model" | "apiFormat" | "credentialRef",
package/src/store.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * re-indexing after wiki changes, and embedding lifecycle.
6
6
  */
7
7
 
8
+ import { AsyncLocalStorage } from "node:async_hooks";
8
9
  import { readFileSync, existsSync } from "node:fs";
9
10
  import { homedir } from "node:os";
10
11
  import { join } from "node:path";
@@ -73,6 +74,51 @@ const RAW_CONTEXT = "Immutable source material: articles, documents, notes. Not
73
74
 
74
75
  const pendingEmbeds = new WeakMap<QMDStore, Promise<void>>();
75
76
 
77
+ export type QmdEmbedOperationResult<T> =
78
+ | { ok: true; value: T; diagnostics: string[] }
79
+ | { ok: false; error: unknown; diagnostics: string[] };
80
+
81
+ // qmd-engine's API adapter logs recoverable provider failures directly to
82
+ // console.error before returning null. Capture only those known messages so
83
+ // best-effort vector search and scheduler retries do not flash raw errors in
84
+ // the Pi UI. Other console errors continue to pass through unchanged.
85
+ const qmdEmbedDiagnosticContext = new AsyncLocalStorage<string[]>();
86
+ let qmdEmbedCaptureCount = 0;
87
+ let qmdEmbedOriginalConsoleError: typeof console.error | null = null;
88
+ const qmdEmbedConsoleInterceptor = (...args: unknown[]): void => {
89
+ const sink = qmdEmbedDiagnosticContext.getStore();
90
+ const first = String(args[0] ?? "");
91
+ if (sink && /^embed(?:Batch)? error:/i.test(first)) {
92
+ sink.push(args.map((arg) => arg instanceof Error ? arg.message : String(arg)).join(" "));
93
+ return;
94
+ }
95
+ qmdEmbedOriginalConsoleError?.(...args);
96
+ };
97
+
98
+ /** Run a QMD embedding operation without leaking its recoverable raw stderr. */
99
+ export async function captureQmdEmbedErrors<T>(operation: () => Promise<T>): Promise<QmdEmbedOperationResult<T>> {
100
+ const diagnostics: string[] = [];
101
+ if (qmdEmbedCaptureCount++ === 0) {
102
+ qmdEmbedOriginalConsoleError = console.error;
103
+ console.error = qmdEmbedConsoleInterceptor;
104
+ }
105
+ try {
106
+ return await qmdEmbedDiagnosticContext.run(diagnostics, async () => {
107
+ try {
108
+ return { ok: true, value: await operation(), diagnostics };
109
+ } catch (error) {
110
+ return { ok: false, error, diagnostics };
111
+ }
112
+ });
113
+ } finally {
114
+ qmdEmbedCaptureCount--;
115
+ if (qmdEmbedCaptureCount === 0) {
116
+ if (console.error === qmdEmbedConsoleInterceptor && qmdEmbedOriginalConsoleError) console.error = qmdEmbedOriginalConsoleError;
117
+ qmdEmbedOriginalConsoleError = null;
118
+ }
119
+ }
120
+ }
121
+
76
122
  // -- Inert-provider tracking ---------------------------------------------------
77
123
 
78
124
  /** Stores opened with the NO_LOCAL_PROVIDERS shim (no real API provider). */
@@ -184,22 +230,10 @@ export async function openStore(wikiDir: string, opts: OpenStoreOptions = {}): P
184
230
  if (opts.backgroundEmbed !== false && opts.paraConfig?.qmd.embedEnabled !== false) {
185
231
  // Schedule embedding in background (non-blocking).
186
232
  // BM25 search works immediately; hybrid search improves once embed completes.
187
- // Suppress console.error from qmd's internal embed failures.
188
- const origError = console.error;
189
- const embedPromise = store.embed().then(() => {
190
- pendingEmbeds.delete(store);
191
- }).catch(() => {
192
- // Embedding failures are non-fatal — hybrid search degrades to BM25
233
+ const embedPromise = captureQmdEmbedErrors(() => store.embed()).then(() => {
234
+ // Embedding failures are non-fatal — hybrid search degrades to BM25.
193
235
  pendingEmbeds.delete(store);
194
- }).finally(() => {
195
- console.error = origError;
196
236
  });
197
- // Temporarily suppress embed error noise during background embed
198
- console.error = (...args: unknown[]) => {
199
- const msg = String(args[0] ?? "");
200
- if (msg.includes("embed error") || msg.includes("fetch failed")) return;
201
- origError(...args);
202
- };
203
237
  pendingEmbeds.set(store, embedPromise);
204
238
  }
205
239
 
@@ -317,7 +351,10 @@ async function mergeLexAndVectorResults(
317
351
  const llm = (store.internal as unknown as { llm?: unknown }).llm;
318
352
  const embedModel = (llm as { embedModelName?: string } | undefined)?.embedModelName;
319
353
  if (status.hasVectorIndex && llm && embedModel) {
320
- vecResults = await store.internal.searchVec(query, embedModel, limit, "wiki", llm as any) as RawQmdSearchResult[];
354
+ const vectorAttempt = await captureQmdEmbedErrors(
355
+ () => store.internal.searchVec(query, embedModel, limit, "wiki", llm as any) as Promise<RawQmdSearchResult[]>,
356
+ );
357
+ if (vectorAttempt.ok) vecResults = vectorAttempt.value;
321
358
  }
322
359
  } catch {
323
360
  // Semantic search is best-effort; lexical BM25 remains the reliable fallback.
@@ -0,0 +1,193 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { QMDStore } from "qmd-engine";
3
+ import { formatFreshness } from "../query.js";
4
+ import { autoLinkSlugs, syncFrontmatterLinks } from "../link-utils.js";
5
+ import { normalizeTags, normalizeScopes } from "../tag-registry.js";
6
+ import { redactSecrets } from "../redact.js";
7
+ import { appendLog, gitCommit, listPages, readIndex, readPage, writePage, type WikiPage } from "../wiki.js";
8
+ import type { WikiReadDetails, WikiWriteDetails } from "./schemas.js";
9
+ import { parsePagePath, scheduleWikiMaintenance } from "./shared.js";
10
+
11
+ // -- Factory: wiki_read execute ----------------------------------------------
12
+
13
+ export function createEditExecute(
14
+ wikiDir: string,
15
+ store: QMDStore,
16
+ markDirty: () => void,
17
+ ) {
18
+ return async (
19
+ params: {
20
+ path: string;
21
+ edits: Array<{ oldText: string; newText: string }>;
22
+ title?: string;
23
+ scope?: string[];
24
+ tags?: string[];
25
+ logSummary?: string;
26
+ },
27
+ ): Promise<AgentToolResult<WikiWriteDetails>> => {
28
+ const parsed = parsePagePath(params.path);
29
+ if (!parsed) {
30
+ return {
31
+ content: [{ type: "text", text: `Invalid page path: ${params.path}` }],
32
+ details: { pagesWritten: [], pagesSkipped: [params.path], indexUpdated: false, logAppended: false },
33
+ };
34
+ }
35
+
36
+ const existing = await readPage(wikiDir, parsed.category, parsed.slug);
37
+ const pagePath = `${parsed.category}/${parsed.slug}`;
38
+ if (!existing) {
39
+ return {
40
+ content: [{ type: "text", text: `Page not found: ${pagePath}` }],
41
+ details: { pagesWritten: [], pagesSkipped: [pagePath], indexUpdated: false, logAppended: false },
42
+ };
43
+ }
44
+
45
+ if (params.edits.length === 0) {
46
+ return {
47
+ content: [{ type: "text", text: "No edits supplied." }],
48
+ details: { pagesWritten: [], pagesSkipped: [pagePath], indexUpdated: false, logAppended: false },
49
+ };
50
+ }
51
+
52
+ // Atomic validation: all oldText strings must exist exactly once before writing.
53
+ const errors: string[] = [];
54
+ for (const edit of params.edits) {
55
+ const first = existing.body.indexOf(edit.oldText);
56
+ const last = existing.body.lastIndexOf(edit.oldText);
57
+ if (first === -1) {
58
+ errors.push(`oldText not found: "${edit.oldText.slice(0, 60)}..."`);
59
+ } else if (first !== last) {
60
+ errors.push(`oldText is not unique: "${edit.oldText.slice(0, 60)}..."`);
61
+ }
62
+ }
63
+ if (errors.length > 0) {
64
+ return {
65
+ content: [{ type: "text", text: `No changes written.\n${errors.join("\n")}` }],
66
+ details: { pagesWritten: [], pagesSkipped: [pagePath], indexUpdated: false, logAppended: false },
67
+ };
68
+ }
69
+
70
+ let editedBody = existing.body;
71
+ for (const edit of params.edits) {
72
+ editedBody = editedBody.replace(edit.oldText, edit.newText);
73
+ }
74
+
75
+ // Surgical edit path: do not scan/autolink/rebuild/reindex the whole wiki.
76
+ // wiki_edit is often called mid-task and must stay fast; broader maintenance
77
+ // can run via wiki_lint/rebuildIndex or on startup.
78
+ const linkedBody = redactSecrets(editedBody).text;
79
+ const now = new Date().toISOString();
80
+ const rawScope = params.scope ?? existing.frontmatter.scope;
81
+ const newScope = normalizeScopes(rawScope);
82
+ const rawTags = params.tags ?? existing.frontmatter.tags;
83
+ const updatedPage: WikiPage = {
84
+ category: parsed.category,
85
+ slug: parsed.slug,
86
+ frontmatter: {
87
+ ...existing.frontmatter,
88
+ title: params.title ?? existing.frontmatter.title,
89
+ scope: newScope,
90
+ tags: normalizeTags(rawTags, newScope),
91
+ updated: now,
92
+ links: syncFrontmatterLinks(linkedBody),
93
+ },
94
+ body: linkedBody,
95
+ };
96
+
97
+ await writePage(wikiDir, updatedPage);
98
+ if (params.logSummary) {
99
+ await appendLog(wikiDir, {
100
+ date: now.split("T")[0],
101
+ operation: "edit",
102
+ summary: params.logSummary,
103
+ pages: [pagePath],
104
+ });
105
+ }
106
+ markDirty();
107
+ await gitCommit(wikiDir, params.logSummary ?? `wiki_edit: ${pagePath}`);
108
+ scheduleWikiMaintenance(wikiDir, store, markDirty);
109
+
110
+ return {
111
+ content: [{ type: "text", text: `Edited ${pagePath}. Scheduled background index/search refresh.` }],
112
+ details: {
113
+ pagesWritten: [pagePath],
114
+ pagesSkipped: [],
115
+ indexUpdated: false,
116
+ logAppended: !!params.logSummary,
117
+ },
118
+ };
119
+ };
120
+ }
121
+
122
+ export function createReadExecute(wikiDir: string) {
123
+ return async (
124
+ params: { path: string },
125
+ ): Promise<AgentToolResult<WikiReadDetails>> => {
126
+ const now = Date.now();
127
+ const normalizedPath = params.path.trim().toLowerCase().replace(/^\/+/, "");
128
+
129
+ if (normalizedPath === "index" || normalizedPath === "index.md" || normalizedPath === "wiki-index") {
130
+ const index = await readIndex(wikiDir);
131
+ return {
132
+ content: [{ type: "text", text: index }],
133
+ details: { found: true, path: "index.md" },
134
+ };
135
+ }
136
+
137
+ // Try parsing as category/slug path
138
+ const parsed = parsePagePath(params.path);
139
+ if (parsed) {
140
+ const page = await readPage(wikiDir, parsed.category, parsed.slug);
141
+ if (page) {
142
+ const fm = page.frontmatter;
143
+ const freshness = formatFreshness(fm.updated, now);
144
+ const header = [
145
+ `# ${fm.title}`,
146
+ `PARA: ${fm.para} | Scope: ${fm.scope.join(", ")} | Tags: ${fm.tags.join(", ")}`,
147
+ `Created: ${fm.created} | Updated: ${fm.updated} | ${freshness}`,
148
+ fm.sources.length > 0 ? `Sources: ${fm.sources.join(", ")}` : "",
149
+ fm.links.length > 0 ? `Links: ${fm.links.map((l) => `[[${l}]]`).join(", ")}` : "",
150
+ ]
151
+ .filter(Boolean)
152
+ .join("\n");
153
+
154
+ return {
155
+ content: [{ type: "text", text: `${header}\n\n${page.body}` }],
156
+ details: { found: true, path: `${parsed.category}/${parsed.slug}` },
157
+ };
158
+ }
159
+ }
160
+
161
+ // Try finding by title (case-insensitive search across all pages)
162
+ const allPages = await listPages(wikiDir);
163
+ const titleLower = params.path.toLowerCase();
164
+ for (const ref of allPages) {
165
+ if (ref.title.toLowerCase() === titleLower || ref.slug === titleLower) {
166
+ const page = await readPage(wikiDir, ref.category, ref.slug);
167
+ if (page) {
168
+ const fm = page.frontmatter;
169
+ const freshness = formatFreshness(fm.updated, now);
170
+ const header = [
171
+ `# ${fm.title}`,
172
+ `PARA: ${fm.para} | Scope: ${fm.scope.join(", ")} | Tags: ${fm.tags.join(", ")}`,
173
+ `Created: ${fm.created} | Updated: ${fm.updated} | ${freshness}`,
174
+ fm.sources.length > 0 ? `Sources: ${fm.sources.join(", ")}` : "",
175
+ fm.links.length > 0 ? `Links: ${fm.links.map((l) => `[[${l}]]`).join(", ")}` : "",
176
+ ]
177
+ .filter(Boolean)
178
+ .join("\n");
179
+
180
+ return {
181
+ content: [{ type: "text", text: `${header}\n\n${page.body}` }],
182
+ details: { found: true, path: `${ref.category}/${ref.slug}` },
183
+ };
184
+ }
185
+ }
186
+ }
187
+
188
+ return {
189
+ content: [{ type: "text", text: `Page not found: "${params.path}"` }],
190
+ details: { found: false },
191
+ };
192
+ };
193
+ }
@@ -0,0 +1,63 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { QMDStore } from "qmd-engine";
3
+ import type { ProjectScope } from "../scope.js";
4
+ import type { ParaCategory } from "../wiki.js";
5
+ import { resolveSource, truncateSource } from "../ingest.js";
6
+ import { INGEST_PROMPT } from "../templates/prompts.js";
7
+ import type { WikiIngestDetails } from "./schemas.js";
8
+
9
+ // -- Factory: wiki_ingest execute --------------------------------------------
10
+
11
+ export function createIngestExecute(
12
+ wikiDir: string,
13
+ _store: QMDStore,
14
+ getScope: () => ProjectScope,
15
+ ) {
16
+ return async (
17
+ params: { source: string; sourceType?: "url" | "file" | "text"; category?: ParaCategory; scope?: string[] },
18
+ ): Promise<AgentToolResult<WikiIngestDetails>> => {
19
+ const scope = getScope();
20
+ const resolved = await resolveSource(wikiDir, {
21
+ source: params.source,
22
+ sourceType: params.sourceType,
23
+ scope: params.scope,
24
+ category: params.category,
25
+ }, scope);
26
+
27
+ const categoryHint = resolved.categoryHint
28
+ ? `\nSuggested PARA category: ${resolved.categoryHint}`
29
+ : "";
30
+
31
+ // Assemble the tool result with instructions for the LLM
32
+ const toolResultText = [
33
+ INGEST_PROMPT,
34
+ "",
35
+ `Current scope: ${resolved.scopeName} (tags: ${resolved.scopeTags.join(", ")})`,
36
+ categoryHint,
37
+ resolved.rawPath ? `Raw source saved to: ${resolved.rawPath}` : "",
38
+ "",
39
+ "<wiki-schema>",
40
+ resolved.schema,
41
+ "</wiki-schema>",
42
+ "",
43
+ "<wiki-index>",
44
+ resolved.index,
45
+ "</wiki-index>",
46
+ "",
47
+ "<source-content>",
48
+ truncateSource(resolved.content),
49
+ "</source-content>",
50
+ "",
51
+ "Now analyze the source content and use wiki_write to create/update pages, update the index, and log the operation.",
52
+ ].join("\n");
53
+
54
+ return {
55
+ content: [{ type: "text", text: toolResultText }],
56
+ details: {
57
+ sourceType: resolved.sourceType,
58
+ rawPath: resolved.rawPath,
59
+ sourceLength: resolved.content.length,
60
+ },
61
+ };
62
+ };
63
+ }