@lincoln504/pi-research 0.1.12 → 1.0.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -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 +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -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 +1044 -0
  30. package/src/config.ts +1036 -147
  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 +264 -7
  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 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  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,157 @@
1
+ export interface Chunk {
2
+ text: string;
3
+ actual_overlap: number;
4
+ }
5
+
6
+ export interface ChunkerOptions {
7
+ targetSize: number;
8
+ overlap: number;
9
+ }
10
+
11
+ import { metrics } from '../utils/metrics.ts';
12
+
13
+ export class Chunker {
14
+ private targetSize: number;
15
+ private overlap: number;
16
+
17
+ constructor(options: ChunkerOptions) {
18
+ if (options.overlap >= options.targetSize) {
19
+ throw new Error(
20
+ `Chunker: overlap (${options.overlap}) must be less than targetSize (${options.targetSize}). ` +
21
+ `This prevents infinite loops during chunking.`
22
+ );
23
+ }
24
+ this.targetSize = options.targetSize;
25
+ this.overlap = options.overlap;
26
+ }
27
+
28
+ chunk(text: string): Chunk[] {
29
+ if (!text) {
30
+ metrics.increment('chunker_operations_total', 1, { status: 'empty' });
31
+ return [];
32
+ }
33
+
34
+ const startTime = Date.now();
35
+ const chunks: Chunk[] = [];
36
+ let start = 0;
37
+ let prevEnd = 0;
38
+ let codeBlockExtensions = 0;
39
+ let headingExtensions = 0;
40
+ let sentenceExtensions = 0;
41
+ let newlineExtensions = 0;
42
+ let spaceExtensions = 0;
43
+
44
+ while (start < text.length) {
45
+ let end = start + this.targetSize;
46
+
47
+ if (end < text.length) {
48
+ let slice = text.slice(start, end);
49
+ let extendedForCodeBlock = false;
50
+
51
+ // 0. Code blocks atomicity
52
+ const textBefore = text.slice(0, start);
53
+ const codeBlockMatchesBefore = textBefore.match(/```/g);
54
+ const startsInCodeBlock = codeBlockMatchesBefore && codeBlockMatchesBefore.length % 2 !== 0;
55
+
56
+ // Hard cap: never extend a chunk beyond 4x targetSize (min 2000 chars) for code blocks.
57
+ // A very large code block (e.g. a package compatibility table) can otherwise
58
+ // produce chunks of 12000+ tokens that OOM the embedding model at inference time.
59
+ const MAX_CHUNK_CHARS = Math.max(this.targetSize * 4, 2000);
60
+
61
+ if (startsInCodeBlock) {
62
+ const nextEnd = text.indexOf('```', start);
63
+ if (nextEnd !== -1 && nextEnd + 3 - start <= MAX_CHUNK_CHARS) {
64
+ end = nextEnd + 3;
65
+ slice = text.slice(start, end);
66
+ extendedForCodeBlock = true;
67
+ codeBlockExtensions++;
68
+ }
69
+ } else {
70
+ const codeBlockMatchesInSlice = slice.match(/```/g);
71
+ if (codeBlockMatchesInSlice && codeBlockMatchesInSlice.length % 2 !== 0) {
72
+ const nextEnd = text.indexOf('```', start + slice.lastIndexOf('```') + 3);
73
+ if (nextEnd !== -1 && nextEnd + 3 - start <= MAX_CHUNK_CHARS) {
74
+ end = nextEnd + 3;
75
+ slice = text.slice(start, end);
76
+ extendedForCodeBlock = true;
77
+ codeBlockExtensions++;
78
+ }
79
+ }
80
+ }
81
+
82
+ if (end < text.length && !extendedForCodeBlock) {
83
+ const lastHeading = slice.lastIndexOf('\n#');
84
+ if (lastHeading !== -1 && lastHeading > this.targetSize * 0.4) {
85
+ end = start + lastHeading + 1;
86
+ headingExtensions++;
87
+ } else {
88
+ const sentenceMatches = [...slice.matchAll(/[.!?](?=\s|\n)/g)];
89
+ let lastSentencePos = -1;
90
+ if (sentenceMatches.length > 0) {
91
+ const lastMatch = sentenceMatches[sentenceMatches.length - 1];
92
+ if (lastMatch && lastMatch.index !== undefined) {
93
+ lastSentencePos = lastMatch.index + 1;
94
+ }
95
+ }
96
+
97
+ if (lastSentencePos !== -1 && lastSentencePos > this.targetSize * 0.6) {
98
+ end = start + lastSentencePos;
99
+ sentenceExtensions++;
100
+ } else {
101
+ const lastNL = slice.lastIndexOf('\n');
102
+ if (lastNL !== -1 && lastNL > this.targetSize * 0.7) {
103
+ end = start + lastNL + 1;
104
+ newlineExtensions++;
105
+ } else {
106
+ const lastSpace = slice.lastIndexOf(' ');
107
+ if (lastSpace !== -1 && lastSpace > this.targetSize * 0.8) {
108
+ end = start + lastSpace + 1;
109
+ spaceExtensions++;
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ } else {
116
+ end = text.length;
117
+ }
118
+
119
+ if (end <= start) end = Math.min(start + this.targetSize, text.length);
120
+
121
+ const chunkText = text.slice(start, end);
122
+ const actual_overlap = chunks.length === 0 ? 0 : Math.max(0, Math.min(chunkText.length, prevEnd - start));
123
+
124
+ chunks.push({ text: chunkText, actual_overlap });
125
+
126
+ if (end >= text.length) break;
127
+
128
+ prevEnd = Math.max(prevEnd, end);
129
+ start = end - this.overlap;
130
+ if (start <= (end - chunkText.length)) {
131
+ start = end - Math.min(this.overlap, chunkText.length - 1);
132
+ }
133
+ if (start < 0) start = 0;
134
+ if (start >= end) start = end - 1;
135
+ }
136
+
137
+ const duration = Date.now() - startTime;
138
+ metrics.observe('chunker_duration_ms', duration);
139
+ metrics.increment('chunker_operations_total', 1, { status: 'success' });
140
+ metrics.increment('chunker_chunks_generated_total', chunks.length);
141
+ metrics.increment('chunker_extensions_total', codeBlockExtensions, { type: 'code_block' });
142
+ metrics.increment('chunker_extensions_total', headingExtensions, { type: 'heading' });
143
+ metrics.increment('chunker_extensions_total', sentenceExtensions, { type: 'sentence' });
144
+ metrics.increment('chunker_extensions_total', newlineExtensions, { type: 'newline' });
145
+ metrics.increment('chunker_extensions_total', spaceExtensions, { type: 'space' });
146
+
147
+ // Track chunk size distribution
148
+ for (const chunk of chunks) {
149
+ metrics.observe('chunker_chunk_size_chars', chunk.text.length);
150
+ if (chunk.actual_overlap > 0) {
151
+ metrics.observe('chunker_overlap_chars', chunk.actual_overlap);
152
+ }
153
+ }
154
+
155
+ return chunks;
156
+ }
157
+ }
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Embedder Initialization Logic
3
+ *
4
+ * Handles initialization and warmup logic for the embedder
5
+ */
6
+
7
+ import type { FeatureExtractionPipeline } from '@huggingface/transformers';
8
+ import { logger } from '../logger.ts';
9
+ import type { IStateManager } from '../core/service-interfaces.ts';
10
+ import { DisposablePipeline } from '../core/interfaces/knowledge-interfaces.ts';
11
+ import { withTimeout, markWebGpuFallback } from './embedder-utils.ts';
12
+ import { getHFEnv } from './onnx-env.ts';
13
+ import { getTransformers } from './transformers-loader.ts';
14
+
15
+ /**
16
+ * Load pipeline with timeout
17
+ */
18
+ export async function loadPipelineWithTimeout(
19
+ model: string,
20
+ device: string,
21
+ timeoutMs: number,
22
+ useCache: boolean
23
+ ): Promise<{ pipeline: FeatureExtractionPipeline; errorMessage: string }> {
24
+ const errorMessage = `Model load timed out after ${timeoutMs}ms. Check network connection or try a smaller model.`;
25
+
26
+ // pipeline() must be created INSIDE runCapturingStderr so that the FD-level
27
+ // stderr redirect is in place before the native ONNX/Dawn C++ code runs.
28
+ // Creating the Promise outside means the background thread could write its
29
+ // Dawn limit-clamping warnings before captureStdio redirects FD 2.
30
+ const loadedPipeline = await logger.runCapturingStderr(async () => {
31
+ const { pipeline } = await getTransformers();
32
+ const pipelinePromise = pipeline('feature-extraction', model, {
33
+ device: device as 'webgpu' | 'cpu' | 'auto' | 'gpu' | 'wasm' | 'webnn' | 'webnn-npu' | 'webnn-gpu' | 'webnn-cpu',
34
+ ...(useCache === false ? { use_cache: false } : {}),
35
+ // Clamp ONNX intra-op thread pool to 2 threads per session.
36
+ // Default (0) = one thread per physical CPU core. With multiple concurrent
37
+ // processes each loading their own pipeline, the default spawns N_cores * N_procs
38
+ // threads that all busy-spin simultaneously, saturating the CPU.
39
+ // 2 threads gives adequate within-op parallelism without thrashing.
40
+ session_options: {
41
+ intraOpNumThreads: 2,
42
+ interOpNumThreads: 1,
43
+ },
44
+ });
45
+ return await withTimeout(pipelinePromise, timeoutMs, errorMessage);
46
+ });
47
+
48
+ return { pipeline: loadedPipeline, errorMessage };
49
+ }
50
+
51
+ /**
52
+ * Warmup the pipeline
53
+ */
54
+ export async function warmupPipeline(
55
+ pipeline: FeatureExtractionPipeline,
56
+ poolingMode: 'mean' | 'cls' | 'last_token',
57
+ useCache: boolean
58
+ ): Promise<{ dummy: any; success: boolean; error?: Error }> {
59
+ try {
60
+ const dummy = await withTimeout(
61
+ pipeline('warmup', {
62
+ pooling: poolingMode as any,
63
+ normalize: false,
64
+ ...(useCache === false ? { use_cache: false } : {}),
65
+ }),
66
+ 20_000,
67
+ 'Model warmup timed out after 20000ms.'
68
+ );
69
+ return { dummy, success: true };
70
+ } catch (warmupErr) {
71
+ return { dummy: null, success: false, error: warmupErr as Error };
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Check if error is a WebGPU device error
77
+ */
78
+ export function isWebGpuDeviceError(err: unknown): boolean {
79
+ if (!err) return false;
80
+
81
+ // Type-safe string extraction from error objects
82
+ let msg: string;
83
+ let stack: string;
84
+
85
+ if (err instanceof Error) {
86
+ msg = err.message ?? '';
87
+ stack = err.stack ?? '';
88
+ } else if (typeof err === 'object') {
89
+ msg = String((err as Record<string, unknown>)['message'] ?? '');
90
+ stack = String((err as Record<string, unknown>)['stack'] ?? '');
91
+ if (!msg && !stack) {
92
+ try { msg = JSON.stringify(err); } catch { msg = String(err); }
93
+ }
94
+ } else {
95
+ msg = String(err);
96
+ stack = '';
97
+ }
98
+
99
+ const combined = (msg + ' ' + stack).toLowerCase();
100
+
101
+ return (
102
+ combined.includes('webgpu') ||
103
+ combined.includes('out_of_device_memory') ||
104
+ combined.includes('out of memory') ||
105
+ combined.includes('vk_error_out_of_device_memory') ||
106
+ combined.includes('vkallocatememory') ||
107
+ combined.includes('device lost') ||
108
+ combined.includes('devicelost') ||
109
+ combined.includes('validation failed') ||
110
+ combined.includes('invalid buffer') ||
111
+ combined.includes('bindgroup') ||
112
+ combined.includes('minbindingsize') ||
113
+ combined.includes('past_key_values') ||
114
+ combined.includes('unlabeled') ||
115
+ combined.includes('bufferbindingtype') ||
116
+ combined.includes('createdevice') ||
117
+ combined.includes('createbindgroup') ||
118
+ combined.includes('out-of-memory')
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Check if a model is FULLY cached on disk.
124
+ *
125
+ * Verifying only that `model.onnx` exists is not enough on two axes:
126
+ *
127
+ * 1. WEIGHTS: large models keep their weights in a sibling external-data file
128
+ * (`model.onnx_data`). An interrupted download can leave the small graph file
129
+ * (`model.onnx`) complete while the weights file is missing, zero-length, or a
130
+ * leftover partial-download artifact — which passes a presence-only check and then
131
+ * crashes the ONNX loader ("Deserialize tensor … file_length … out of bounds").
132
+ * 2. ROOT METADATA: transformers.js also needs `config.json` and a tokenizer at the
133
+ * MODEL ROOT (not in `onnx/`). A truncated/garbage `config.json` (external disk
134
+ * error, partial delete) otherwise passes a weights-only check and then fails to
135
+ * parse with an error isCorruptModelError does NOT match — a permanent, un-self-
136
+ * healing init failure on every startup. Requiring them here re-fetches instead.
137
+ *
138
+ * Returning false routes the caller back through a fresh download, which heals the gap.
139
+ * A leftover `.tmp` artifact alongside COMPLETE weights is just trash from a prior
140
+ * interrupted attempt that later succeeded — it is swept (best-effort) and does not
141
+ * defeat the cached fast-path (which would otherwise re-download every startup).
142
+ */
143
+ export async function isModelCached(model: string): Promise<boolean> {
144
+ try {
145
+ const env = await getHFEnv();
146
+ const cacheDir = env.cacheDir;
147
+ if (!cacheDir) return false;
148
+ const { access, readdir, readFile, stat, rm } = await import('node:fs/promises');
149
+ const path = await import('node:path');
150
+ const modelDir = path.default.join(cacheDir, model);
151
+ const onnxDir = path.default.join(modelDir, 'onnx');
152
+
153
+ // Graph file must exist.
154
+ await access(path.default.join(onnxDir, 'model.onnx'));
155
+
156
+ // Root config.json must exist AND parse — catches a missing/empty/truncated/garbage
157
+ // config (the un-self-healing corruption case). It is tiny, so the parse is cheap.
158
+ try {
159
+ const cfgRaw = await readFile(path.default.join(modelDir, 'config.json'), 'utf-8');
160
+ JSON.parse(cfgRaw);
161
+ } catch {
162
+ return false;
163
+ }
164
+
165
+ // A tokenizer must be present and non-empty. Accept any recognized form so this never
166
+ // false-negatives across the supported models (fast tokenizer.json) or a custom model
167
+ // (sentencepiece tokenizer.model / WordPiece vocab.txt).
168
+ const tokenizerFiles = ['tokenizer.json', 'tokenizer.model', 'vocab.txt'];
169
+ let hasTokenizer = false;
170
+ for (const f of tokenizerFiles) {
171
+ const st = await stat(path.default.join(modelDir, f)).catch(() => null);
172
+ if (st && st.size > 0) { hasTokenizer = true; break; }
173
+ }
174
+ if (!hasTokenizer) return false;
175
+
176
+ const entries = await readdir(onnxDir).catch(() => [] as string[]);
177
+
178
+ // If an external weights file is listed, it must be non-empty — an interrupted
179
+ // download can leave a zero-length model.onnx_data that crashes the ONNX loader.
180
+ const hasExternalWeights = entries.includes('model.onnx_data');
181
+ if (hasExternalWeights) {
182
+ const dataStat = await stat(path.default.join(onnxDir, 'model.onnx_data')).catch(() => null);
183
+ if (!dataStat || dataStat.size === 0) return false;
184
+ }
185
+
186
+ const partialArtifacts = entries.filter(
187
+ (f) => /\.(incomplete|downloading|part|tmp)$/i.test(f) || f.includes('.tmp.')
188
+ );
189
+ if (partialArtifacts.length > 0) {
190
+ // A leftover partial-download artifact. Treat it as trash to sweep ONLY when the
191
+ // real weights are confirmed present and complete (model.onnx_data non-empty,
192
+ // checked above) — a prior interrupted attempt that later succeeded. With no
193
+ // confirmed external-weights file the partial IS an in-flight/interrupted weights
194
+ // download, so the cache is genuinely incomplete → re-download.
195
+ if (hasExternalWeights) {
196
+ for (const f of partialArtifacts) {
197
+ await rm(path.default.join(onnxDir, f), { force: true }).catch(() => {});
198
+ }
199
+ } else {
200
+ return false;
201
+ }
202
+ }
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Whether a model-load failure indicates a corrupt/truncated on-disk cache (as opposed to a
211
+ * network, timeout, OOM, opset-mismatch, or GPU error). These are unrecoverable by retrying the
212
+ * same files — the only fix is to purge the cache and re-download.
213
+ *
214
+ * Match ONLY corruption-specific signals. ONNX Runtime wraps essentially every session-creation
215
+ * failure in two GENERIC strings — `Load model from <uri> failed: <ex>` and `Failed to load
216
+ * model with error: <ex>` — and also emits broad `out of bounds` / `out_of_range` for non-cache
217
+ * conditions (CPU OOM during weight load, unregistered-op/opset drift, shape inference, WebGPU
218
+ * buffer-offset checks). Matching those would purge + re-download a perfectly good multi-hundred-MB
219
+ * cache on a transient error, and PERMANENTLY destroy it if the host is offline. The genuine
220
+ * truncated-weights error ("Deserialize tensor … file_length … out of bounds") is still caught
221
+ * here via 'deserialize'/'file_length', so narrowing loses no real corruption coverage.
222
+ */
223
+ export function isCorruptModelError(err: unknown): boolean {
224
+ const msg = (err instanceof Error ? (err.message + ' ' + (err.stack ?? '')) : String(err)).toLowerCase();
225
+ return (
226
+ msg.includes('deserialize') ||
227
+ msg.includes('file_length') ||
228
+ msg.includes('protobuf parsing failed') ||
229
+ msg.includes('invalid protobuf')
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Delete a model's on-disk cache directory so the next load re-downloads it from scratch.
235
+ * Best-effort: a deletion failure is logged and swallowed (the subsequent re-download attempt
236
+ * will surface any real, persistent problem).
237
+ */
238
+ export async function purgeModelCache(model: string): Promise<void> {
239
+ try {
240
+ const env = await getHFEnv();
241
+ const cacheDir = env.cacheDir;
242
+ if (!cacheDir) return;
243
+ const { rm } = await import('node:fs/promises');
244
+ const path = await import('node:path');
245
+ // Containment guard: the consequence here is a recursive delete, so never let an empty,
246
+ // absolute, or traversal model id collapse `modelDir` to the cache root (which would wipe
247
+ // every model) or escape it. `model` is infrastructure config (EMBEDDING_MODEL), not
248
+ // attacker-derived, but validate in depth regardless.
249
+ const resolvedCache = path.default.resolve(cacheDir);
250
+ const modelDir = path.default.resolve(resolvedCache, model);
251
+ if (!model || path.default.isAbsolute(model) || modelDir === resolvedCache || !modelDir.startsWith(resolvedCache + path.default.sep)) {
252
+ logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
253
+ return;
254
+ }
255
+ await rm(modelDir, { recursive: true, force: true });
256
+ logger.warn(`[embedder] Purged corrupt model cache at ${modelDir}`);
257
+ } catch (err) {
258
+ logger.warn('[embedder] Failed to purge model cache:', err);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Load model on CPU (fallback from WebGPU)
264
+ */
265
+ export async function loadModelOnCPU(
266
+ model: string,
267
+ initializationTimeoutMs: number,
268
+ useCache: boolean
269
+ ): Promise<FeatureExtractionPipeline> {
270
+ logger.info(`[embedder] Loading model on CPU after WebGPU error...`);
271
+
272
+ const loadedPipeline = await logger.runCapturingStderr(async () => {
273
+ const { pipeline } = await getTransformers();
274
+ const pipelinePromise = pipeline('feature-extraction', model, {
275
+ device: 'cpu',
276
+ ...(useCache === false ? { use_cache: false } : {}),
277
+ session_options: {
278
+ intraOpNumThreads: 2,
279
+ interOpNumThreads: 1,
280
+ },
281
+ });
282
+ return await withTimeout(pipelinePromise, initializationTimeoutMs, 'CPU fallback model load timed out');
283
+ });
284
+
285
+ return loadedPipeline;
286
+ }
287
+
288
+ /**
289
+ * Release GPU lock if held
290
+ */
291
+ export async function releaseGpuLock(stateManager: IStateManager | null, gpuLockHeld: boolean): Promise<void> {
292
+ if (gpuLockHeld && stateManager) {
293
+ await stateManager.releaseGpuLock().catch(err => {
294
+ logger.warn('[embedder] Failed to release GPU lock:', err);
295
+ });
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Acquire GPU lock for initialization
301
+ */
302
+ export async function acquireGpuLock(
303
+ stateManager: IStateManager | null
304
+ ): Promise<{ acquired: boolean; shouldFallback: boolean }> {
305
+ if (!stateManager) {
306
+ return { acquired: false, shouldFallback: false };
307
+ }
308
+
309
+ const gpuLockHeld = await stateManager.acquireGpuLock(undefined, 30_000);
310
+ if (!gpuLockHeld) {
311
+ logger.warn('[embedder] Failed to acquire GPU init lock within 30s — falling back to CPU');
312
+ return { acquired: false, shouldFallback: true };
313
+ }
314
+
315
+ logger.debug('[embedder] Acquired GPU init lock');
316
+ return { acquired: true, shouldFallback: false };
317
+ }
318
+
319
+ /**
320
+ * Handle WebGPU error during load
321
+ */
322
+ export async function handleWebGPULoadError(
323
+ loadErr: unknown,
324
+ pipeline: FeatureExtractionPipeline | null,
325
+ stateManager: IStateManager | null,
326
+ gpuLockHeld: boolean,
327
+ model: string,
328
+ initializationTimeoutMs: number,
329
+ useCache: boolean
330
+ ): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
331
+ const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr);
332
+ const isValidationError = errorMsg.toLowerCase().includes('validation');
333
+
334
+ markWebGpuFallback();
335
+
336
+ if (isValidationError) {
337
+ logger.warn('[embedder] WebGPU validation error during pipeline loading — falling back to CPU');
338
+ logger.debug('[embedder] Validation error details:', errorMsg);
339
+ } else {
340
+ logger.warn('[embedder] WebGPU OOM during pipeline loading — falling back to CPU');
341
+ }
342
+
343
+ // Clean up
344
+ if (pipeline) {
345
+ try { if (typeof (pipeline as any).dispose === 'function') await (pipeline as DisposablePipeline).dispose(); } catch (err) { logger.warn('[embedder] Error disposing pipeline:', err); }
346
+ }
347
+ await releaseGpuLock(stateManager, gpuLockHeld);
348
+
349
+ // Load on CPU instead
350
+ try {
351
+ const cpuPipeline = await loadModelOnCPU(model, initializationTimeoutMs, useCache);
352
+ logger.info(`[embedder] Pipeline loaded (device: cpu)`);
353
+ return { success: true, pipeline: cpuPipeline };
354
+ } catch (cpuLoadErr) {
355
+ logger.error('[embedder] CPU fallback pipeline load failed:', cpuLoadErr);
356
+ const error = new Error(`WebGPU initialization failed and CPU fallback load also failed: ${cpuLoadErr instanceof Error ? cpuLoadErr.message : String(cpuLoadErr)}`, { cause: cpuLoadErr });
357
+ return { success: false, error };
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Handle WebGPU error during warmup
363
+ */
364
+ export async function handleWebGPUWarmupError(
365
+ warmupErr: Error,
366
+ pipeline: FeatureExtractionPipeline | null,
367
+ stateManager: IStateManager | null,
368
+ gpuLockHeld: boolean,
369
+ model: string,
370
+ initializationTimeoutMs: number,
371
+ useCache: boolean
372
+ ): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
373
+ const isValidationError = warmupErr.message.toLowerCase().includes('validation');
374
+
375
+ markWebGpuFallback();
376
+
377
+ if (isValidationError) {
378
+ logger.warn('[embedder] WebGPU validation error during warmup — falling back to CPU');
379
+ logger.debug('[embedder] Validation error details:', warmupErr.message);
380
+ } else {
381
+ logger.warn('[embedder] WebGPU OOM during warmup — falling back to CPU');
382
+ }
383
+
384
+ // Clean up
385
+ if (pipeline) {
386
+ try { if (typeof (pipeline as any).dispose === 'function') await (pipeline as DisposablePipeline).dispose(); } catch (err) { logger.warn('[embedder] Error disposing pipeline:', err); }
387
+ }
388
+ await releaseGpuLock(stateManager, gpuLockHeld);
389
+
390
+ // Load on CPU
391
+ try {
392
+ const cpuPipeline = await loadModelOnCPU(model, initializationTimeoutMs, useCache);
393
+ logger.info('[embedder] CPU pipeline loaded successfully');
394
+
395
+ // Warmup CPU pipeline
396
+ const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, 'mean', useCache);
397
+ if (!success || cpuWarmupErr) {
398
+ logger.error('[embedder] CPU fallback pipeline warmup failed:', cpuWarmupErr);
399
+ if (cpuPipeline) {
400
+ try { if (typeof (cpuPipeline as any).dispose === 'function') await (cpuPipeline as DisposablePipeline).dispose(); } catch (err) { logger.warn('[embedder] Error disposing fallback CPU pipeline:', err); }
401
+ }
402
+ const error = new Error(`WebGPU initialization failed and CPU fallback warmup also failed: ${cpuWarmupErr?.message || 'Unknown error'}`, { cause: cpuWarmupErr });
403
+ return { success: false, error };
404
+ }
405
+
406
+ logger.info(`[embedder] CPU pipeline warmup successful. Ready with device: cpu`);
407
+ return { success: true, pipeline: cpuPipeline, dummy };
408
+ } catch (cpuLoadErr) {
409
+ logger.error('[embedder] CPU pipeline load failed:', cpuLoadErr);
410
+ const error = new Error(`WebGPU initialization failed and CPU fallback load also failed: ${cpuLoadErr instanceof Error ? cpuLoadErr.message : String(cpuLoadErr)}`, { cause: cpuLoadErr });
411
+ return { success: false, error };
412
+ }
413
+ }