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