@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,535 @@
1
+ /**
2
+ * Embedding Server
3
+ *
4
+ * Wraps a single Embedder instance behind an HTTP server so all other
5
+ * processes can share one GPU context. Mirrors BrowserTaskScheduler.
6
+ */
7
+
8
+ import * as http from 'node:http';
9
+ import * as path from 'node:path';
10
+ import { logger } from '../../logger.ts';
11
+ import { captureStdio } from '../../utils/stdio-capture.ts';
12
+ import { buildDefaultDebugLogPath } from '../../utils/log-utils.ts';
13
+ import { DiskSpaceChecker } from '../../utils/disk-space-checker.ts';
14
+ import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
15
+ import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
16
+ import type { Embedder } from '../../knowledge/embedder.ts';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // SerialQueue — ensures embed/embedMany never run concurrently on the GPU
20
+ // ---------------------------------------------------------------------------
21
+
22
+ class SerialQueue {
23
+ private running = false;
24
+ private readonly tasks: Array<{ run: () => Promise<void>; fail: (e: Error) => void }> = [];
25
+ private readonly maxDepth: number;
26
+ private readonly timeoutMs: number;
27
+ private readonly hardDeadlineMs: number;
28
+ private readonly onPoison?: (err: Error) => void;
29
+ private poisoned: Error | null = null;
30
+
31
+ constructor(
32
+ maxDepth = 200,
33
+ timeoutMs = 120000,
34
+ // A single embed/embedMany that has still not settled after this long is
35
+ // genuinely hung (e.g. a WebGPU device-lost stall), not merely slow. Kept well
36
+ // above the soft caller timeout so a legitimately slow CPU batch is never
37
+ // false-poisoned. On expiry the queue is POISONED rather than the slot freed:
38
+ // freeing the slot would start a second inference concurrently with the stuck
39
+ // one on the re-entrant-locked GPU context (the native-segfault class this
40
+ // queue exists to prevent), and the native call cannot be cancelled in-process.
41
+ hardDeadlineMs = 600000,
42
+ onPoison?: (err: Error) => void,
43
+ ) {
44
+ this.maxDepth = maxDepth;
45
+ this.timeoutMs = timeoutMs;
46
+ this.hardDeadlineMs = hardDeadlineMs;
47
+ this.onPoison = onPoison;
48
+ }
49
+
50
+ isPoisoned(): boolean {
51
+ return this.poisoned !== null;
52
+ }
53
+
54
+ enqueue<T>(fn: () => Promise<T>): Promise<T> {
55
+ // Once poisoned (a prior inference hung permanently) the queue can never run
56
+ // another task without risking concurrent GPU inference on the stuck context,
57
+ // so fail every caller fast instead of letting them hang indefinitely.
58
+ if (this.poisoned) return Promise.reject(this.poisoned);
59
+ if (this.tasks.length >= this.maxDepth) {
60
+ return Promise.reject(new Error(`SerialQueue at capacity (${this.maxDepth}). Embed request dropped.`));
61
+ }
62
+ return new Promise<T>((resolve, reject) => {
63
+ const run = async () => {
64
+ let timer: ReturnType<typeof setTimeout> | undefined;
65
+ const timeoutPromise = new Promise<never>((_, rej) => {
66
+ timer = setTimeout(() => rej(new Error(`SerialQueue: embed request timed out after ${this.timeoutMs}ms`)), this.timeoutMs);
67
+ });
68
+ // Start the real GPU work once and keep a handle to it. On timeout we
69
+ // report to the caller promptly, but must NOT let pump() advance to the
70
+ // next task while this inference is still executing — Promise.race does
71
+ // not cancel the loser, and the same-PID GPU lock is re-entrant, so this
72
+ // queue is the only guard against concurrent GPU inference (the class of
73
+ // bug that previously caused native segfaults). fn() is invoked inside the
74
+ // try so a synchronous throw rejects the caller cleanly (run() itself
75
+ // never rejects, so pump()'s `await run()` can't produce an unhandled
76
+ // rejection).
77
+ let work: Promise<T> | undefined;
78
+ let settled = false;
79
+ let hardTimer: ReturnType<typeof setTimeout> | undefined;
80
+ try {
81
+ work = fn();
82
+ // Arm the hard-deadline watchdog from the moment the real GPU work starts, so
83
+ // the cap is an honest wall-clock deadline measured from work-start rather than
84
+ // (soft-timeout + hardDeadlineMs). A single inference still unsettled after
85
+ // hardDeadlineMs is genuinely hung (e.g. a WebGPU device-lost stall); on expiry
86
+ // the queue is POISONED rather than the slot freed — freeing it would start a
87
+ // second inference concurrently with the stuck one on the re-entrant GPU lock.
88
+ hardTimer = setTimeout(() => {
89
+ if (settled) return;
90
+ this.poison(new Error(
91
+ `SerialQueue: embed permanently hung (exceeded hard deadline ${this.hardDeadlineMs}ms) — embedder poisoned; leader stepping down`,
92
+ ));
93
+ }, this.hardDeadlineMs);
94
+ hardTimer.unref?.();
95
+ resolve(await Promise.race([work, timeoutPromise]));
96
+ } catch (e) {
97
+ reject(e);
98
+ } finally {
99
+ if (timer !== undefined) clearTimeout(timer);
100
+ }
101
+ // Hold the queue slot until the actual work settles, even past a reported (soft)
102
+ // timeout, to preserve the GPU-serialization invariant. The hard-deadline watchdog
103
+ // armed above is the backstop: a genuinely hung inference never settles and would
104
+ // otherwise wedge the whole queue (and, via the leader's HTTP endpoint, every other
105
+ // process) forever, so it poisons the queue once the work outlives hardDeadlineMs.
106
+ if (work) {
107
+ try {
108
+ await work.catch(() => {});
109
+ } finally {
110
+ settled = true;
111
+ if (hardTimer !== undefined) clearTimeout(hardTimer);
112
+ }
113
+ }
114
+ };
115
+ this.tasks.push({ run, fail: reject });
116
+ if (!this.running) void this.pump();
117
+ });
118
+ }
119
+
120
+ private poison(err: Error): void {
121
+ if (this.poisoned) return;
122
+ this.poisoned = err;
123
+ logger.error(`[SerialQueue] ${err.message}`);
124
+ // Fast-fail everything already queued behind the hung task — their per-task
125
+ // timeout timers never armed (the timer is created only once a task starts
126
+ // running), so without this they would hang with no timeout at all.
127
+ const queued = this.tasks.splice(0, this.tasks.length);
128
+ for (const entry of queued) {
129
+ try { entry.fail(err); } catch { /* caller already settled */ }
130
+ }
131
+ try { this.onPoison?.(err); } catch (e) { logger.warn('[SerialQueue] onPoison hook failed:', e); }
132
+ }
133
+
134
+ private async pump(): Promise<void> {
135
+ // FIX (#19): Guard against empty array (edge case from concurrent enqueue + pump)
136
+ if (this.running) return;
137
+ this.running = true;
138
+ try {
139
+ while (this.tasks.length > 0) {
140
+ const entry = this.tasks.shift();
141
+ if (entry) {
142
+ await entry.run();
143
+ }
144
+ }
145
+ } finally {
146
+ this.running = false;
147
+ // If tasks were enqueued while we were finishing, pump again
148
+ if (this.tasks.length > 0) {
149
+ void this.pump();
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ // Exported under a _-prefixed alias for focused unit testing of the
156
+ // serialization invariant; the class itself stays internal.
157
+ export { SerialQueue as _SerialQueue };
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // EmbeddingServer
161
+ // ---------------------------------------------------------------------------
162
+
163
+ export class EmbeddingServer implements IEmbedder {
164
+ private server: http.Server | null = null;
165
+ private leadershipTimer: ReturnType<typeof setTimeout> | null = null;
166
+ private consecutiveLeadershipMisses = 0;
167
+ private readonly LEADERSHIP_MISS_THRESHOLD = 3;
168
+ private isShuttingDown = false;
169
+ private readonly queue: SerialQueue;
170
+
171
+ // Dimension field — set by store.ts from table schema before embedder is warmed up,
172
+ // and updated after embedder gets its own dimension at initialization.
173
+ dimension: number | null = null;
174
+
175
+ constructor(
176
+ private readonly embedder: Embedder,
177
+ private readonly stateManager: IStateManager,
178
+ public readonly serverId: string,
179
+ // Invoked when the embedding queue poisons itself after a permanently-hung
180
+ // inference — lets the factory drop this now-leaderless singleton so the next
181
+ // getEmbedder() re-elects a fresh leader in a clean process.
182
+ private readonly externalOnPoison?: (err: Error) => void,
183
+ ) {
184
+ // Wire the queue's poison hook to step this leader down (without disposing the
185
+ // stuck embedder) so a fresh process can re-elect with a clean GPU context.
186
+ this.queue = new SerialQueue(200, 120000, 600000, (err) => this.handleQueuePoisoned(err));
187
+ // Leadership check is started externally (by embedding-factory) after the
188
+ // election is won. Starting it here would cause false misses because model
189
+ // init (inside startServer) can take longer than the 30s check interval.
190
+ }
191
+
192
+ // ---- IEmbedder ----
193
+
194
+ getDevice(): string {
195
+ return this.embedder.isInitialized() ? this.embedder.getDevice() : 'unknown';
196
+ }
197
+
198
+ getOriginalDevice(): string {
199
+ return this.embedder.isInitialized() ? this.embedder.getOriginalDevice() : 'unknown';
200
+ }
201
+
202
+ getDimension(): number | null {
203
+ if (this.dimension !== null) return this.dimension;
204
+ return this.embedder.isInitialized() ? this.embedder.getDimension() : null;
205
+ }
206
+
207
+ setDimension(dim: number): void {
208
+ this.dimension = dim;
209
+ }
210
+
211
+ isInitialized(): boolean {
212
+ return this.embedder.isInitialized();
213
+ }
214
+
215
+ async embed(text: string): Promise<Float32Array> {
216
+ this.assertServing();
217
+ return this.queue.enqueue(() => this.embedder.embed(text));
218
+ }
219
+
220
+ async embedMany(texts: string[]): Promise<(Float32Array | number[])[]> {
221
+ this.assertServing();
222
+ return this.queue.enqueue(() => this.embedder.embedMany(texts));
223
+ }
224
+
225
+ // Refuse to serve once this server has stepped down (leadership lost or queue
226
+ // poisoned). Throwing an ECONNREFUSED-bearing error — the same signal a dead
227
+ // remote leader produces — drives the store's withEmbedderReconnect to re-resolve
228
+ // through the factory and become a client of the NEW leader, instead of silently
229
+ // re-initializing this disposed embedder and re-acquiring the GPU in a process
230
+ // that is no longer the leader (which would break the single-GPU-context invariant).
231
+ private assertServing(): void {
232
+ if (this.isShuttingDown) {
233
+ throw new Error('[EmbeddingServer] embedding endpoint gone (ECONNREFUSED): leader stepped down — reconnect required');
234
+ }
235
+ }
236
+
237
+ private handleQueuePoisoned(err: Error): void {
238
+ logger.error(
239
+ '[EmbeddingServer] Embedding queue poisoned by a permanently hung inference. ' +
240
+ 'Stepping down as leader so a fresh process can re-elect with a clean GPU context. ' +
241
+ err.message,
242
+ );
243
+ // Step down WITHOUT disposing the embedder: pipeline.dispose() would join the
244
+ // stuck native thread (itself hanging) or touch a device-lost context. Clearing
245
+ // the state registration + closing the HTTP server is enough for re-election;
246
+ // the wedged native resources are abandoned to eventual process exit.
247
+ void this.shutdown(false);
248
+ this.externalOnPoison?.(err);
249
+ }
250
+
251
+ async dispose(): Promise<void> {
252
+ return this.shutdown();
253
+ }
254
+
255
+ // ---- Server lifecycle ----
256
+
257
+ async startServer(): Promise<number> {
258
+ // Wrap embedder initialization in a dedicated captureStdio scope keyed by
259
+ // serverId. This ensures native C++ output (Dawn/ONNX limit-clamping warnings
260
+ // written directly to FD 2) is redirected to the log file regardless of whether
261
+ // a research session's outer captureStdio is already active — the serverId key
262
+ // bypasses the global isAnyLoggerCapturingOutput guard.
263
+ const diskChecker = new DiskSpaceChecker();
264
+ // Consolidated log: the embedding server writes into the same file as the
265
+ // main process (path defaults to tmpdir, overridable via PI_RESEARCH_LOG_PATH).
266
+ const logFile = buildDefaultDebugLogPath();
267
+ await captureStdio(
268
+ logFile,
269
+ () => diskChecker.checkDiskSpace(path.dirname(logFile)),
270
+ () => this.embedder.initialize(),
271
+ this.serverId,
272
+ );
273
+
274
+ // Capture dimension after initialization
275
+ this.dimension = this.embedder.getDimension();
276
+
277
+ return new Promise((resolve, reject) => {
278
+ this.server = http.createServer((req, res) => {
279
+ void this.handleRequest(req, res);
280
+ });
281
+
282
+ this.server.listen(0, '127.0.0.1', () => {
283
+ const addr = this.server?.address();
284
+ if (addr && typeof addr === 'object') {
285
+ logger.info(`[EmbeddingServer] Listening on http://127.0.0.1:${addr.port} (serverId: ${this.serverId})`);
286
+ resolve(addr.port);
287
+ } else {
288
+ reject(new Error('[EmbeddingServer] Failed to get server port'));
289
+ }
290
+ });
291
+
292
+ this.server.on('error', (err) => {
293
+ logger.error('[EmbeddingServer] Server error:', err);
294
+ reject(err);
295
+ });
296
+ });
297
+ }
298
+
299
+ async shutdown(disposeEmbedder = true): Promise<void> {
300
+ if (this.isShuttingDown) return;
301
+ this.isShuttingDown = true;
302
+
303
+ if (this.leadershipTimer) {
304
+ clearTimeout(this.leadershipTimer);
305
+ this.leadershipTimer = null;
306
+ }
307
+
308
+ // Clear our registration if we still own it
309
+ try {
310
+ const serverInfo = await this.stateManager.getEmbeddingServer();
311
+ if (serverInfo?.serverId === this.serverId) {
312
+ await this.stateManager.clearEmbeddingServer().catch((err) => {
313
+ logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
314
+ });
315
+ }
316
+ } catch (err) {
317
+ const msg = err instanceof Error ? err.message : String(err);
318
+ // State manager is torn down before the embedding server during process exit —
319
+ // this is expected and harmless; log at debug to avoid noise.
320
+ if (msg.includes('not initialized') || msg.includes('State manager')) {
321
+ logger.debug('[EmbeddingServer] State manager already disposed during shutdown, skipping registration cleanup.');
322
+ } else {
323
+ logger.warn('[EmbeddingServer] Could not read embedding server state during shutdown:', err);
324
+ }
325
+ }
326
+
327
+ if (this.server) {
328
+ this.server.closeAllConnections?.();
329
+ const srv = this.server;
330
+ // Bound server.close(): a lingering keep-alive client could otherwise hang
331
+ // the callback forever and block process shutdown. Force teardown after 5s.
332
+ await new Promise<void>((resolve) => {
333
+ let settled = false;
334
+ const done = () => { if (!settled) { settled = true; resolve(); } };
335
+ const timer = setTimeout(() => {
336
+ logger.warn('[EmbeddingServer] server.close() timed out after 5s; forcing teardown.');
337
+ done();
338
+ }, 5000);
339
+ timer.unref?.();
340
+ try {
341
+ srv.close(() => { clearTimeout(timer); done(); });
342
+ } catch {
343
+ clearTimeout(timer); done();
344
+ }
345
+ });
346
+ this.server = null;
347
+ }
348
+
349
+ // Skip embedder disposal on the poison path (disposeEmbedder=false): the native
350
+ // inference is wedged, so pipeline.dispose()/ReleaseSession would join the stuck
351
+ // thread and hang the whole teardown. Registration is already cleared above, which
352
+ // is enough for another process to re-elect; the leaked native resources die with
353
+ // eventual process exit.
354
+ if (disposeEmbedder) {
355
+ await this.embedder.dispose().catch((err) => {
356
+ if (this.isShuttingDown) {
357
+ logger.debug('[EmbeddingServer] Error disposing embedder during shutdown (expected):', err);
358
+ } else {
359
+ logger.warn('[EmbeddingServer] Error disposing embedder:', err);
360
+ }
361
+ });
362
+ }
363
+ }
364
+
365
+ private async getEmbeddingServerWithRetry(retries = 2, delay = 300): Promise<{ port: number; pid: number; serverId: string } | null> {
366
+ for (let i = 0; i <= retries; i++) {
367
+ const serverInfo = await this.stateManager.getEmbeddingServer();
368
+ if (serverInfo) return serverInfo;
369
+ if (i < retries) {
370
+ await new Promise(resolve => setTimeout(resolve, delay));
371
+ }
372
+ }
373
+ return null;
374
+ }
375
+
376
+ // ---- Leadership check ----
377
+
378
+ startLeadershipCheck(): void {
379
+ const check = async () => {
380
+ if (this.isShuttingDown) return;
381
+ try {
382
+ const serverInfo = await this.getEmbeddingServerWithRetry();
383
+
384
+ // Resilience: Skip check if server info is transiently unavailable after retries.
385
+ // This is a benign, explicitly-handled condition (the state entry can be briefly
386
+ // absent while a concurrent run rewrites shared state), so log at DEBUG — at WARN
387
+ // it spams the log every 30s for the life of the process.
388
+ if (!serverInfo) {
389
+ logger.debug('[EmbeddingServer] Leadership check: no embedding server found in state after retries. Skipping check.');
390
+ return;
391
+ }
392
+
393
+ if (serverInfo.serverId !== this.serverId) {
394
+ this.consecutiveLeadershipMisses++;
395
+ logger.warn(
396
+ `[EmbeddingServer] Leadership check failed (${this.consecutiveLeadershipMisses}/${this.LEADERSHIP_MISS_THRESHOLD}) — ` +
397
+ `expected ${this.serverId}, found ${serverInfo?.serverId ?? 'none'}`,
398
+ );
399
+ if (this.consecutiveLeadershipMisses >= this.LEADERSHIP_MISS_THRESHOLD) {
400
+ // Another process won leadership; step down. This is an orderly, expected
401
+ // handoff (not a fault), so it logs at WARN to match the surrounding
402
+ // leadership-check severity (DEBUG/WARN above) rather than polluting the
403
+ // ERROR log — which also re-tracks into the diagnostic error count.
404
+ logger.warn('[EmbeddingServer] Leadership lost, shutting down...');
405
+ void this.shutdown();
406
+ return;
407
+ }
408
+ } else {
409
+ if (this.consecutiveLeadershipMisses > 0) {
410
+ logger.log(`[EmbeddingServer] Leadership confirmed, resetting miss counter from ${this.consecutiveLeadershipMisses}`);
411
+ this.consecutiveLeadershipMisses = 0;
412
+ }
413
+ }
414
+ } catch (err) {
415
+ logger.warn('[EmbeddingServer] Leadership check error:', err);
416
+ } finally {
417
+ if (!this.isShuttingDown) {
418
+ this.leadershipTimer = setTimeout(check, 30_000);
419
+ if (this.leadershipTimer.unref) this.leadershipTimer.unref();
420
+ }
421
+ }
422
+ };
423
+
424
+ this.leadershipTimer = setTimeout(check, 30_000);
425
+ if (this.leadershipTimer.unref) this.leadershipTimer.unref();
426
+ }
427
+
428
+ // ---- HTTP request handler ----
429
+
430
+ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
431
+ const MAX_BODY_SIZE = 50 * 1024 * 1024; // 50 MB
432
+
433
+ let body = '';
434
+ let oversized = false;
435
+ req.on('data', (chunk: Buffer) => {
436
+ body += chunk;
437
+ if (body.length > MAX_BODY_SIZE) {
438
+ oversized = true;
439
+ req.destroy(new Error('Payload too large'));
440
+ }
441
+ });
442
+
443
+ await new Promise<void>((resolve) => {
444
+ let settled = false;
445
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
446
+
447
+ // req.destroy() on an oversized body (and any client-side stream fault)
448
+ // emits 'error' but NOT 'end'. Without this the 'end'-only awaiter below
449
+ // would never resolve — the handler would dangle and the client would
450
+ // block until its own timeout. Respond (best-effort) and unblock.
451
+ req.on('error', (err) => {
452
+ logger.error('[EmbeddingServer] Request stream error:', err);
453
+ if (!res.headersSent) {
454
+ try {
455
+ res.writeHead(oversized ? 413 : 400, { 'Content-Type': 'application/json' });
456
+ res.end(JSON.stringify({ error: oversized ? 'Payload too large' : 'Request stream error' }));
457
+ } catch { /* socket may already be torn down */ }
458
+ }
459
+ finish();
460
+ });
461
+
462
+ req.on('end', async () => {
463
+ try {
464
+ if (req.method === 'GET' && req.url === '/health') {
465
+ const payload = {
466
+ status: 'ok',
467
+ device: this.getDevice(),
468
+ dimension: this.getDimension(),
469
+ };
470
+ res.writeHead(200, { 'Content-Type': 'application/json' });
471
+ res.end(JSON.stringify(payload));
472
+ finish();
473
+ return;
474
+ }
475
+
476
+ if (req.method !== 'POST') {
477
+ res.writeHead(405);
478
+ res.end('Method Not Allowed');
479
+ finish();
480
+ return;
481
+ }
482
+
483
+ const data = JSON.parse(body);
484
+
485
+ switch (req.url) {
486
+ case '/embed': {
487
+ // Refuse once stepped down (leader lost / queue poisoned) — the in-process
488
+ // embed()/embedMany() already guard, but the HTTP path (the actual REMOTE
489
+ // client route) bypassed it and would re-acquire the GPU in a non-leader
490
+ // process, breaking the single-GPU-context invariant. The ECONNREFUSED
491
+ // message drives the client to reconnect to the new leader.
492
+ this.assertServing();
493
+ const result = await this.queue.enqueue(() => this.embedder.embed(data.text as string));
494
+ res.writeHead(200, { 'Content-Type': 'application/json' });
495
+ res.end(JSON.stringify({ data: Array.from(result) }));
496
+ break;
497
+ }
498
+ case '/embedMany': {
499
+ this.assertServing();
500
+ const results = await this.queue.enqueue(() => this.embedder.embedMany(data.texts as string[]));
501
+ res.writeHead(200, { 'Content-Type': 'application/json' });
502
+ res.end(JSON.stringify({ results: results.map(r => Array.from(r)) }));
503
+ break;
504
+ }
505
+ default:
506
+ res.writeHead(404);
507
+ res.end('Not Found');
508
+ }
509
+ } catch (error) {
510
+ // Log full error internally for debugging
511
+ logger.error('[EmbeddingServer] Error handling request:', error);
512
+
513
+ // Extract safe error message - first line only to prevent stack trace exposure
514
+ let errorMessage = 'Unknown error';
515
+ if (error instanceof Error && error.message) {
516
+ errorMessage = (error.message.split('\n')[0] || '').trim();
517
+ } else if (typeof error === 'string') {
518
+ errorMessage = (error.split('\n')[0] || '').trim();
519
+ }
520
+
521
+ // Headers may already be out (e.g. res.end() threw mid-response) —
522
+ // writeHead() would then throw ERR_HTTP_HEADERS_SENT inside this async
523
+ // handler and surface as an unhandledRejection in the leader process.
524
+ if (!res.headersSent) {
525
+ res.writeHead(500, { 'Content-Type': 'application/json' });
526
+ res.end(JSON.stringify({ error: errorMessage }));
527
+ } else {
528
+ res.destroy();
529
+ }
530
+ }
531
+ finish();
532
+ });
533
+ });
534
+ }
535
+ }