@lincoln504/pi-research 0.1.13 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +55 -138
- package/dist/cli.mjs +23375 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1195 -0
- package/src/config.ts +1032 -155
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +258 -11
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- 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
|
+
}
|