@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.
Files changed (63) hide show
  1. package/README.md +13 -9
  2. package/agent-skill/pi-research/README.md +5 -4
  3. package/agent-skill/pi-research/SKILL.md +1 -1
  4. package/dist/cli.mjs +587 -149
  5. package/dist/pdf-extract-worker.mjs +1289 -0
  6. package/dist/prompts/research-tool-usage.md +2 -1
  7. package/dist/prompts/researcher.md +1 -1
  8. package/dist/prompts/system-coordinator.md +2 -2
  9. package/dist/prompts/system-lead-router.md +1 -1
  10. package/docs/AGENT-SKILL.md +6 -6
  11. package/docs/ARCHITECTURE.md +24 -22
  12. package/docs/CHANGELOG.md +54 -0
  13. package/docs/CONFIGURATION.md +3 -2
  14. package/docs/SDK.md +7 -12
  15. package/package.json +7 -4
  16. package/scripts/build.cjs +35 -0
  17. package/scripts/setup.cjs +12 -10
  18. package/src/cleanup/research-cleanup.ts +11 -5
  19. package/src/cli.ts +7 -5
  20. package/src/config.ts +9 -3
  21. package/src/core/interfaces/knowledge-interfaces.ts +8 -0
  22. package/src/core/interfaces/orchestration-interfaces.ts +1 -1
  23. package/src/core/interfaces/research-plan-types.ts +6 -0
  24. package/src/core/llm/agentic-repair.ts +40 -3
  25. package/src/core/llm/llm-utils.ts +93 -5
  26. package/src/core/llm/model-registry-factory.ts +2 -15
  27. package/src/core/pi-ai-skew.ts +351 -0
  28. package/src/core/pi-version.ts +1 -1
  29. package/src/core/planning-service.ts +110 -15
  30. package/src/core/planning-utils.ts +3 -0
  31. package/src/core/service-registry.ts +8 -6
  32. package/src/index.ts +14 -0
  33. package/src/infrastructure/browser/config.ts +6 -7
  34. package/src/infrastructure/browser/ensure-browser.ts +14 -7
  35. package/src/infrastructure/browser/thread-worker-browser.ts +18 -19
  36. package/src/knowledge/chunker.ts +22 -4
  37. package/src/knowledge/embedder-init.ts +41 -1
  38. package/src/knowledge/store.ts +68 -1
  39. package/src/observers/research-observer-impl.ts +4 -1
  40. package/src/orchestration/deep-research-orchestrator.ts +3 -3
  41. package/src/orchestration/headless-observer.ts +33 -0
  42. package/src/orchestration/quick-research-orchestrator.ts +17 -3
  43. package/src/orchestration/research-orchestration-service.ts +27 -4
  44. package/src/orchestration/session-state.ts +36 -7
  45. package/src/prompts/research-tool-usage.md +2 -1
  46. package/src/prompts/researcher.md +1 -1
  47. package/src/prompts/system-coordinator.md +2 -2
  48. package/src/prompts/system-lead-router.md +1 -1
  49. package/src/sdk.ts +12 -4
  50. package/src/tools/research-knowledge-search.ts +23 -7
  51. package/src/tools/research-tool-definition.ts +1 -1
  52. package/src/tools/scrape.ts +12 -9
  53. package/src/tools/search.ts +12 -6
  54. package/src/tools/security.ts +6 -1
  55. package/src/tools/stackexchange.ts +6 -1
  56. package/src/utils/cancellation.ts +34 -1
  57. package/src/utils/llm-usage.ts +8 -0
  58. package/src/utils/log-utils.ts +1 -1
  59. package/src/web-research/browser-search.ts +6 -5
  60. package/src/web-research/pdf-extract-worker.mjs +1289 -0
  61. package/src/web-research/pdf-extract-worker.ts +42 -0
  62. package/src/web-research/pdf-extraction.ts +392 -0
  63. 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 (withTimeout emits the same "cancelled
186
- * or timed out" wording for a user-abort and a real timeout, and abortableDelay is the backstop).
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 response = await withTimeout(
438
- completeSimple(model, {
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 responseText = validateAndExtractText(response, 'Coordinator');
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
- logger.warn('[PlanningService] Initial plan JSON malformed, attempting agentic repair');
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
- logger.warn(`[PlanningService] ${role} JSON malformed, attempting agentic repair`);
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, so concurrent disposeAll()/reset() calls
118
- // coalesce onto (and AWAIT) it rather than a second caller observing "disposed" while
119
- // teardown is still running, or reset() throwing mid-disposal.
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. Also publish the in-flight run as
493
- // _disposalPromise so reset() (a separate, non-re-entrant caller) can AWAIT the
494
- // teardown instead of throwing on the race — the Windows CI teardown fix.
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
- * CAVEAT, deliberate: the pinned camoufox-js (0.10.x — see
48
- * thread-worker-browser.ts for why it cannot move yet) hardcodes
49
- * `userCacheDir("camoufox")` and honours NEITHER variable, so on the shipped
50
- * pin a custom location still only relocates lookup and the underlying bug
51
- * remains. This plumbing is correct and inert rather than wrong: it starts
52
- * working the moment the pin can advance, and costs nothing until then. Until
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 ~100MB download so a stalled network fails fast instead of hanging forever. */
31
- const FETCH_TIMEOUT_MS = 15 * 60 * 1000;
32
- /** A lock older than this is considered stale (crashed mid-fetch) and may be stolen. */
33
- const STALE_LOCK_MS = 20 * 60 * 1000;
34
- /** When another process holds the fetch lock, poll this long for the browser to appear. */
35
- const WAIT_FOR_PEER_MS = 16 * 60 * 1000;
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, ~100MB): ${cmd} ${args.join(' ')}`);
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.10.2 → the two ORIGINAL reasons for this pin have
140
- // both lapsed (camoufox restored Windows assets in v152.0.4-beta.26,
141
- // 2026-07-16; and impit's `only-allow pnpm` guard existed only in
142
- // 0.13.1/0.14.0), but a THIRD, worse one replaced them. 0.12.0
143
- // requires `better-sqlite3 ^13.0.1`, and better-sqlite3 13 DROPPED
144
- // PREBUILT BINARIES: 12.x installs via `prebuild-install || node-gyp
145
- // rebuild`, 13.x has no install script at all, so npm falls back to
146
- // auto-running `node-gyp rebuild` for its binding.gyp and every
147
- // install needs a full C++ toolchain. Measured: `npm ci` fails
148
- // outright on the Windows CI runner ("could not find a version of
149
- // Visual Studio 2017 or newer"), which is most Windows consumers.
150
- // Upgrading again means checking better-sqlite3 first, not camoufox.
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.13.0 (exact, a direct dep though only camoufox-js uses it)
159
- // → camoufox-js 0.10.2 requires `impit ^0.13.0`, and within that range
160
- // 0.13.0 is the only version free of the `preinstall: npx only-allow
161
- // pnpm` guard (an upstream mistake in 0.13.1/0.14.0, dropped again in
162
- // 0.14.1) that hard-fails `npm install -g`. The exact pin is also the
163
- // ONLY way to force a version for consumers, since npm `overrides`
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
@@ -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
- const textBefore = text.slice(0, start);
54
- const codeBlockMatchesBefore = textBefore.match(/```/g);
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
 
@@ -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({ activeTableName: this.tableName }, null, 2);
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
- updateSliceStatus(panelState, 'coord', 'ready', debouncedRefresh);
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;