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