@lincoln504/pi-research 0.1.13 → 1.0.1

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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Lazy loader for the @huggingface/transformers module.
3
+ *
4
+ * Same rationale as ./lancedb-loader.ts. The embedder/knowledge stack is reached
5
+ * only behind a dynamic import() (infrastructure/service-initialization.ts), but
6
+ * esbuild bundles the CLI into a single ESM file with `packages:'external'` and no
7
+ * splitting, and in that mode it HOISTS every *static* `import ... from
8
+ * '@huggingface/transformers'` to the top of the bundle. transformers' Node entry
9
+ * eagerly dlopens the native onnxruntime-node binding at module load, so a hoisted
10
+ * static import evaluates that native load before main() runs — and on any platform
11
+ * where the binding is absent (Intel macOS / darwin-x64, for which onnxruntime-node
12
+ * ships no binary; musl/Alpine; a partial install) the whole bundle fails to
13
+ * evaluate and even `pi-research --help` / `status` exit 1.
14
+ *
15
+ * Loading transformers through a runtime import() keeps esbuild from hoisting it, so
16
+ * the native binding is required only on first real embedder use — where
17
+ * KnowledgeStoreService.initialize() already catches a missing native stack
18
+ * (isNativeStackUnavailableError) and degrades the store to DISABLED. Help, version,
19
+ * status, and browser-only research then work with no binding present.
20
+ *
21
+ * Modules that need transformers *types* should keep `import type { … } from
22
+ * '@huggingface/transformers'` (type imports are erased and never hoisted); only
23
+ * runtime value access (pipeline, env, …) must go through getTransformers().
24
+ */
25
+
26
+ let _transformers: typeof import('@huggingface/transformers') | undefined;
27
+
28
+ /** Resolve the transformers module, memoizing after the first load. */
29
+ export async function getTransformers(): Promise<typeof import('@huggingface/transformers')> {
30
+ return (_transformers ??= await import('@huggingface/transformers'));
31
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Out-of-process WebGPU viability probe (child process entry point).
3
+ *
4
+ * WHY THIS EXISTS: onnxruntime-node bundles Dawn as its WebGPU execution
5
+ * provider. On a host whose only Vulkan adapter is software/paravirtual
6
+ * (llvmpipe/lavapipe, virtio/venus, SwiftShader, "Microsoft Basic Render
7
+ * Driver", ...), Dawn happily *loads* the pipeline but then SIGSEGVs the moment
8
+ * it dispatches the first compute shader. A SIGSEGV is a process-level signal,
9
+ * not a JavaScript exception — no try/catch, no Promise rejection handler, and
10
+ * none of the embedder's WebGPU->CPU fallback logic can ever observe it. The
11
+ * V8 isolate simply dies.
12
+ *
13
+ * The only reliable way to learn "will WebGPU compute crash on this machine?"
14
+ * is to actually run one tiny embedding in a DISPOSABLE child process and read
15
+ * its exit code. A native crash here kills only this child; the parent reads a
16
+ * non-zero exit / fatal signal and chooses CPU. This converts an uncatchable
17
+ * native crash into an ordinary, catchable signal.
18
+ *
19
+ * Contract — the parent judges viability by the STDOUT SENTINEL, not the exit
20
+ * code. This is deliberate: onnxruntime-node's native teardown can itself abort
21
+ * (SIGABRT, "Attempt to use DefaultLogger but none has been registered") when the
22
+ * process exits with a live ORT session — even after a perfectly successful
23
+ * compute. So:
24
+ * argv[2] = model id (e.g. onnx-community/granite-embedding-small-english-r2-ONNX)
25
+ * argv[3] = HuggingFace cache directory (shared with the parent)
26
+ * prints "PROBE_OK <dims>" to STDOUT (written synchronously) => compute worked
27
+ * no "PROBE_OK" on stdout (crash during compute, load failure, ...) => not viable
28
+ * A SIGSEGV during the compute dispatch (the software/paravirtual-GPU case) kills
29
+ * the child BEFORE the sentinel is written, so its absence is the signal.
30
+ *
31
+ * This file is plain .mjs (no TypeScript, no bundling). It is shipped verbatim
32
+ * next to the bundled entry points (dist/) and resolves @huggingface/transformers
33
+ * from the installed package's node_modules, exactly like the other entry points.
34
+ */
35
+
36
+ import process from 'node:process';
37
+ import { writeSync } from 'node:fs';
38
+
39
+ async function main() {
40
+ const model = process.argv[2];
41
+ const cacheDir = process.argv[3];
42
+ if (!model || !cacheDir) {
43
+ process.stderr.write('webgpu-probe: missing model/cacheDir args\n');
44
+ process.exit(2);
45
+ }
46
+
47
+ const { pipeline, env } = await import('@huggingface/transformers');
48
+ env.cacheDir = cacheDir;
49
+ // Keep the probe quiet — we only care about the exit code, not Dawn chatter.
50
+ try {
51
+ if (env.backends && env.backends.onnx) env.backends.onnx.logLevel = 'error';
52
+ } catch {
53
+ /* best-effort */
54
+ }
55
+
56
+ // Load on the WebGPU EP exactly as the real embedder would, then run ONE embed.
57
+ // The crash (if any) happens on this first compute dispatch, after load.
58
+ const pipe = await pipeline('feature-extraction', model, {
59
+ device: 'webgpu',
60
+ session_options: { intraOpNumThreads: 2, interOpNumThreads: 1 },
61
+ });
62
+
63
+ const out = await pipe('webgpu viability probe', { pooling: 'mean', normalize: true });
64
+
65
+ // Touch the result so the compute cannot be optimized away.
66
+ if (out && out.data && out.data.length > 0) {
67
+ // Write the sentinel SYNCHRONOUSLY to fd 1 so it is guaranteed on the pipe
68
+ // before process teardown — which may itself abort the ORT native runtime.
69
+ // The parent keys off this sentinel, so a teardown abort here is harmless.
70
+ writeSync(1, 'PROBE_OK ' + out.data.length + '\n');
71
+ process.exit(0);
72
+ }
73
+ process.stderr.write('webgpu-probe: empty embedding output\n');
74
+ process.exit(3);
75
+ }
76
+
77
+ main().catch((err) => {
78
+ process.stderr.write('webgpu-probe error: ' + (err && err.message ? err.message : String(err)) + '\n');
79
+ process.exit(1);
80
+ });
@@ -0,0 +1,255 @@
1
+ /**
2
+ * WebGPU viability resolution for the embedder.
3
+ *
4
+ * Resolves the configured embedding device ('auto' | 'webgpu' | 'cpu') to a
5
+ * concrete backend the embedder can hand to onnxruntime-node WITHOUT risking a
6
+ * native SIGSEGV in the main process:
7
+ *
8
+ * - 'cpu' -> 'cpu' (no probe; always safe)
9
+ * - 'webgpu' -> 'webgpu' (explicit force/override; advanced — no probe)
10
+ * - 'auto' -> run an out-of-process probe (see webgpu-probe.mjs) ONCE, cache
11
+ * the verdict per host signature, then return 'webgpu' if WebGPU
12
+ * compute actually works here, else 'cpu'.
13
+ *
14
+ * The probe runs in a disposable child process precisely because the failure
15
+ * mode on software/paravirtual GPUs is an uncatchable native crash, not a JS
16
+ * error. See webgpu-probe.mjs for the full rationale.
17
+ */
18
+
19
+ import { spawn } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { existsSync } from 'node:fs';
22
+ import * as fsp from 'node:fs/promises';
23
+ import * as path from 'node:path';
24
+
25
+ import { logger } from '../logger.ts';
26
+ import { getModelCacheDir } from './embedder-utils.ts';
27
+ import { isModelCached } from './embedder-init.ts';
28
+ import { WEBGPU_PROBE_TIMEOUT_MS } from '../constants.ts';
29
+
30
+ export type ResolvedDevice = 'webgpu' | 'cpu';
31
+
32
+ // Bump when the probe semantics change in a way that should invalidate cached
33
+ // verdicts (e.g. a different onnxruntime/Dawn version that fixes or breaks a path).
34
+ const VERDICT_SCHEMA = 1;
35
+
36
+ interface Verdict {
37
+ schema: number;
38
+ signature: string;
39
+ viable: boolean;
40
+ ts: number;
41
+ }
42
+
43
+ /**
44
+ * Host signature used as the verdict cache key. A change in platform, arch, Node
45
+ * major, or model invalidates the cached verdict. Driver-only changes are NOT
46
+ * captured (no portable way to read driver version without launching a GPU
47
+ * process); set PI_RESEARCH_WEBGPU_REPROBE=1 to force a fresh probe after a
48
+ * driver/GPU change.
49
+ */
50
+ function hostSignature(model: string): string {
51
+ const nodeMajor = process.versions.node.split('.')[0];
52
+ return `${process.platform}-${process.arch}-node${nodeMajor}-${model}`;
53
+ }
54
+
55
+ function verdictFilePath(): string {
56
+ // One level above the model cache dir: ~/.cache/pi-research/webgpu-viability.json
57
+ return path.join(path.dirname(getModelCacheDir()), 'webgpu-viability.json');
58
+ }
59
+
60
+ async function readVerdict(signature: string): Promise<boolean | null> {
61
+ if (process.env['PI_RESEARCH_WEBGPU_REPROBE'] === '1') return null;
62
+ try {
63
+ const raw = await fsp.readFile(verdictFilePath(), 'utf8');
64
+ const v = JSON.parse(raw) as Verdict;
65
+ if (v && v.schema === VERDICT_SCHEMA && v.signature === signature && typeof v.viable === 'boolean') {
66
+ return v.viable;
67
+ }
68
+ } catch {
69
+ /* missing/corrupt -> treat as no verdict */
70
+ }
71
+ return null;
72
+ }
73
+
74
+ async function writeVerdict(signature: string, viable: boolean): Promise<void> {
75
+ try {
76
+ const file = verdictFilePath();
77
+ await fsp.mkdir(path.dirname(file), { recursive: true });
78
+ const v: Verdict = { schema: VERDICT_SCHEMA, signature, viable, ts: Date.now() };
79
+ await fsp.writeFile(file, JSON.stringify(v), 'utf8');
80
+ } catch (err) {
81
+ logger.debug('[embedder] Could not persist WebGPU viability verdict:', err);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Locate the probe child script. In dev it sits next to this module
87
+ * (src/knowledge/webgpu-probe.mjs); in a built install it is copied next to the
88
+ * bundled entry point (dist/webgpu-probe.mjs) — in both cases it is a sibling of
89
+ * the running module file (import.meta.url), because esbuild flattens this module
90
+ * into the dist entry bundle. Returns null if it cannot be found (fail-safe: the
91
+ * caller then chooses CPU rather than risking a crash).
92
+ */
93
+ function probeScriptPath(): string | null {
94
+ try {
95
+ const here = path.dirname(fileURLToPath(import.meta.url));
96
+ const candidate = path.join(here, 'webgpu-probe.mjs');
97
+ return existsSync(candidate) ? candidate : null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Run the out-of-process probe. Viability is judged by the STDOUT SENTINEL
105
+ * ("PROBE_OK"), NOT the exit code: onnxruntime-node's native teardown can abort
106
+ * the child (SIGABRT) on exit even after a fully successful compute, so a
107
+ * non-zero exit does not imply failure. The sentinel is written synchronously
108
+ * before exit; a real compute crash (SIGSEGV on a software GPU) kills the child
109
+ * before the sentinel is ever produced. Timeout, spawn failure, or missing
110
+ * script => false (the safe CPU direction). A timeout is treated as a negative
111
+ * (cached CPU) on purpose: GPU behaviour is deterministic per host, so a hang
112
+ * would otherwise recur — and re-stall — on every start; the 30s cap is ~10x a
113
+ * real cached-model WebGPU init, so a false timeout on a capable host is unlikely
114
+ * (and PI_RESEARCH_WEBGPU_REPROBE=1 recovers it). NEVER throws.
115
+ */
116
+ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<boolean> {
117
+ return new Promise<boolean>((resolve) => {
118
+ const script = probeScriptPath();
119
+ if (!script) {
120
+ logger.warn('[embedder] WebGPU probe script not found — defaulting to CPU (safe).');
121
+ resolve(false);
122
+ return;
123
+ }
124
+
125
+ let settled = false;
126
+ const finish = (viable: boolean) => {
127
+ if (settled) return;
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ resolve(viable);
131
+ };
132
+
133
+ let child: ReturnType<typeof spawn>;
134
+ try {
135
+ child = spawn(process.execPath, [script, model, cacheDir], {
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ // Inherit env so XDG_CACHE_HOME etc. stay consistent with the parent.
138
+ env: process.env,
139
+ });
140
+ } catch (err) {
141
+ logger.warn('[embedder] WebGPU probe failed to spawn — defaulting to CPU:', err);
142
+ finish(false);
143
+ return;
144
+ }
145
+
146
+ const timer = setTimeout(() => {
147
+ logger.warn(`[embedder] WebGPU probe timed out after ${timeoutMs}ms — defaulting to CPU.`);
148
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
149
+ finish(false);
150
+ }, timeoutMs);
151
+ if (typeof timer.unref === 'function') timer.unref();
152
+
153
+ let stdout = '';
154
+ let stderr = '';
155
+ child.stdout?.on('data', (d) => { stdout += String(d); });
156
+ child.stderr?.on('data', (d) => { stderr += String(d); });
157
+
158
+ child.on('error', (err) => {
159
+ logger.warn('[embedder] WebGPU probe process error — defaulting to CPU:', err);
160
+ finish(false);
161
+ });
162
+
163
+ child.on('close', (code, signal) => {
164
+ // Viability is the sentinel, not the exit code (teardown may abort post-success).
165
+ if (stdout.includes('PROBE_OK')) {
166
+ finish(true);
167
+ return;
168
+ }
169
+ const detail = stderr.trim().split('\n').pop() ?? '';
170
+ const how = signal ? `signal ${signal}` : `exit ${code}`;
171
+ logger.info(`[embedder] WebGPU probe produced no success sentinel (${how}) — not viable here, using CPU.${detail ? ' (' + detail + ')' : ''}`);
172
+ finish(false);
173
+ });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * In-process memoization of the running probe, keyed by host signature. Mirrors
179
+ * the embedding-factory `_embeddingInitPromise` singleton idiom: the probe AND its
180
+ * `writeVerdict` persist run inside ONE promise held at module scope. If the caller
181
+ * that started the probe is torn down before it settles (e.g. a health check losing
182
+ * a `Promise.race` timeout, or a store dispose), the module-level reference keeps
183
+ * the promise alive so `writeVerdict` still runs on the continuation — so the
184
+ * expensive probe is paid at most once per process instead of repeating every init.
185
+ * Bypassed under PI_RESEARCH_WEBGPU_REPROBE=1 (same escape hatch as readVerdict).
186
+ */
187
+ let _probeInFlight: { signature: string; promise: Promise<boolean> } | null = null;
188
+
189
+ /**
190
+ * Resolve the configured device to a concrete backend. See module header.
191
+ *
192
+ * @param requested config EMBEDDING_DEVICE value ('auto' | 'webgpu' | 'cpu')
193
+ * @param model embedding model id (passed to the probe)
194
+ * @param timeoutMs caller's upper bound; the probe is additionally capped at
195
+ * WEBGPU_PROBE_TIMEOUT_MS (it runs only against an already-cached
196
+ * model, so it needs no model-download budget).
197
+ */
198
+ export async function resolveEmbeddingDevice(
199
+ requested: string,
200
+ model: string,
201
+ timeoutMs: number,
202
+ ): Promise<ResolvedDevice> {
203
+ if (requested === 'cpu') return 'cpu';
204
+ if (requested === 'webgpu') return 'webgpu';
205
+
206
+ // 'auto' (and any unexpected value) -> probe-gated.
207
+ const signature = hostSignature(model);
208
+
209
+ const cached = await readVerdict(signature);
210
+ if (cached !== null) {
211
+ logger.info(`[embedder] WebGPU viability (cached verdict): ${cached ? 'viable -> webgpu' : 'not viable -> cpu'}`);
212
+ return cached ? 'webgpu' : 'cpu';
213
+ }
214
+
215
+ // The probe loads the model on the WebGPU EP to test a real compute. If the model
216
+ // is not cached yet, downloading it INSIDE the probe would block init on a
217
+ // first-ever run and can hang for minutes on a GPU-less host. Defer instead: use
218
+ // CPU now WITHOUT caching a verdict, so the model downloads on the ordinary CPU
219
+ // path and the NEXT init (model present) runs a fast, download-free probe for the
220
+ // real verdict. Keeps the probe bounded and never on a model-download critical path.
221
+ if (!(await isModelCached(model))) {
222
+ logger.info('[embedder] Embedding model not cached yet — using CPU for now; WebGPU will be probed once the model is present.');
223
+ return 'cpu';
224
+ }
225
+
226
+ const reprobe = process.env['PI_RESEARCH_WEBGPU_REPROBE'] === '1';
227
+ if (reprobe || _probeInFlight?.signature !== signature) {
228
+ // Model is cached here, so the probe only does a WebGPU init + tiny compute:
229
+ // bound it tightly (WEBGPU_PROBE_TIMEOUT_MS) so it fails fast to CPU on a
230
+ // GPU-less/hanging host instead of running out the caller's full budget.
231
+ const probeTimeoutMs = Math.min(WEBGPU_PROBE_TIMEOUT_MS, timeoutMs);
232
+ logger.info('[embedder] Probing WebGPU viability in a disposable child process...');
233
+ const cacheDir = getModelCacheDir();
234
+ // runProbe never throws and writeVerdict swallows its own errors, so this
235
+ // promise never rejects — no poisoned-memo concern. The verdict is written
236
+ // INSIDE the memoized promise so it persists even if the awaiting caller is torn
237
+ // down before the probe settles — the expensive probe is paid at most once.
238
+ _probeInFlight = {
239
+ signature,
240
+ promise: (async () => {
241
+ const v = await runProbe(model, cacheDir, probeTimeoutMs);
242
+ await writeVerdict(signature, v);
243
+ return v;
244
+ })(),
245
+ };
246
+ }
247
+
248
+ const viable = await _probeInFlight.promise;
249
+ logger.info(
250
+ viable
251
+ ? '[embedder] WebGPU probe succeeded — using WebGPU.'
252
+ : '[embedder] WebGPU probe failed/crashed — using CPU (safe). This is expected on VMs, containers, CI, and software-Vulkan hosts.',
253
+ );
254
+ return viable ? 'webgpu' : 'cpu';
255
+ }
@@ -0,0 +1,252 @@
1
+ import { normalizeUrl, validateUrl } from '../utils/url-utils.ts';
2
+ import { isEmbedderUnreachable } from './embedder-utils.ts';
3
+ import { logger } from '../logger.ts';
4
+ import { metrics } from '../utils/metrics.ts';
5
+ import type { Chunker } from './chunker.ts';
6
+ import { createHash } from 'node:crypto';
7
+ import { ServiceLifecycle } from '../core/service-registry.ts';
8
+ import { ServiceNames } from '../core/service-interfaces.ts';
9
+ import type {
10
+ IWriterQueue,
11
+ IngestionItem,
12
+ StoreDocument
13
+ } from '../core/interfaces/knowledge-interfaces.ts';
14
+
15
+ interface WriterQueueOptions {
16
+ store: {
17
+ isStoreClosed?: () => boolean;
18
+ findByUrl: (url: string) => Promise<StoreDocument[]>;
19
+ deleteByUrlAndType: (url: string, type: string) => Promise<void>;
20
+ addDocuments: (docs: StoreDocument[]) => Promise<void>;
21
+ };
22
+ chunker: Chunker | null;
23
+ }
24
+
25
+ /** Bounded window for draining the writer queue during dispose/shutdown. Kept well
26
+ * under the global SHUTDOWN_TIMEOUT_MS (8s) so LanceDB close and the browser orphan
27
+ * sweep still get their share of the teardown budget. */
28
+ const DRAIN_DISPOSE_TIMEOUT_MS = 5_000;
29
+
30
+ function isNoSpace(err: unknown): boolean {
31
+ const code = (err as NodeJS.ErrnoException)?.code;
32
+ const msg = err instanceof Error ? err.message : String(err);
33
+ return code === 'ENOSPC' || msg.includes('ENOSPC') || msg.toLowerCase().includes('no space left');
34
+ }
35
+
36
+ export class WriterQueue implements IWriterQueue {
37
+ readonly name = ServiceNames.WRITER_QUEUE;
38
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
39
+
40
+ private queue: IngestionItem[] = [];
41
+ private processing = false;
42
+ private options: WriterQueueOptions;
43
+ private drainResolvers: (() => void)[] = [];
44
+ // FIX (#2): Per-URL lock map to prevent TOCTOU races when concurrent writers
45
+ // ingest the same URL. Key is the normalized URL; value is the in-flight promise.
46
+ private inflightByUrl = new Map<string, Promise<void>>();
47
+
48
+ constructor(options: WriterQueueOptions) {
49
+ this.options = options;
50
+ }
51
+
52
+ async initialize(): Promise<void> {
53
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
54
+ }
55
+
56
+ async dispose(): Promise<void> {
57
+ // Bound the drain so a large burst enqueued right before shutdown (or a hung
58
+ // embed) can't consume the whole global SHUTDOWN_TIMEOUT_MS budget — which is
59
+ // shared with LanceDB close and the browser orphan sweep. On timeout we proceed
60
+ // and account for the un-persisted items rather than hanging teardown.
61
+ await this.drain(DRAIN_DISPOSE_TIMEOUT_MS);
62
+ this.lifecycle = ServiceLifecycle.DISPOSED;
63
+ }
64
+
65
+ enqueue(item: IngestionItem): void {
66
+ this.queue.push(item);
67
+ this.process().catch(err => {
68
+ logger.error('[writer-queue] Error in process():', err);
69
+ });
70
+ }
71
+
72
+ private async process(): Promise<void> {
73
+ if (this.processing) return;
74
+ this.processing = true;
75
+
76
+ while (this.queue.length > 0) {
77
+ const item = this.queue.shift()!;
78
+ try {
79
+ // FIX (#2): Chain per-URL to serialize concurrent ingests of the same URL.
80
+ // process() already runs sequentially, so we chain only for the race case
81
+ // where two enqueue() calls trigger concurrent process() calls.
82
+ const urlKey = normalizeUrl(item.url);
83
+ const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
84
+ const inflight = prev.then(() => this._ingestInner(item));
85
+ this.inflightByUrl.set(urlKey, inflight);
86
+ try {
87
+ await inflight;
88
+ } finally {
89
+ if (this.inflightByUrl.get(urlKey) === inflight) {
90
+ this.inflightByUrl.delete(urlKey);
91
+ }
92
+ }
93
+ } catch (err) {
94
+ if (isEmbedderUnreachable(err)) {
95
+ logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
96
+ await new Promise(r => setTimeout(r, 2000));
97
+ try {
98
+ await this.ingest(item);
99
+ } catch (retryErr) {
100
+ logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
101
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'embedder_unreachable' });
102
+ }
103
+ } else if (isNoSpace(err)) {
104
+ logger.error(`[writer-queue] ENOSPC: disk full — dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
105
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'enospc' });
106
+ } else {
107
+ logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
108
+ metrics.increment('knowledge_ingest_dropped_total', 1, { reason: 'ingest_error' });
109
+ }
110
+ }
111
+ }
112
+
113
+ this.processing = false;
114
+
115
+ // Notify all drain callers
116
+ const resolvers = [...this.drainResolvers];
117
+ this.drainResolvers = [];
118
+ for (const resolve of resolvers) {
119
+ resolve();
120
+ }
121
+ }
122
+
123
+ private async ingest(item: IngestionItem): Promise<void> {
124
+ const urlKey = normalizeUrl(item.url);
125
+ const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
126
+ const inflight = prev.then(() => this._ingestInner(item));
127
+ this.inflightByUrl.set(urlKey, inflight);
128
+ try {
129
+ await inflight;
130
+ } finally {
131
+ if (this.inflightByUrl.get(urlKey) === inflight) {
132
+ this.inflightByUrl.delete(urlKey);
133
+ }
134
+ }
135
+ }
136
+
137
+ private async _ingestInner(item: IngestionItem): Promise<void> {
138
+ const incomingType = (item.metadata?.['ingestionType'] as string | undefined) ?? 'synthesis-description';
139
+
140
+ if (!item.markdown) {
141
+ logger.warn(`[writer-queue] Skipping ingest for ${item.url} — markdown is empty`);
142
+ return;
143
+ }
144
+
145
+ // FIX (Issue 3/4): Validate URL format to prevent storing hallucinated/malformed URLs
146
+ if (!validateUrl(item.url)) {
147
+ logger.warn(`[writer-queue] Skipping ingest — URL failed validation: ${item.url}`);
148
+ return;
149
+ }
150
+
151
+ // Normalize URL for consistent deduplication and storage.
152
+ // The original URL is preserved in metadata for reference, but all
153
+ // dedup checks and store operations use the normalized form.
154
+ const normalizedUrl = normalizeUrl(item.url);
155
+
156
+ const hash = createHash('sha256').update(item.markdown).update(item.content ?? '').digest('hex');
157
+
158
+ if (this.options.store.isStoreClosed?.()) {
159
+ logger.warn(`[writer-queue] Skipping ingest for ${normalizedUrl} — store is closing`);
160
+ return;
161
+ }
162
+
163
+ // FIX (Issue 5): Add provenance metadata to distinguish verified vs unverified entries
164
+ const hasContent = !!item.content;
165
+ const provenanceCategory = hasContent ? 'scraped-verified' : 'description-unverified';
166
+ const provenanceMeta = {
167
+ provenance: provenanceCategory,
168
+ hasContent,
169
+ validatedAt: Date.now(),
170
+ };
171
+
172
+ // Dedup: check if we already have this exact URL+type with identical content.
173
+ // findByUrl applies scope filtering, so this only dedupes within the
174
+ // current project's visible scope (local workspace + global entries).
175
+ const existing = await this.options.store.findByUrl(normalizedUrl);
176
+ const sameType = existing.filter(c => c.metadata['ingestionType'] === incomingType);
177
+ if (sameType.length > 0 && sameType[0]!.metadata['contentHash'] === hash) {
178
+ logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) — content unchanged.`);
179
+ return;
180
+ }
181
+
182
+ if (sameType.length > 0) {
183
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType);
184
+ }
185
+
186
+ const rawChunks = this.options.chunker
187
+ ? this.options.chunker.chunk(item.markdown)
188
+ : [{ text: item.markdown, actual_overlap: 0 }];
189
+
190
+ if (rawChunks.length === 0) return;
191
+
192
+ const docs: StoreDocument[] = rawChunks.map((chunk, i) => ({
193
+ url: normalizedUrl,
194
+ text: chunk.text,
195
+ content: i === 0 ? (item.content ?? undefined) : undefined,
196
+ metadata: {
197
+ ...(item.metadata || {}),
198
+ // Preserve the original (pre-normalization) URL for debugging/display
199
+ originalUrl: item.url !== normalizedUrl ? item.url : undefined,
200
+ contentHash: hash,
201
+ ingestionType: incomingType,
202
+ ...provenanceMeta,
203
+ chunkIndex: i,
204
+ totalChunks: rawChunks.length,
205
+ },
206
+ timestamp: Date.now(),
207
+ }));
208
+
209
+ await this.options.store.addDocuments(docs);
210
+ }
211
+
212
+ async drain(timeoutMs?: number): Promise<void> {
213
+ // FIX (#7): Atomically check state AND register the resolver inside the
214
+ // Promise constructor to prevent the race where process() finishes between
215
+ // the check and new Promise().
216
+ return new Promise<void>((resolve) => {
217
+ if (!this.processing && this.queue.length === 0) {
218
+ resolve();
219
+ return;
220
+ }
221
+ // Optional bounded drain: if the queue doesn't empty within timeoutMs, resolve
222
+ // anyway so a caller (shutdown) isn't held hostage by a slow/hung embed. Items
223
+ // still queued at the deadline are counted as dropped (best-effort — a few may
224
+ // still land as process() finishes, so the metric is a conservative upper bound).
225
+ // These are re-scrapable cache entries, not primary user data.
226
+ let settled = false;
227
+ let timer: ReturnType<typeof setTimeout> | undefined;
228
+ const done = () => {
229
+ if (settled) return;
230
+ settled = true;
231
+ if (timer) clearTimeout(timer);
232
+ resolve();
233
+ };
234
+ this.drainResolvers.push(done);
235
+ if (timeoutMs && timeoutMs > 0) {
236
+ timer = setTimeout(() => {
237
+ const remaining = this.queue.length + (this.processing ? 1 : 0);
238
+ logger.warn(`[writer-queue] drain timed out after ${timeoutMs}ms with ~${remaining} item(s) not yet persisted; proceeding with shutdown.`);
239
+ if (remaining > 0) {
240
+ metrics.increment('knowledge_ingest_dropped_total', remaining, { reason: 'drain_timeout' });
241
+ }
242
+ // Detach our resolver so a later process() completion doesn't call it again
243
+ // (harmless via the settled guard, but keeps drainResolvers from leaking).
244
+ const idx = this.drainResolvers.indexOf(done);
245
+ if (idx !== -1) this.drainResolvers.splice(idx, 1);
246
+ done();
247
+ }, timeoutMs);
248
+ timer.unref?.();
249
+ }
250
+ });
251
+ }
252
+ }