@lincoln504/pi-research 1.6.5 → 1.6.6
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/README.md +13 -9
- package/agent-skill/pi-research/README.md +5 -4
- package/agent-skill/pi-research/SKILL.md +1 -1
- package/dist/cli.mjs +587 -149
- package/dist/pdf-extract-worker.mjs +1289 -0
- package/dist/prompts/research-tool-usage.md +2 -1
- package/dist/prompts/researcher.md +1 -1
- package/dist/prompts/system-coordinator.md +2 -2
- package/dist/prompts/system-lead-router.md +1 -1
- package/docs/AGENT-SKILL.md +6 -6
- package/docs/ARCHITECTURE.md +24 -22
- package/docs/CHANGELOG.md +54 -0
- package/docs/CONFIGURATION.md +3 -2
- package/docs/SDK.md +7 -12
- package/package.json +7 -4
- package/scripts/build.cjs +35 -0
- package/scripts/setup.cjs +12 -10
- package/src/cleanup/research-cleanup.ts +11 -5
- package/src/cli.ts +7 -5
- package/src/config.ts +9 -3
- package/src/core/interfaces/knowledge-interfaces.ts +8 -0
- package/src/core/interfaces/orchestration-interfaces.ts +1 -1
- package/src/core/interfaces/research-plan-types.ts +6 -0
- package/src/core/llm/agentic-repair.ts +40 -3
- package/src/core/llm/llm-utils.ts +93 -5
- package/src/core/llm/model-registry-factory.ts +2 -15
- package/src/core/pi-ai-skew.ts +351 -0
- package/src/core/pi-version.ts +1 -1
- package/src/core/planning-service.ts +110 -15
- package/src/core/planning-utils.ts +3 -0
- package/src/core/service-registry.ts +8 -6
- package/src/index.ts +14 -0
- package/src/infrastructure/browser/config.ts +6 -7
- package/src/infrastructure/browser/ensure-browser.ts +14 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +18 -19
- package/src/knowledge/chunker.ts +22 -4
- package/src/knowledge/embedder-init.ts +41 -1
- package/src/knowledge/store.ts +68 -1
- package/src/observers/research-observer-impl.ts +4 -1
- package/src/orchestration/deep-research-orchestrator.ts +3 -3
- package/src/orchestration/headless-observer.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +17 -3
- package/src/orchestration/research-orchestration-service.ts +27 -4
- package/src/orchestration/session-state.ts +36 -7
- package/src/prompts/research-tool-usage.md +2 -1
- package/src/prompts/researcher.md +1 -1
- package/src/prompts/system-coordinator.md +2 -2
- package/src/prompts/system-lead-router.md +1 -1
- package/src/sdk.ts +12 -4
- package/src/tools/research-knowledge-search.ts +23 -7
- package/src/tools/research-tool-definition.ts +1 -1
- package/src/tools/scrape.ts +12 -9
- package/src/tools/search.ts +12 -6
- package/src/tools/security.ts +6 -1
- package/src/tools/stackexchange.ts +6 -1
- package/src/utils/cancellation.ts +34 -1
- package/src/utils/llm-usage.ts +8 -0
- package/src/utils/log-utils.ts +1 -1
- package/src/web-research/browser-search.ts +6 -5
- package/src/web-research/pdf-extract-worker.mjs +1289 -0
- package/src/web-research/pdf-extract-worker.ts +42 -0
- package/src/web-research/pdf-extraction.ts +392 -0
- package/src/web-research/web-scraper.ts +3 -111
|
@@ -23,12 +23,12 @@ import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
|
23
23
|
import { normalizeCitations } from '../utils/citation-utils.ts';
|
|
24
24
|
import { lastCitedLinksHeader } from '../utils/text-utils.ts';
|
|
25
25
|
import { repairJsonWithLlm } from './llm/agentic-repair.ts';
|
|
26
|
-
import { buildSafeOptions, validateAndExtractText } from './llm/llm-utils.ts';
|
|
26
|
+
import { buildSafeOptions, validateAndExtractText, buildConstrainedSubmitTool, completeSimpleStructured } from './llm/llm-utils.ts';
|
|
27
27
|
import { safeGetApiKeyAndHeaders } from './llm/model-registry-factory.ts';
|
|
28
28
|
import { withTimeout } from './llm/llm-timeout.ts';
|
|
29
29
|
import { retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
30
30
|
import { getConfig, type Config } from '../config.ts';
|
|
31
|
-
import type { Model } from '@earendil-works/pi-ai';
|
|
31
|
+
import type { Model, Tool } from '@earendil-works/pi-ai';
|
|
32
32
|
import { DEFAULT_MODEL_CONTEXT_WINDOW } from '../constants.ts';
|
|
33
33
|
import { deriveCoverageDigest, formatDigestsForRouter } from '../utils/coverage-digest.ts';
|
|
34
34
|
import {
|
|
@@ -182,8 +182,11 @@ export function partitionCorpus<T>(
|
|
|
182
182
|
* leave the prior (still-running) stream orphaned and billed, and would multiply a multi-minute
|
|
183
183
|
* wait. A timeout instead degrades straight to the fallback plan (see the catch blocks, which
|
|
184
184
|
* treat "timed out" as degradable). A deliberate cancellation is never retried — detected by
|
|
185
|
-
* AbortSignal state / AbortError name, not by the message
|
|
186
|
-
*
|
|
185
|
+
* AbortSignal state / AbortError name, not by the message: the withTimeout imported here is
|
|
186
|
+
* llm-timeout's, whose timeout rejection reads "LLM call timed out after Nms (label)" while a
|
|
187
|
+
* user abort surfaces as the underlying fetch's AbortError — two DIFFERENT shapes, unlike the
|
|
188
|
+
* same-named withTimeout in web-research/retry-utils.ts (whose wording this comment used to
|
|
189
|
+
* describe). Rename candidate if these two helpers ever converge.
|
|
187
190
|
*
|
|
188
191
|
* Exported for direct unit testing of the abort/timeout guards.
|
|
189
192
|
*/
|
|
@@ -419,6 +422,18 @@ export class PlanningService implements IPlanningService {
|
|
|
419
422
|
|
|
420
423
|
const userMessage = `Generate the initial research plan for: "${query}"`;
|
|
421
424
|
|
|
425
|
+
// Structured-output path: offer a schema-constrained submit tool (pi-ai
|
|
426
|
+
// constrainedSampling, strict 'prefer'). Where the provider supports strict
|
|
427
|
+
// json_schema the plan arrives schema-exact with no parse/repair round-trip;
|
|
428
|
+
// everywhere else it degrades to a plain tool call, and a text answer flows
|
|
429
|
+
// into the existing parse/repair pipeline unchanged — never worse than today.
|
|
430
|
+
const submitPlanTool = buildConstrainedSubmitTool(
|
|
431
|
+
'submit_plan',
|
|
432
|
+
'Submit the complete research plan as structured arguments.',
|
|
433
|
+
PlanningResponseSchemaAsTSchema,
|
|
434
|
+
);
|
|
435
|
+
const toolInstruction = `\n\nRespond by calling the ${submitPlanTool.name} tool with the plan as its arguments.`;
|
|
436
|
+
|
|
422
437
|
try {
|
|
423
438
|
const authResult = await safeGetApiKeyAndHeaders(modelRegistry, model);
|
|
424
439
|
if (!authResult.ok) {
|
|
@@ -434,21 +449,27 @@ export class PlanningService implements IPlanningService {
|
|
|
434
449
|
// malformed JSON (a different failure) and would otherwise be re-paid on every attempt.
|
|
435
450
|
const { response, responseText } = await retryWithBackoff(
|
|
436
451
|
async () => {
|
|
437
|
-
const
|
|
438
|
-
|
|
452
|
+
const structured = await withTimeout(
|
|
453
|
+
completeSimpleStructured(model, {
|
|
439
454
|
systemPrompt: populatedPrompt,
|
|
440
455
|
messages: [
|
|
441
|
-
{ role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
|
|
456
|
+
{ role: 'user', content: [{ type: 'text', text: userMessage + toolInstruction }], timestamp: Date.now() },
|
|
442
457
|
],
|
|
443
|
-
}, buildSafeOptions(model, {
|
|
458
|
+
}, submitPlanTool, buildSafeOptions(model, {
|
|
444
459
|
apiKey: authResult.apiKey || '',
|
|
445
460
|
headers: authResult.headers,
|
|
446
461
|
signal,
|
|
447
462
|
sessionId,
|
|
448
|
-
}, config.PLANNING_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
|
|
463
|
+
}, config.PLANNING_MAX_TOKENS, config.LLM_THINKING_LEVEL), 'Coordinator'),
|
|
449
464
|
llmTimeout, 'coordinator-generatePlan',
|
|
450
465
|
);
|
|
451
|
-
const
|
|
466
|
+
const response = structured.response;
|
|
467
|
+
// A tool call's arguments are already an object: re-serialize so they
|
|
468
|
+
// flow through the SAME parse → coerce → validate (→ repair) pipeline;
|
|
469
|
+
// on a text answer this is byte-identical to the previous behavior.
|
|
470
|
+
const responseText = structured.kind === 'toolCall'
|
|
471
|
+
? JSON.stringify(structured.args)
|
|
472
|
+
: structured.text;
|
|
452
473
|
return { response, responseText };
|
|
453
474
|
},
|
|
454
475
|
{
|
|
@@ -464,12 +485,30 @@ export class PlanningService implements IPlanningService {
|
|
|
464
485
|
// Track usage once, on the successful attempt (failed attempts throw before here).
|
|
465
486
|
recordLlmUsage(model, (response as any).usage, { component: 'coordinator', complexity, observer });
|
|
466
487
|
|
|
488
|
+
// Truncation is the #1 cause of a malformed plan: a response that hit the
|
|
489
|
+
// output-token cap ends mid-JSON no matter how well it started. Log the cap
|
|
490
|
+
// hit here so the malformed-JSON warning below is diagnosable from the log
|
|
491
|
+
// (stopReason 'length' is otherwise dropped — validateAndExtractText only
|
|
492
|
+
// inspects 'error').
|
|
493
|
+
if (response.stopReason === 'length') {
|
|
494
|
+
logger.warn(
|
|
495
|
+
`[PlanningService] Plan response hit the output-token cap (stopReason 'length', maxTokens ${config.PLANNING_MAX_TOKENS}); output is likely truncated mid-JSON`
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
467
499
|
// Extract and validate JSON
|
|
468
500
|
let plan: ResearchPlan | null = null;
|
|
469
501
|
try {
|
|
470
502
|
plan = this.parseJsonPlan(responseText);
|
|
471
|
-
} catch {
|
|
472
|
-
|
|
503
|
+
} catch (err) {
|
|
504
|
+
// The thrown error carries the parse/validation detail and a head preview;
|
|
505
|
+
// stopReason + the response TAIL are the missing forensics — truncation leaves
|
|
506
|
+
// the JSON dangling at the END of the text, which no head preview shows.
|
|
507
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
508
|
+
logger.warn(
|
|
509
|
+
`[PlanningService] Initial plan JSON malformed (stopReason '${response.stopReason}'), attempting agentic repair. ` +
|
|
510
|
+
`Parse error: ${reason}. Response tail: ${JSON.stringify(responseText.slice(-160))}`
|
|
511
|
+
);
|
|
473
512
|
const repaired = await repairJsonWithLlm<ResearchPlan>(
|
|
474
513
|
responseText,
|
|
475
514
|
completeSimple,
|
|
@@ -483,6 +522,7 @@ export class PlanningService implements IPlanningService {
|
|
|
483
522
|
sessionId,
|
|
484
523
|
maxTokens: config.PLANNING_MAX_TOKENS,
|
|
485
524
|
thinkingLevel: config.LLM_THINKING_LEVEL,
|
|
525
|
+
timeoutMs: config.LLM_TIMEOUT_MS,
|
|
486
526
|
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'coordinator', complexity, observer }),
|
|
487
527
|
}
|
|
488
528
|
);
|
|
@@ -733,6 +773,17 @@ export class PlanningService implements IPlanningService {
|
|
|
733
773
|
const responseText = isRouter
|
|
734
774
|
? await (() => {
|
|
735
775
|
const routerMessage = buildRouterMessage(...this.buildRouterEvidence(options, round), runContext);
|
|
776
|
+
// Same structured-output pattern as generatePlan: the router's output
|
|
777
|
+
// is a structured JSON delegate plan (validated by the same
|
|
778
|
+
// parseJsonPlan), so offer the constrained submit tool and fall back
|
|
779
|
+
// to the text pipeline when the model answers in prose.
|
|
780
|
+
const submitRoutingTool = buildConstrainedSubmitTool(
|
|
781
|
+
'submit_routing_decision',
|
|
782
|
+
// Audit LOW: this description is offered verbatim on EVERY delegation
|
|
783
|
+
// round — the hard-coded "round-2" misdescribed rounds 3, 4, … N.
|
|
784
|
+
`Submit the updated round-${round} routing plan as structured arguments.`,
|
|
785
|
+
PlanningResponseSchemaAsTSchema,
|
|
786
|
+
);
|
|
736
787
|
// The router carries no reduce mechanism the way synthesizeCorpus does — a
|
|
737
788
|
// routing decision made from PARTITIONED evidence isn't naturally mergeable
|
|
738
789
|
// the way synthesis prose is, and this call's output is a structured JSON
|
|
@@ -757,10 +808,11 @@ export class PlanningService implements IPlanningService {
|
|
|
757
808
|
return this.callLead({
|
|
758
809
|
...callOptions,
|
|
759
810
|
systemPrompt: populate(''),
|
|
760
|
-
userMessage: routerMessage
|
|
811
|
+
userMessage: routerMessage + `\n\nRespond by calling the ${submitRoutingTool.name} tool with the updated plan as its arguments.`,
|
|
761
812
|
maxTokens: ROUTER_MAX_TOKENS,
|
|
762
813
|
component: 'router',
|
|
763
814
|
label: 'router-updatePlanForRound',
|
|
815
|
+
submitTool: submitRoutingTool,
|
|
764
816
|
});
|
|
765
817
|
})()
|
|
766
818
|
: await this.synthesizeCorpus({ ...callOptions, reports, runContext, populate });
|
|
@@ -769,8 +821,15 @@ export class PlanningService implements IPlanningService {
|
|
|
769
821
|
let plan: ResearchPlan | null = null;
|
|
770
822
|
try {
|
|
771
823
|
plan = this.parseJsonPlan(responseText);
|
|
772
|
-
} catch {
|
|
773
|
-
|
|
824
|
+
} catch (err) {
|
|
825
|
+
// Same forensics as the initial-plan site; the response object itself is not
|
|
826
|
+
// in scope here (callLead/synthesizeCorpus return text only), but callLead
|
|
827
|
+
// already warned if that call hit its output-token cap.
|
|
828
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
829
|
+
logger.warn(
|
|
830
|
+
`[PlanningService] ${role} JSON malformed, attempting agentic repair. ` +
|
|
831
|
+
`Parse error: ${reason}. Response tail: ${JSON.stringify(responseText.slice(-160))}`
|
|
832
|
+
);
|
|
774
833
|
const repaired = await repairJsonWithLlm<ResearchPlan>(
|
|
775
834
|
responseText,
|
|
776
835
|
completeSimple,
|
|
@@ -784,6 +843,7 @@ export class PlanningService implements IPlanningService {
|
|
|
784
843
|
sessionId,
|
|
785
844
|
maxTokens: isRouter ? ROUTER_MAX_TOKENS : config.SYNTHESIS_MAX_TOKENS,
|
|
786
845
|
thinkingLevel: config.LLM_THINKING_LEVEL,
|
|
846
|
+
timeoutMs: config.LLM_TIMEOUT_MS,
|
|
787
847
|
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: role, complexity, observer }),
|
|
788
848
|
}
|
|
789
849
|
);
|
|
@@ -1024,11 +1084,40 @@ export class PlanningService implements IPlanningService {
|
|
|
1024
1084
|
maxTokens: number;
|
|
1025
1085
|
component: string;
|
|
1026
1086
|
label: string;
|
|
1087
|
+
/** Optional structured-output tool; when set the call offers it and stringifies tool-call args as the text. */
|
|
1088
|
+
submitTool?: Tool;
|
|
1027
1089
|
}): Promise<string> {
|
|
1028
1090
|
const { model, signal, config, complexity, observer, label } = args;
|
|
1029
1091
|
|
|
1030
1092
|
const { response, responseText } = await retryWithBackoff(
|
|
1031
1093
|
async () => {
|
|
1094
|
+
// Structured path: offer the caller's submit tool (constrained where the
|
|
1095
|
+
// provider supports it) and unify both outcomes into the text contract
|
|
1096
|
+
// this method has always had — tool-call args re-serialized through the
|
|
1097
|
+
// SAME downstream parse/validate/repair, text passed through untouched.
|
|
1098
|
+
if (args.submitTool) {
|
|
1099
|
+
const structured = await withTimeout(
|
|
1100
|
+
completeSimpleStructured(model, {
|
|
1101
|
+
systemPrompt: args.systemPrompt,
|
|
1102
|
+
messages: [
|
|
1103
|
+
{ role: 'user', content: [{ type: 'text', text: args.userMessage }], timestamp: Date.now() },
|
|
1104
|
+
],
|
|
1105
|
+
}, args.submitTool, buildSafeOptions(model, {
|
|
1106
|
+
apiKey: args.apiKey,
|
|
1107
|
+
headers: args.headers,
|
|
1108
|
+
signal,
|
|
1109
|
+
sessionId: args.sessionId,
|
|
1110
|
+
}, args.maxTokens, config.LLM_THINKING_LEVEL), label),
|
|
1111
|
+
config.LLM_TIMEOUT_MS, label,
|
|
1112
|
+
);
|
|
1113
|
+
const responseText = structured.kind === 'toolCall'
|
|
1114
|
+
? JSON.stringify(structured.args)
|
|
1115
|
+
: structured.text;
|
|
1116
|
+
if (structured.response.stopReason === 'length') {
|
|
1117
|
+
logger.warn(`[PlanningService] ${label} response hit the output-token cap (stopReason 'length', maxTokens ${args.maxTokens}); output is likely truncated`);
|
|
1118
|
+
}
|
|
1119
|
+
return { response: structured.response, responseText };
|
|
1120
|
+
}
|
|
1032
1121
|
const response = await withTimeout(
|
|
1033
1122
|
completeSimple(model, {
|
|
1034
1123
|
systemPrompt: args.systemPrompt,
|
|
@@ -1044,6 +1133,12 @@ export class PlanningService implements IPlanningService {
|
|
|
1044
1133
|
config.LLM_TIMEOUT_MS, label,
|
|
1045
1134
|
);
|
|
1046
1135
|
const responseText = validateAndExtractText(response, label);
|
|
1136
|
+
// A cap hit here means the router decision / synthesis pass was cut mid-output
|
|
1137
|
+
// (often mid-JSON) — surface it so a downstream malformed-JSON warning has its
|
|
1138
|
+
// cause in the log instead of a silent stopReason drop.
|
|
1139
|
+
if (response.stopReason === 'length') {
|
|
1140
|
+
logger.warn(`[PlanningService] ${label} response hit the output-token cap (stopReason 'length', maxTokens ${args.maxTokens}); output is likely truncated`);
|
|
1141
|
+
}
|
|
1047
1142
|
return { response, responseText };
|
|
1048
1143
|
},
|
|
1049
1144
|
{
|
|
@@ -214,6 +214,9 @@ export function buildFallbackCoordinatorPlan(serviceName: string, _rawText: stri
|
|
|
214
214
|
logger.warn(`[${serviceName}] Coordinator fallback: single researcher for "${query.slice(0, 80)}"`);
|
|
215
215
|
return {
|
|
216
216
|
action: 'delegate',
|
|
217
|
+
// Marks this as engine-generated after unusable model output, so UI surfaces can
|
|
218
|
+
// say "degraded" instead of presenting one researcher as the model's considered plan.
|
|
219
|
+
fallback: true,
|
|
217
220
|
researchers: [{ id: '1', name: 'General Researcher', goal: `Research the following query comprehensively: ${query}`, queries }],
|
|
218
221
|
allQueries: queries,
|
|
219
222
|
};
|
|
@@ -114,9 +114,9 @@ export class ServiceContainer {
|
|
|
114
114
|
private services: Map<string, ServiceRegistration<any>> = new Map();
|
|
115
115
|
private dependencies: Map<string, Set<string>> = new Map();
|
|
116
116
|
public isDisposing: boolean = false;
|
|
117
|
-
// The single in-flight disposeAll() run
|
|
118
|
-
//
|
|
119
|
-
//
|
|
117
|
+
// The single in-flight disposeAll() run. Only reset() JOINS it (awaiting teardown
|
|
118
|
+
// rather than throwing mid-disposal — the Windows CI teardown fix); a concurrent
|
|
119
|
+
// second disposeAll() returns immediately without awaiting (see disposeAll).
|
|
120
120
|
private _disposalPromise: Promise<void> | null = null;
|
|
121
121
|
public isReady: boolean = false;
|
|
122
122
|
public cwd: string = process.cwd();
|
|
@@ -489,9 +489,11 @@ export class ServiceContainer {
|
|
|
489
489
|
async disposeAll(): Promise<void> {
|
|
490
490
|
// Synchronous re-entrancy guard: isDisposing is set as _runDisposeAll's first
|
|
491
491
|
// statement (before any await), so a service whose dispose() re-enters disposeAll()
|
|
492
|
-
// gets a safe no-op instead of recursing
|
|
493
|
-
//
|
|
494
|
-
//
|
|
492
|
+
// gets a safe no-op instead of recursing OR deadlocking (awaiting its own teardown).
|
|
493
|
+
// CONTRACT (verbatim, matches the code): a concurrent SECOND disposeAll() caller
|
|
494
|
+
// also returns immediately WITHOUT awaiting the in-flight teardown — only reset()
|
|
495
|
+
// joins it via _disposalPromise. Any caller that must observe "teardown complete"
|
|
496
|
+
// before acting should call reset(), or await container._disposalPromise explicitly.
|
|
495
497
|
if (this.isDisposing) return;
|
|
496
498
|
this._disposalPromise = this._runDisposeAll().finally(() => { this._disposalPromise = null; });
|
|
497
499
|
return this._disposalPromise;
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { createResearchTool, createHealthTool } from './tool.ts';
|
|
|
12
12
|
import { createResearchKnowledgeSearchTool } from './tools/research-knowledge-search.ts';
|
|
13
13
|
import { logger } from './logger.ts';
|
|
14
14
|
import { checkPiCompatibility } from './core/pi-version.ts';
|
|
15
|
+
import { checkPiAiSkew } from './core/pi-ai-skew.ts';
|
|
15
16
|
import { randomUUID } from 'node:crypto';
|
|
16
17
|
import { shutdownManager } from './utils/shutdown-manager.ts';
|
|
17
18
|
import { healthRegistry } from './healthcheck/index.ts';
|
|
@@ -114,6 +115,19 @@ export default async function (pi: ExtensionAPI) {
|
|
|
114
115
|
logger.warn(compat.message);
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// The gate above classifies the HOST version but cannot see the copies THIS
|
|
119
|
+
// extension resolves from its own node_modules — the 2026-08-30 incident
|
|
120
|
+
// (host 0.84.4 vs extension-resolved pi-ai 0.84.2) passed it as 'ok' while
|
|
121
|
+
// every researcher died at provider load. Detect that skew here and refuse
|
|
122
|
+
// to start with the actual remediation instead.
|
|
123
|
+
const skew = checkPiAiSkew();
|
|
124
|
+
if (skew.fatal) {
|
|
125
|
+
throw new Error(skew.message ?? '[pi-research] pi-ai version skew.');
|
|
126
|
+
}
|
|
127
|
+
if (skew.message) {
|
|
128
|
+
logger.warn(skew.message);
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
// Re-register the beforeExit safety net (deactivate() strips event listeners during reload).
|
|
118
132
|
// This ensures the ONNX pipeline is disposed even after extension reloads.
|
|
119
133
|
registerBeforeExitSafetyNet();
|
|
@@ -44,13 +44,12 @@ function getWindowsCamoufoxDir(): string {
|
|
|
44
44
|
* camoufox-js (getBrowserEnv, scripts/setup.cjs) so that install and lookup
|
|
45
45
|
* cannot disagree.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* that happens, treat a custom browser directory as unsupported.
|
|
47
|
+
* HISTORY: camoufox-js <0.12.0 hardcoded `userCacheDir("camoufox")` and
|
|
48
|
+
* honoured NEITHER variable, which made this plumbing deliberately inert for
|
|
49
|
+
* two pin cycles. The 0.12.0 refresh (2026-08-30) honours CAMOUFOX_INSTALL_DIR
|
|
50
|
+
* again, so a custom location now relocates the install itself, not just our
|
|
51
|
+
* lookup — the two can no longer disagree via version skew, but keep the dual
|
|
52
|
+
* export (install + lookup) so they can never drift apart in future.
|
|
54
53
|
*/
|
|
55
54
|
function getCustomCamoufoxDir(): string | undefined {
|
|
56
55
|
return process.env['CAMOUFOX_INSTALL_DIR'] || process.env['PLAYWRIGHT_BROWSERS_PATH'] || undefined;
|
|
@@ -27,12 +27,19 @@ import { tmpdir } from 'node:os';
|
|
|
27
27
|
import { logger } from '../../logger.ts';
|
|
28
28
|
import { getCamoufoxBinaryPath } from './config.ts';
|
|
29
29
|
|
|
30
|
-
/** Bound the ~
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
/** Bound the ~500MB camoufox-js 0.12 browser download (measured 484MB; the
|
|
31
|
+
* 0.10 pin was ~100MB and the old bound assumed that) so a stalled network
|
|
32
|
+
* fails eventually instead of hanging forever, while a slow-but-working link
|
|
33
|
+
* can actually finish. */
|
|
34
|
+
const FETCH_TIMEOUT_MS = 45 * 60 * 1000;
|
|
35
|
+
/** A lock older than this is considered stale (crashed mid-fetch) and may be stolen.
|
|
36
|
+
* Must exceed FETCH_TIMEOUT_MS: stealing a LIVE fetcher's lock mid-download would
|
|
37
|
+
* start a second 500MB download beside the first. */
|
|
38
|
+
const STALE_LOCK_MS = 55 * 60 * 1000;
|
|
39
|
+
/** When another process holds the fetch lock, poll this long for the browser to appear.
|
|
40
|
+
* Spans a full FETCH_TIMEOUT_MS fetch, so a legitimate slow peer is waited out rather
|
|
41
|
+
* than duplicated. */
|
|
42
|
+
const WAIT_FOR_PEER_MS = 50 * 60 * 1000;
|
|
36
43
|
const POLL_INTERVAL_MS = 2000;
|
|
37
44
|
|
|
38
45
|
/** In-process dedupe: concurrent pool inits / scrapers share one fetch. */
|
|
@@ -78,7 +85,7 @@ function runFetch(): Promise<void> {
|
|
|
78
85
|
return new Promise<void>((resolve, reject) => {
|
|
79
86
|
const bin = resolveCamoufoxBin();
|
|
80
87
|
const [cmd, args] = bin ? [bin, ['fetch']] : ['npx', ['camoufox-js', 'fetch']];
|
|
81
|
-
logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~
|
|
88
|
+
logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~500MB): ${cmd} ${args.join(' ')}`);
|
|
82
89
|
|
|
83
90
|
// Windows needs shell:true to run the .cmd shim (Node CVE-2024-27980 fix makes a
|
|
84
91
|
// shell-less spawn of .cmd throw EINVAL), but shell:true performs NO quoting, so a
|
|
@@ -136,18 +136,18 @@ export async function initBrowser(): Promise<void> {
|
|
|
136
136
|
// (consumers install without our lockfile, so a floating range resolves
|
|
137
137
|
// to a newer, broken version):
|
|
138
138
|
//
|
|
139
|
-
// • camoufox-js ^0.
|
|
140
|
-
//
|
|
141
|
-
// 2026-07-16;
|
|
142
|
-
// 0.13.1/0.14.0
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
139
|
+
// • camoufox-js ^0.12.0 (2026-08-30 refresh) → the 0.10.x pin survived
|
|
140
|
+
// two refresh cycles on three since-lapsed blockers (Windows assets
|
|
141
|
+
// returned in v152.0.4-beta.26, 2026-07-16; impit's `only-allow pnpm`
|
|
142
|
+
// guard existed only in 0.13.1/0.14.0; and better-sqlite3 13's changed
|
|
143
|
+
// install model). The refresh accepted better-sqlite3 13. MEASURED on
|
|
144
|
+
// 13.0.3: `prebuilds/` for all eight platform/arch pairs ship INSIDE
|
|
145
|
+
// the tarball and load at runtime via node-gyp-build — no install
|
|
146
|
+
// script, no toolchain needed on modern npm (≥11.19 skips the
|
|
147
|
+
// injected `node-gyp rebuild` that a binding.gyp without an install
|
|
148
|
+
// script triggers; it was that needless rebuild, not a missing
|
|
149
|
+
// binary, that broke toolchain-less Windows installs). A future bump
|
|
150
|
+
// must still check better-sqlite3 first, not camoufox.
|
|
151
151
|
// • playwright-core 1.60.0 (exact) → playwright-core and the camoufox
|
|
152
152
|
// binary are one matched Juggler-protocol pair. 1.61 added a
|
|
153
153
|
// viewport.isMobile field the build REJECTS ("property
|
|
@@ -155,13 +155,12 @@ export async function initBrowser(): Promise<void> {
|
|
|
155
155
|
// launch below. Corroborated upstream: camoufox-js 0.12.0 declares
|
|
156
156
|
// `peerDependencies: playwright-core "<1.61.0"`, the same bound this
|
|
157
157
|
// pin has held by hand.
|
|
158
|
-
// • impit 0.
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// don't propagate to installers of a published package.
|
|
158
|
+
// • impit 0.14.4 (exact, a direct dep though only camoufox-js uses it;
|
|
159
|
+
// 2026-08-30 refresh from 0.13.0) → kept EXACT because it is the only
|
|
160
|
+
// way to force a version for consumers: npm `overrides` don't
|
|
161
|
+
// propagate to installers of a published package, and impit's
|
|
162
|
+
// mid-stream `only-allow pnpm` guard incident (0.13.1/0.14.0) showed
|
|
163
|
+
// floating ranges breaking fresh global installs our lockfile masks.
|
|
165
164
|
//
|
|
166
165
|
// What is NOT pinned: the browser BINARY. `camoufox-js fetch` takes no
|
|
167
166
|
// version argument — it walks the GitHub releases newest-first and takes
|
package/src/knowledge/chunker.ts
CHANGED
|
@@ -40,6 +40,14 @@ export class Chunker {
|
|
|
40
40
|
let sentenceExtensions = 0;
|
|
41
41
|
let newlineExtensions = 0;
|
|
42
42
|
let spaceExtensions = 0;
|
|
43
|
+
// Running count of ``` fences in text[0..start) — fence parity for the
|
|
44
|
+
// code-block-atomicity check. Maintained incrementally at the bottom of
|
|
45
|
+
// the loop (each iteration scans only the span of text newly consumed);
|
|
46
|
+
// the previous text.slice(0, start) rescan per iteration made chunking
|
|
47
|
+
// O(n²): ~350MB of allocation+regex scanning for a 1MB document, on the
|
|
48
|
+
// ingest hot path, worst exactly on the largest pages.
|
|
49
|
+
let fencesBefore = 0;
|
|
50
|
+
let lastFenceScan = 0;
|
|
43
51
|
|
|
44
52
|
while (start < text.length) {
|
|
45
53
|
const iterationStart = start;
|
|
@@ -49,10 +57,9 @@ export class Chunker {
|
|
|
49
57
|
let slice = text.slice(start, end);
|
|
50
58
|
let extendedForCodeBlock = false;
|
|
51
59
|
|
|
52
|
-
// 0. Code blocks atomicity
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
const startsInCodeBlock = codeBlockMatchesBefore && codeBlockMatchesBefore.length % 2 !== 0;
|
|
60
|
+
// 0. Code blocks atomicity (fence parity from the running counter —
|
|
61
|
+
// see fencesBefore above).
|
|
62
|
+
const startsInCodeBlock = fencesBefore % 2 !== 0;
|
|
56
63
|
|
|
57
64
|
// Hard cap: never extend a chunk beyond 4x targetSize (min 2000 chars) for code blocks.
|
|
58
65
|
// A very large code block (e.g. a package compatibility table) can otherwise
|
|
@@ -232,6 +239,17 @@ export class Chunker {
|
|
|
232
239
|
}
|
|
233
240
|
}
|
|
234
241
|
}
|
|
242
|
+
|
|
243
|
+
// Advance the running fence counter over the span this iteration newly
|
|
244
|
+
// consumed. start only ever moves forward (start-side forward-progress
|
|
245
|
+
// guard), so scanning [lastFenceScan, start) once keeps fencesBefore
|
|
246
|
+
// exactly equal to the fence count in text[0..start) that the next
|
|
247
|
+
// iteration's parity check needs — amortized O(n) across the whole loop.
|
|
248
|
+
if (start > lastFenceScan) {
|
|
249
|
+
const newlyConsumed = text.slice(lastFenceScan, start).match(/```/g);
|
|
250
|
+
if (newlyConsumed) fencesBefore += newlyConsumed.length;
|
|
251
|
+
lastFenceScan = start;
|
|
252
|
+
}
|
|
235
253
|
}
|
|
236
254
|
|
|
237
255
|
const duration = Date.now() - startTime;
|
|
@@ -208,6 +208,40 @@ export async function isModelCached(model: string): Promise<boolean> {
|
|
|
208
208
|
|
|
209
209
|
const entries = await readdir(onnxDir).catch(() => [] as string[]);
|
|
210
210
|
|
|
211
|
+
// External-weights integrity: transformers.js exports most embedding models as a
|
|
212
|
+
// small graph stub (model.onnx) that references its real weights in a SIBLING
|
|
213
|
+
// model.onnx_data. An interrupted download can leave EXACTLY the stub without the
|
|
214
|
+
// weights — every check above passes (the stub is a valid protobuf, config and
|
|
215
|
+
// tokenizer are present), so this function reported "cached", the loader ran with
|
|
216
|
+
// allowRemoteModels=false, and transformers failed with "model.onnx_data ... not
|
|
217
|
+
// found locally" — an error isCorruptModelError did not classify, so no purge and
|
|
218
|
+
// no re-download: init retried into the same wall until the caller's budget died.
|
|
219
|
+
// Detect the reference in the graph bytes and require the weights file when it is
|
|
220
|
+
// present. The scan is bounded to 1MB: a self-contained (internal-weights) model.onnx
|
|
221
|
+
// is far larger than that, and the reference string lives in the small external-data
|
|
222
|
+
// layout's protobuf.
|
|
223
|
+
const onnxPath = path.default.join(onnxDir, 'model.onnx');
|
|
224
|
+
const onnxStat = await stat(onnxPath).catch(() => null);
|
|
225
|
+
if (onnxStat && onnxStat.size > 0 && onnxStat.size <= 1024 * 1024) {
|
|
226
|
+
try {
|
|
227
|
+
const handle = await import('node:fs/promises');
|
|
228
|
+
const fh = await handle.default.open(onnxPath, 'r');
|
|
229
|
+
try {
|
|
230
|
+
const { buffer, bytesRead } = await fh.read(Buffer.alloc(Math.min(onnxStat.size, 1024 * 1024)), 0, Math.min(onnxStat.size, 1024 * 1024), 0);
|
|
231
|
+
const referencesExternalWeights = buffer.subarray(0, bytesRead).includes('model.onnx_data');
|
|
232
|
+
if (referencesExternalWeights && !entries.includes('model.onnx_data')) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
await fh.close();
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
// A read failure here is NOT evidence of a poisoned cache — treat the
|
|
240
|
+
// verdict as inconclusive and fall through to the historical checks
|
|
241
|
+
// (the loader-side corrupt classification remains the backstop).
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
211
245
|
// If an external weights file is listed, it must be non-empty — an interrupted
|
|
212
246
|
// download can leave a zero-length model.onnx_data that crashes the ONNX loader.
|
|
213
247
|
const hasExternalWeights = entries.includes('model.onnx_data');
|
|
@@ -259,7 +293,13 @@ export function isCorruptModelError(err: unknown): boolean {
|
|
|
259
293
|
msg.includes('deserialize') ||
|
|
260
294
|
msg.includes('file_length') ||
|
|
261
295
|
msg.includes('protobuf parsing failed') ||
|
|
262
|
-
msg.includes('invalid protobuf')
|
|
296
|
+
msg.includes('invalid protobuf') ||
|
|
297
|
+
// transformers.js local-file-missing: the cache LOOKED complete to isModelCached
|
|
298
|
+
// but a referenced file (typically external weights, model.onnx_data) is absent —
|
|
299
|
+
// an interrupted download. Classifying it here routes to purge + one remote
|
|
300
|
+
// re-download instead of init retrying into the same wall.
|
|
301
|
+
msg.includes('not found locally') ||
|
|
302
|
+
msg.includes('local_files_only=true')
|
|
263
303
|
);
|
|
264
304
|
}
|
|
265
305
|
|
package/src/knowledge/store.ts
CHANGED
|
@@ -87,6 +87,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
87
87
|
private manifestPath: string;
|
|
88
88
|
private isClosing = false;
|
|
89
89
|
private activeWrites = new Set<Promise<any>>();
|
|
90
|
+
// Last recorded model/schema migration (persisted in the store manifest).
|
|
91
|
+
// Compared against the incoming migration target to detect alternating-model
|
|
92
|
+
// thrash against a shared dbDir (see the alternation warn in openInternal).
|
|
93
|
+
private lastMigration: { model: string; ts: number } | null = null;
|
|
94
|
+
// The migration BEFORE lastMigration — detects a RETURN to an earlier model
|
|
95
|
+
// (A→B→A, the true ping-pong signature) while leaving a deliberate onward
|
|
96
|
+
// change (A→B→C) quiet. Persisted alongside lastMigration.
|
|
97
|
+
private prevMigration: { model: string; ts: number } | null = null;
|
|
90
98
|
// LanceDB table version at the last successful FTS rebuild. rebuildFtsIndex() skips
|
|
91
99
|
// when the version is unchanged: a research run that committed no new transactions
|
|
92
100
|
// would otherwise create a fresh FTS index (and a new table version) on every cleanup,
|
|
@@ -141,6 +149,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
141
149
|
this.tableName = manifest.activeTableName;
|
|
142
150
|
logger.debug(`[store] Loaded active table name from manifest: ${this.tableName}`);
|
|
143
151
|
}
|
|
152
|
+
if (manifest.lastMigration && typeof manifest.lastMigration.model === 'string') {
|
|
153
|
+
this.lastMigration = { model: manifest.lastMigration.model, ts: Number(manifest.lastMigration.ts) || 0 };
|
|
154
|
+
}
|
|
155
|
+
if (manifest.prevMigration && typeof manifest.prevMigration.model === 'string') {
|
|
156
|
+
this.prevMigration = { model: manifest.prevMigration.model, ts: Number(manifest.prevMigration.ts) || 0 };
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
} catch (err) {
|
|
146
160
|
logger.warn('[store] Failed to load manifest, using default table name:', err);
|
|
@@ -151,7 +165,16 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
151
165
|
private async saveManifest(): Promise<boolean> {
|
|
152
166
|
const tempPath = `${this.manifestPath}.tmp`;
|
|
153
167
|
try {
|
|
154
|
-
const content = JSON.stringify({
|
|
168
|
+
const content = JSON.stringify({
|
|
169
|
+
activeTableName: this.tableName,
|
|
170
|
+
// Record of the two most recent model/schema migrations. Read back on open
|
|
171
|
+
// to detect ALTERNATION (two configurations sharing this dbDir each
|
|
172
|
+
// migrating the table back and forth) — previously that thrash was
|
|
173
|
+
// invisible outside a per-open WARN line, while every alternation
|
|
174
|
+
// served an empty store.
|
|
175
|
+
lastMigration: this.lastMigration,
|
|
176
|
+
prevMigration: this.prevMigration,
|
|
177
|
+
}, null, 2);
|
|
155
178
|
await fsPromises.writeFile(tempPath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
156
179
|
await fsPromises.rename(tempPath, this.manifestPath);
|
|
157
180
|
return true;
|
|
@@ -166,6 +189,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
166
189
|
|
|
167
190
|
async open(): Promise<void> {
|
|
168
191
|
if (this.db) return;
|
|
192
|
+
// close() is irreversible in this class (isClosing latches the operation
|
|
193
|
+
// no-op path), so an open() after it must not "succeed" and hand back a
|
|
194
|
+
// zombie whose every operation silently no-ops — the service layer always
|
|
195
|
+
// constructs FRESH components after dispose, and this makes any deviation
|
|
196
|
+
// fail loud at the call site instead of as mysterious empty results.
|
|
197
|
+
if (this.isClosing) {
|
|
198
|
+
throw new Error('KnowledgeStore.open() called after close() — construct a fresh store instead.');
|
|
199
|
+
}
|
|
169
200
|
|
|
170
201
|
// Coalesce concurrent opens in-process. withLock (when provided) is a
|
|
171
202
|
// cross-process FILE lock, not an in-process mutex — and when it's absent there
|
|
@@ -286,6 +317,42 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
286
317
|
throw new Error(errorMsg, { cause: err });
|
|
287
318
|
}
|
|
288
319
|
}
|
|
320
|
+
|
|
321
|
+
// Alternation detector: the PREVIOUS migration moved this shared dbDir
|
|
322
|
+
// to a model that is NOT the one being migrated to now — the A→B→A
|
|
323
|
+
// thrash signature of two configurations (EMBEDDING_MODEL is
|
|
324
|
+
// machine-scoped; one shell with an override is enough) fighting over
|
|
325
|
+
// one table. Each alternation serves an EMPTY store and leans on the
|
|
326
|
+
// newest backup for the other model's data; make that LOUD and
|
|
327
|
+
// countable instead of a per-open WARN nobody correlates.
|
|
328
|
+
const prev = this.lastMigration;
|
|
329
|
+
const twoAgo = this.prevMigration;
|
|
330
|
+
// Thrash = RETURN to the model from two migrations ago (A→B→A):
|
|
331
|
+
// the ping-pong of two configurations sharing this dbDir. A first
|
|
332
|
+
// migration (no history) and a deliberate onward change (A→B→C)
|
|
333
|
+
// stay quiet; with two configs fighting, every leg after the first
|
|
334
|
+
// round-trip IS a bounce-back, so the real case still fires on
|
|
335
|
+
// every subsequent leg. (The prev.model check is kept even though a
|
|
336
|
+
// real migration to a different target implies it — defense in
|
|
337
|
+
// depth against a hand-edited manifest.)
|
|
338
|
+
const isThrash =
|
|
339
|
+
prev !== null && prev.model !== this.options.modelName &&
|
|
340
|
+
twoAgo !== null && twoAgo.model === this.options.modelName;
|
|
341
|
+
if (isThrash) {
|
|
342
|
+
const ageHours = Math.max(0, Math.round((Date.now() - twoAgo!.ts) / 3_600_000));
|
|
343
|
+
logger.warn(
|
|
344
|
+
`[store] MODEL ALTERNATION on the shared table: '${this.options.modelName}' was already here ~${ageHours}h ago ` +
|
|
345
|
+
`(migrations: '${twoAgo!.model}' → '${prev!.model}' → back to '${this.options.modelName}'). ` +
|
|
346
|
+
`Two configurations sharing this dbDir are thrashing it — each open serves an empty store until re-ingest. ` +
|
|
347
|
+
`Fix the EMBEDDING_MODEL drift, or give each model its own dbDir.`,
|
|
348
|
+
);
|
|
349
|
+
metrics.increment('knowledge_store_model_migration_total', 1, { alternation: 'true' });
|
|
350
|
+
} else {
|
|
351
|
+
metrics.increment('knowledge_store_model_migration_total', 1, { alternation: 'false' });
|
|
352
|
+
}
|
|
353
|
+
this.prevMigration = prev;
|
|
354
|
+
this.lastMigration = { model: this.options.modelName, ts: Date.now() };
|
|
355
|
+
// Persisted by the saveManifest() later in openInternal.
|
|
289
356
|
}
|
|
290
357
|
} catch (schemaErr) {
|
|
291
358
|
// A migration failure is fatal — re-throw so the caller learns the store
|
|
@@ -158,7 +158,10 @@ export function createResearchObserver(
|
|
|
158
158
|
},
|
|
159
159
|
|
|
160
160
|
onPlanningSuccess: (plan) => {
|
|
161
|
-
|
|
161
|
+
// A fallback plan means the model's planning output was unusable and the engine
|
|
162
|
+
// degraded to a single researcher — the user asked for deep research and got a
|
|
163
|
+
// shallow one. Say so in the status line instead of presenting it as 'ready'.
|
|
164
|
+
updateSliceStatus(panelState, 'coord', plan.fallback ? 'fallback — planning failed' : 'ready', debouncedRefresh);
|
|
162
165
|
completeSlice(panelState, 'coord');
|
|
163
166
|
const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
164
167
|
const count = plan.researchers?.length || 0;
|