@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,205 @@
1
+ /**
2
+ * Embedding Client
3
+ *
4
+ * HTTP client that implements IEmbedder by forwarding embed/embedMany calls
5
+ * to a remote EmbeddingServer. Mirrors BrowserClient.
6
+ */
7
+
8
+ import * as http from 'node:http';
9
+ import { logger } from '../../logger.ts';
10
+ import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
11
+
12
+ export class EmbeddingClient implements IEmbedder {
13
+ // Public so store.ts can set the dimension from existing table schema
14
+ // before the embedder has been fully warmed up (see Embedder.setDimension).
15
+ dimension: number | null = null;
16
+
17
+ constructor(
18
+ private readonly port: number,
19
+ private _device: string = 'unknown',
20
+ ) {
21
+ logger.log(`[EmbeddingClient] Connecting to embedding server at http://127.0.0.1:${port}`);
22
+ }
23
+
24
+ // ---- IEmbedder ----
25
+
26
+ getDevice(): string {
27
+ return this._device;
28
+ }
29
+
30
+ getOriginalDevice(): string {
31
+ return this._device;
32
+ }
33
+
34
+ getDimension(): number | null {
35
+ return this.dimension;
36
+ }
37
+
38
+ setDimension(dim: number): void {
39
+ this.dimension = dim;
40
+ }
41
+
42
+ isInitialized(): boolean {
43
+ // The server has already initialised; from the client's perspective we are ready.
44
+ return true;
45
+ }
46
+
47
+ async embed(text: string): Promise<Float32Array> {
48
+ const resp = await this.request<{ data: number[] }>('/embed', { text });
49
+ return new Float32Array(resp.data);
50
+ }
51
+
52
+ async embedMany(texts: string[]): Promise<(Float32Array | number[])[]> {
53
+ const resp = await this.request<{ results: number[][] }>('/embedMany', { texts });
54
+ return resp.results.map(r => new Float32Array(r));
55
+ }
56
+
57
+ async dispose(): Promise<void> {
58
+ // Clients don't shut down the server.
59
+ }
60
+
61
+ // ---- Health ----
62
+
63
+ async fetchHealth(): Promise<void> {
64
+ const result = await this.getRequest<{ status: string; device: string; dimension: number | null }>('/health');
65
+ this._device = result.device;
66
+ if (result.dimension !== null) {
67
+ this.dimension = result.dimension;
68
+ }
69
+ }
70
+
71
+ // ---- Internal HTTP helpers ----
72
+
73
+ private getRequest<T>(path: string): Promise<T> {
74
+ return new Promise((resolve, reject) => {
75
+ const timeoutMs = 120_000;
76
+ let resolved = false;
77
+ let req: http.ClientRequest | undefined;
78
+ const timer = setTimeout(() => {
79
+ if (!resolved) {
80
+ resolved = true;
81
+ // Destroy the request so a stalled body read is torn down, not left
82
+ // holding the socket until the server eventually closes it.
83
+ req?.destroy();
84
+ reject(new Error(`[EmbeddingClient] GET ${path} timed out after ${timeoutMs}ms`));
85
+ }
86
+ }, timeoutMs);
87
+ // FIX (#34): Don't keep the event loop alive for timeout timers
88
+ if (timer.unref) timer.unref();
89
+
90
+ req = http.request(
91
+ {
92
+ hostname: '127.0.0.1',
93
+ port: this.port,
94
+ path,
95
+ method: 'GET',
96
+ },
97
+ (res) => {
98
+ // Keep the timeout armed through the body read — clearing it on headers
99
+ // would let a stalled body hang for the full process lifetime.
100
+ let body = '';
101
+ res.on('data', (chunk: Buffer) => { body += chunk; });
102
+ res.on('end', () => {
103
+ if (resolved) return;
104
+ resolved = true;
105
+ clearTimeout(timer);
106
+ try {
107
+ const parsed = JSON.parse(body) as T;
108
+ if (res.statusCode !== 200) {
109
+ reject(new Error(`[EmbeddingClient] GET ${path} returned HTTP ${res.statusCode}`));
110
+ } else {
111
+ resolve(parsed);
112
+ }
113
+ } catch (_e) {
114
+ reject(new Error(`[EmbeddingClient] Failed to parse GET ${path} response: ${body}`));
115
+ }
116
+ });
117
+ res.on('error', (err) => {
118
+ if (resolved) return;
119
+ resolved = true;
120
+ clearTimeout(timer);
121
+ reject(new Error(`[EmbeddingClient] GET ${path} response error: ${err.message}`));
122
+ });
123
+ },
124
+ );
125
+
126
+ req.on('error', (err) => {
127
+ if (resolved) return;
128
+ resolved = true;
129
+ clearTimeout(timer);
130
+ reject(new Error(`[EmbeddingClient] GET ${path} request error: ${err.message}`));
131
+ });
132
+
133
+ req.end();
134
+ });
135
+ }
136
+
137
+ private request<T>(path: string, data: unknown): Promise<T> {
138
+ return new Promise((resolve, reject) => {
139
+ const timeoutMs = 120_000;
140
+ let resolved = false;
141
+ let req: http.ClientRequest | undefined;
142
+ const timer = setTimeout(() => {
143
+ if (!resolved) {
144
+ resolved = true;
145
+ req?.destroy();
146
+ reject(new Error(`[EmbeddingClient] POST ${path} timed out after ${timeoutMs}ms`));
147
+ }
148
+ }, timeoutMs);
149
+ // FIX (#34): Don't keep the event loop alive for timeout timers
150
+ if (timer.unref) timer.unref();
151
+
152
+ const body = JSON.stringify(data);
153
+
154
+ req = http.request(
155
+ {
156
+ hostname: '127.0.0.1',
157
+ port: this.port,
158
+ path,
159
+ method: 'POST',
160
+ headers: {
161
+ 'Content-Type': 'application/json',
162
+ 'Content-Length': Buffer.byteLength(body),
163
+ },
164
+ },
165
+ (res) => {
166
+ // Keep the timeout armed through the body read (see getRequest).
167
+ let responseBody = '';
168
+ res.on('data', (chunk: Buffer) => { responseBody += chunk; });
169
+ res.on('end', () => {
170
+ if (resolved) return;
171
+ resolved = true;
172
+ clearTimeout(timer);
173
+ try {
174
+ const parsed = JSON.parse(responseBody) as T;
175
+ if (res.statusCode !== 200) {
176
+ const msg = (parsed as Record<string, unknown>)?.['error'];
177
+ reject(new Error(typeof msg === 'string' ? msg : `HTTP ${res.statusCode}`));
178
+ } else {
179
+ resolve(parsed);
180
+ }
181
+ } catch (_e) {
182
+ reject(new Error(`[EmbeddingClient] Failed to parse POST ${path} response: ${responseBody}`));
183
+ }
184
+ });
185
+ res.on('error', (err) => {
186
+ if (resolved) return;
187
+ resolved = true;
188
+ clearTimeout(timer);
189
+ reject(new Error(`[EmbeddingClient] POST ${path} response error: ${err.message}`));
190
+ });
191
+ },
192
+ );
193
+
194
+ req.on('error', (err) => {
195
+ if (resolved) return;
196
+ resolved = true;
197
+ clearTimeout(timer);
198
+ reject(new Error(`[EmbeddingClient] POST ${path} request error: ${err.message}`));
199
+ });
200
+
201
+ req.write(body);
202
+ req.end();
203
+ });
204
+ }
205
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Embedding Factory
3
+ *
4
+ * Leader-election for the embedding server. Mirrors getScheduler() in
5
+ * scheduler-factory.ts: one process wins and runs EmbeddingServer; all
6
+ * others become EmbeddingClient instances pointing at the winner's port.
7
+ */
8
+
9
+ import * as crypto from 'node:crypto';
10
+ import * as net from 'node:net';
11
+ import type { Config } from '../../config.ts';
12
+ import { getConfig } from '../../config.ts';
13
+ import { logger } from '../../logger.ts';
14
+ import { getService } from '../../core/service-registry.ts';
15
+ import { ServiceNames } from '../../core/service-interfaces.ts';
16
+ import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
17
+ import type { IProcessLifecycle } from '../../core/interfaces/process-interfaces.ts';
18
+ import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
19
+ import { Embedder } from '../../knowledge/embedder.ts';
20
+ import { getModelEmbedderConfig } from '../../knowledge/model-config.ts';
21
+
22
+ import { EmbeddingServer } from './embedding-server.ts';
23
+ import { EmbeddingClient } from './embedding-client.ts';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Port probe — same helper as scheduler-factory.ts
27
+ // ---------------------------------------------------------------------------
28
+
29
+ async function isPortListening(port: number, timeoutMs = 2000): Promise<boolean> {
30
+ return new Promise((resolve) => {
31
+ const socket = new net.Socket();
32
+ let settled = false;
33
+ const settle = (ok: boolean) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ socket.destroy();
37
+ resolve(ok);
38
+ };
39
+ socket.setTimeout(timeoutMs);
40
+ socket.once('connect', () => settle(true));
41
+ socket.once('error', () => settle(false));
42
+ socket.once('timeout', () => settle(false));
43
+ socket.connect(port, '127.0.0.1');
44
+ });
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Leader liveness — PID paired with start-time to defeat PID reuse
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Decide whether a persisted embedding-leader entry still refers to a live process.
53
+ *
54
+ * Pairs the recorded PID with its start-time (when present) so a recycled PID — the OS
55
+ * handing a dead leader's PID to an unrelated new process, which a bare signal-0 check
56
+ * would mistake for the still-running leader — is correctly reported dead. This matches
57
+ * the PID-reuse handling the GPU lock and session cleanup already use. Entries written
58
+ * before start-time was recorded (no `startTime`) degrade to a bare liveness check, so
59
+ * older on-disk state keeps working with no migration.
60
+ *
61
+ * The reachable failure this closes is narrow: a leader that dies mid-model-init (its
62
+ * entry still carries the sentinel port -1, so the port probe is skipped) whose PID the
63
+ * OS then recycles. Without start-time the waiter would spin the full poll timeout on a
64
+ * leader that will never come; with it the stale entry is cleared immediately.
65
+ */
66
+ export async function isEmbeddingLeaderAlive(
67
+ info: { pid: number; startTime?: number },
68
+ processLifecycle: Pick<IProcessLifecycle, 'isProcessAlive'>,
69
+ ): Promise<boolean> {
70
+ return processLifecycle.isProcessAlive(info.pid, info.startTime);
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Module-level singleton cache (mirrors scheduler-factory.ts pattern)
75
+ // ---------------------------------------------------------------------------
76
+
77
+ let _embeddingInstance: IEmbedder | null = null;
78
+ let _embeddingInitPromise: Promise<IEmbedder> | null = null;
79
+ let _cachedModel: string | null = null;
80
+ let _cachedDevice: string | null = null;
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // getEmbedder — public entry point
84
+ // ---------------------------------------------------------------------------
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // In-process Embedder construction — shared by the leader path (wrapped in an
88
+ // EmbeddingServer) and the model-mismatch fallback (used bare, never elected).
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function buildInProcessEmbedder(cfg: Config, stateManager: IStateManager): Embedder {
92
+ const modelCfg = getModelEmbedderConfig(cfg.EMBEDDING_MODEL);
93
+ return new Embedder({
94
+ model: cfg.EMBEDDING_MODEL,
95
+ pooling: modelCfg.pooling,
96
+ queryPrefix: modelCfg.queryPrefix,
97
+ initializationTimeoutMs: cfg.EMBEDDING_MODEL_INIT_TIMEOUT_MS,
98
+ device: cfg.EMBEDDING_DEVICE,
99
+ maxTokens: modelCfg.maxTokens,
100
+ batchSize: modelCfg.batchSize,
101
+ charsPerToken: modelCfg.charsPerToken,
102
+ documentPrefix: modelCfg.documentPrefix,
103
+ stateManager,
104
+ useCache: modelCfg.useCache,
105
+ });
106
+ }
107
+
108
+ // Bounds the leader-wait retry tail-call below. Without a cap, a cluster where no
109
+ // process can ever become leader (e.g. every model init OOMs) would recurse
110
+ // forever, each hop doing a state-lock round-trip and accumulating async frames.
111
+ const MAX_LEADER_WAIT_RETRIES = 5;
112
+
113
+ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbedder> {
114
+ const cfg = config ?? getConfig();
115
+
116
+ // If we have a cached instance, check if it matches the current config
117
+ if (_embeddingInstance) {
118
+ if (_cachedModel === cfg.EMBEDDING_MODEL && _cachedDevice === cfg.EMBEDDING_DEVICE) {
119
+ return _embeddingInstance;
120
+ }
121
+ logger.info(`[EmbeddingFactory] Configuration change detected (${_cachedModel} on ${_cachedDevice} -> ${cfg.EMBEDDING_MODEL} on ${cfg.EMBEDDING_DEVICE}). Disposing stale instance.`);
122
+ await _embeddingInstance.dispose?.();
123
+ _embeddingInstance = null;
124
+ }
125
+
126
+ if (_embeddingInitPromise) return _embeddingInitPromise;
127
+
128
+ let p: Promise<IEmbedder>;
129
+
130
+ const init = async (): Promise<IEmbedder> => {
131
+ const serverId = crypto.randomUUID();
132
+ const stateManager = await getService<IStateManager>(ServiceNames.STATE_MANAGER);
133
+ const processLifecycle = await getService<IProcessLifecycle>(ServiceNames.PROCESS_LIFECYCLE);
134
+ // Recorded alongside our PID so another process can distinguish "leader still
135
+ // alive" from "our PID was recycled after we died" (getCurrentProcessStartTime
136
+ // caches, so this is a single cheap read). Null on a platform where it cannot be
137
+ // read → the entry omits it and liveness falls back to a bare signal-0 check.
138
+ const myStartTime = await processLifecycle.getCurrentProcessStartTime();
139
+
140
+ // ---- Phase 1: Atomically claim candidacy under the state lock ----
141
+ // Writing port=-1 acts as a mutex: other processes that see it will wait
142
+ // for the real port rather than starting their own model initialization.
143
+ // This prevents concurrent GPU sessions and wasted init work.
144
+ let iAmCandidate = false;
145
+ let fastPathPort: number | null = null;
146
+ let fastPathModel: string | null = null;
147
+
148
+ await stateManager.updateState(async (state) => {
149
+ if (state.embeddingServer) {
150
+ const alive = await isEmbeddingLeaderAlive(state.embeddingServer, processLifecycle);
151
+ if (alive) {
152
+ // Either a real server (port > 0) or another process is initializing (port = -1)
153
+ fastPathPort = state.embeddingServer.port;
154
+ fastPathModel = state.embeddingServer.model ?? null;
155
+ return state;
156
+ }
157
+ // Stale entry from a dead process — clear it
158
+ delete state.embeddingServer;
159
+ }
160
+ // Claim the slot with sentinel port -1 to signal "initializing". Record the
161
+ // model we will serve so followers with a DIFFERENT configured model can
162
+ // refuse adoption (see mismatch check below) instead of silently
163
+ // cross-embedding into a shared vector space.
164
+ state.embeddingServer = {
165
+ port: -1,
166
+ pid: process.pid,
167
+ serverId,
168
+ model: cfg.EMBEDDING_MODEL,
169
+ ...(myStartTime !== null ? { startTime: myStartTime } : {}),
170
+ };
171
+ iAmCandidate = true;
172
+ return state;
173
+ });
174
+
175
+ // A live leader serving a DIFFERENT model must not be adopted: two sessions
176
+ // with different EMBEDDING_MODELs would cross-embed (same-dimension models
177
+ // corrupt the vector space silently). Fall back to a local in-process
178
+ // embedder for this session — no competing election, the leader's entry is
179
+ // left untouched. Entries without a recorded model (older process) are
180
+ // tolerated as matching.
181
+ const localFallbackForMismatch = (leaderModel: string): IEmbedder => {
182
+ logger.warn(
183
+ `[EmbeddingFactory] Live embedding leader serves model "${leaderModel}" but this session is configured for ` +
184
+ `"${cfg.EMBEDDING_MODEL}". Not adopting it — using a local in-process embedder for this session.`,
185
+ );
186
+ const embedder = buildInProcessEmbedder(cfg, stateManager);
187
+ _embeddingInstance = embedder;
188
+ _cachedModel = cfg.EMBEDDING_MODEL;
189
+ _cachedDevice = cfg.EMBEDDING_DEVICE;
190
+ return embedder;
191
+ };
192
+
193
+ if (!iAmCandidate) {
194
+ if (fastPathModel !== null && fastPathModel !== cfg.EMBEDDING_MODEL) {
195
+ return localFallbackForMismatch(fastPathModel);
196
+ }
197
+ // Another process is already leader or initializing — wait for real port
198
+ const POLL_INTERVAL_MS = 500;
199
+ const POLL_TIMEOUT_MS = 120_000;
200
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
201
+
202
+ logger.info(`[EmbeddingFactory] Another process is initializing (port=${fastPathPort}), waiting...`);
203
+
204
+ while (Date.now() < deadline) {
205
+ await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
206
+ const info = await stateManager.getEmbeddingServer();
207
+ if (!info) {
208
+ // Candidate died and cleared state — fall through to restart below
209
+ break;
210
+ }
211
+ if (!(await isEmbeddingLeaderAlive(info, processLifecycle))) {
212
+ // Candidate process died without cleaning up (or its PID was recycled by
213
+ // an unrelated process — the start-time pairing catches that too).
214
+ await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
215
+ break;
216
+ }
217
+ // Re-check model identity every poll: the leader may have changed while
218
+ // we waited (old one died, a new one with a different model claimed).
219
+ if (info.model !== undefined && info.model !== cfg.EMBEDDING_MODEL) {
220
+ return localFallbackForMismatch(info.model);
221
+ }
222
+ if (info.port > 0) {
223
+ const portOk = await isPortListening(info.port);
224
+ if (portOk) {
225
+ logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
226
+ const client = new EmbeddingClient(info.port);
227
+ await client.fetchHealth();
228
+ _embeddingInstance = client;
229
+ _cachedModel = cfg.EMBEDDING_MODEL;
230
+ _cachedDevice = cfg.EMBEDDING_DEVICE;
231
+ return client;
232
+ }
233
+ // Port registered but not responding — wait one more interval and re-check
234
+ await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
235
+ const secondCheck = await isPortListening(info.port);
236
+ if (!secondCheck) {
237
+ logger.warn(`[EmbeddingFactory] Registered port ${info.port} is unreachable after two checks — clearing stale state`);
238
+ await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
239
+ break;
240
+ }
241
+ }
242
+ }
243
+
244
+ // Timed out or candidate died — attempt to claim candidacy ourselves (tail-call)
245
+ _embeddingInitPromise = null;
246
+ if (_attempt + 1 >= MAX_LEADER_WAIT_RETRIES) {
247
+ throw new Error(
248
+ `[EmbeddingFactory] Could not obtain an embedding server after ${MAX_LEADER_WAIT_RETRIES} attempts ` +
249
+ `(no process became leader; model init may be failing repeatedly).`,
250
+ );
251
+ }
252
+ logger.warn(`[EmbeddingFactory] Wait for embedding leader timed out or leader died, retrying (attempt ${_attempt + 2}/${MAX_LEADER_WAIT_RETRIES})...`);
253
+ return getEmbedder(cfg, _attempt + 1);
254
+ }
255
+
256
+ // ---- Phase 2: We are the exclusive candidate — do model init ----
257
+ const embedder = buildInProcessEmbedder(cfg, stateManager);
258
+
259
+ const server = new EmbeddingServer(embedder, stateManager, serverId, () => {
260
+ // This leader poisoned itself after a permanently hung inference; drop the
261
+ // cached singleton so this process's next getEmbedder() re-elects. The store's
262
+ // reconnectFactory also triggers on the first fast-failed embed, so cluster
263
+ // recovery does not depend solely on this hook.
264
+ void clearEmbeddingInstance();
265
+ });
266
+ let port: number;
267
+ try {
268
+ port = await server.startServer();
269
+ } catch (err) {
270
+ // Init failed — clear our placeholder so others can try
271
+ await stateManager.clearEmbeddingServer().catch((err) => logger.debug('Swallowed clear embedding server error:', err));
272
+ throw err;
273
+ }
274
+
275
+ // ---- Phase 3: Update state with real port ----
276
+ await stateManager.updateState(async (state) => {
277
+ if (state.embeddingServer?.serverId === serverId) {
278
+ state.embeddingServer.port = port;
279
+ }
280
+ return state;
281
+ });
282
+
283
+ logger.info(`[EmbeddingFactory] Won election, serving as embedding leader on port ${port} (PID ${process.pid})`);
284
+ // Start leadership check only after winning — model init (inside startServer)
285
+ // can exceed the 30s interval, so starting earlier would produce false misses.
286
+ server.startLeadershipCheck();
287
+ _embeddingInstance = server;
288
+ _cachedModel = cfg.EMBEDDING_MODEL;
289
+ _cachedDevice = cfg.EMBEDDING_DEVICE;
290
+ return server;
291
+ };
292
+
293
+ p = init();
294
+ _embeddingInitPromise = p;
295
+ // init() already assigns _embeddingInstance before it resolves, so do NOT
296
+ // re-assign it here. If clearEmbeddingInstance() ran (and disposed the server)
297
+ // between init resolving and this microtask, re-assigning would resurrect the
298
+ // disposed instance and hand it to the next getEmbedder() caller. Only clear
299
+ // the in-flight promise marker.
300
+ p.then(() => {
301
+ _embeddingInitPromise = null;
302
+ }).catch(() => {
303
+ _embeddingInitPromise = null;
304
+ });
305
+ return p;
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // clearEmbeddingInstance — for testing / restart scenarios
310
+ // ---------------------------------------------------------------------------
311
+
312
+ export async function clearEmbeddingInstance(): Promise<void> {
313
+ if (_embeddingInstance) {
314
+ try {
315
+ await _embeddingInstance.dispose?.();
316
+ } catch (err) {
317
+ logger.warn('[EmbeddingFactory] Error disposing old embedding instance:', err);
318
+ }
319
+ }
320
+ _embeddingInstance = null;
321
+ _embeddingInitPromise = null;
322
+ _cachedModel = null;
323
+ _cachedDevice = null;
324
+ }