@lincoln504/pi-research 1.3.12 → 1.4.3

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 (86) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5670 -4705
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-lead-router.md +122 -0
  6. package/dist/prompts/system-lead-synthesizer.md +46 -0
  7. package/dist/thread-worker.mjs +169 -62
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +225 -0
  10. package/docs/CONFIGURATION.md +106 -7
  11. package/package.json +2 -2
  12. package/scripts/build.cjs +8 -1
  13. package/skills/pi-research/SKILL.md +1 -1
  14. package/src/cli.ts +5 -1
  15. package/src/config.ts +22 -12
  16. package/src/constants.ts +1 -1
  17. package/src/core/interfaces/observer-interfaces.ts +3 -3
  18. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  19. package/src/core/interfaces/planning-interfaces.ts +7 -1
  20. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  21. package/src/core/llm/agentic-repair.ts +7 -2
  22. package/src/core/llm/inject-date.ts +4 -1
  23. package/src/core/llm/llm-utils.ts +7 -0
  24. package/src/core/planning-service.ts +766 -91
  25. package/src/core/planning-utils.ts +21 -5
  26. package/src/core/service-initialization.ts +19 -3
  27. package/src/healthcheck/index.ts +11 -2
  28. package/src/index.ts +4 -3
  29. package/src/infrastructure/browser/browser-client.ts +68 -34
  30. package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
  31. package/src/infrastructure/browser/config.ts +79 -1
  32. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  33. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  34. package/src/infrastructure/browser/task-execution-service.ts +5 -5
  35. package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
  37. package/src/infrastructure/browser/thread-worker.mjs +169 -62
  38. package/src/infrastructure/browser/thread-worker.ts +13 -5
  39. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  40. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  41. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  42. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  43. package/src/infrastructure/file-lock-service.ts +214 -19
  44. package/src/infrastructure/knowledge-store-service.ts +14 -0
  45. package/src/infrastructure/research-run-semaphore.ts +1 -1
  46. package/src/infrastructure/service-initialization.ts +7 -0
  47. package/src/knowledge/embedder-init.ts +46 -19
  48. package/src/knowledge/embedder.ts +11 -3
  49. package/src/knowledge/index.ts +36 -10
  50. package/src/knowledge/store.ts +29 -5
  51. package/src/logger.ts +11 -0
  52. package/src/orchestration/deep-research-orchestrator.ts +143 -87
  53. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  54. package/src/orchestration/research-orchestration-service.ts +8 -0
  55. package/src/orchestration/research-synthesis-service.ts +53 -1
  56. package/src/orchestration/researcher-executor.ts +53 -2
  57. package/src/orchestration/researcher.ts +15 -2
  58. package/src/orchestration/session-state.ts +16 -0
  59. package/src/prompts/research-tool-usage.md +1 -1
  60. package/src/prompts/researcher.md +1 -0
  61. package/src/prompts/system-lead-router.md +122 -0
  62. package/src/prompts/system-lead-synthesizer.md +46 -0
  63. package/src/sdk.ts +10 -0
  64. package/src/skill-install/skill-installer.ts +5 -1
  65. package/src/stackexchange/index.ts +3 -0
  66. package/src/stackexchange/rest-client.ts +7 -12
  67. package/src/tools/research-knowledge-search.ts +11 -3
  68. package/src/tools/research-tool-definition.ts +38 -7
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +31 -10
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/host-config.ts +12 -1
  77. package/src/utils/llm-usage.ts +33 -6
  78. package/src/utils/log-utils.ts +10 -0
  79. package/src/utils/metrics-summary.ts +20 -4
  80. package/src/utils/metrics.ts +27 -1
  81. package/src/web-research/browser-search.ts +102 -22
  82. package/src/web-research/web-scraper.ts +27 -16
  83. package/src/youtube/potoken.ts +8 -1
  84. package/src/youtube/transcript-client.ts +56 -5
  85. package/dist/prompts/system-lead-evaluator.md +0 -118
  86. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Cancellation detection shared by the agent-facing tools.
3
+ *
4
+ * Every tool wrapper is built to never throw: a failure becomes prose the agent
5
+ * reads and acts on. That contract is right for a failure and wrong for a
6
+ * cancellation, because the prose is written to explain a *fault* — "this may be
7
+ * due to API rate limiting", "unable to search security databases, try again
8
+ * later", "may be due to network issues or YouTube bot-protection". When the user
9
+ * presses Ctrl-C, the in-flight work rejects with an abort and those sentences are
10
+ * simply false. The agent believes them: it retries, spends budget the user just
11
+ * asked it to stop spending, and can carry the invented cause into the report.
12
+ *
13
+ * A cancellation must therefore propagate. The run is ending either way, and the
14
+ * throw is what unwinds the agent loop instead of feeding it a fabricated fault.
15
+ */
16
+
17
+ /**
18
+ * True when `error` represents the abort of `signal` (or an abort in general)
19
+ * rather than a genuine failure of the work.
20
+ *
21
+ * The message-shape check is the load-bearing part in practice. Cancellation
22
+ * crosses several layers here — worker IPC, HTTP to the browser leader, the
23
+ * research orchestrator's own abort plumbing — and only some of them preserve
24
+ * `AbortError` as the error name; the rest arrive as a plain Error whose message
25
+ * is the reason string. `signal.aborted` alone is not sufficient either: a tool
26
+ * may be handed no signal at all, and a signal can abort after the failure it is
27
+ * being asked to explain.
28
+ */
29
+ export function isCancellation(error: unknown, signal?: AbortSignal | null): boolean {
30
+ if (signal?.aborted) return true;
31
+ if (error instanceof Error && error.name === 'AbortError') return true;
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return /^(aborted|the operation was aborted|research (aborted|cancelled|canceled)|operation cancelled|operation canceled)\.?$/i.test(
34
+ message.trim(),
35
+ );
36
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Coverage digests — the routing half of the evaluator's input.
3
+ *
4
+ * A researcher emits a short, structured account of what it covered at the HEAD of its
5
+ * report, delimited by `COVERAGE DIGEST` / `END COVERAGE DIGEST`. The digest is split off
6
+ * when the report is stored, so every consumer of a report body sees one without routing
7
+ * metadata in it. The router reads a fresh report in full WITH its digest beside it, and
8
+ * represents every earlier round by digest alone; the synthesizer reads bodies only.
9
+ *
10
+ * Why the head and not the tail: the tail already belongs to `CITED LINKS`, and a report
11
+ * that gets truncated mid-write (timeout, token ceiling — see researcher-executor's
12
+ * partial-report path) loses its tail first. A digest at the head survives truncation,
13
+ * which is exactly the case where the router most needs to know what was and wasn't
14
+ * established.
15
+ *
16
+ * Everything here is defensive: a missing, unterminated, or oversized digest must degrade
17
+ * to a usable router input, never to a lost report body. The report is the run's product;
18
+ * the digest is an optimization.
19
+ */
20
+
21
+ import { parseCitations, lastCitedLinksHeaderIndex } from './text-utils.ts';
22
+
23
+ /** Opening delimiter, matched line-leading and case-insensitively. */
24
+ export const COVERAGE_DIGEST_HEADER = 'COVERAGE DIGEST';
25
+
26
+ /** Closing delimiter. Required — see `splitCoverageDigest`. */
27
+ export const COVERAGE_DIGEST_TERMINATOR = 'END COVERAGE DIGEST';
28
+
29
+ /**
30
+ * How far into the report the opening delimiter may appear and still count.
31
+ *
32
+ * The digest is specified to be the first thing in the report, but models routinely emit
33
+ * a short preamble ("Here is my report:") ahead of it. This window tolerates that while
34
+ * refusing to match the phrase occurring incidentally in the middle of prose — a report
35
+ * ABOUT research methodology could well discuss a "coverage digest", and treating that as
36
+ * a delimiter would silently truncate everything before it out of the synthesis corpus.
37
+ */
38
+ const DIGEST_HEAD_WINDOW_CHARS = 600;
39
+
40
+ /**
41
+ * Ceiling on the digest text handed to the router.
42
+ *
43
+ * A well-formed digest is a handful of lines. A digest much larger than this means the
44
+ * model wrote its findings inside the delimiters, which would reintroduce exactly the
45
+ * unbounded routing input this protocol exists to remove. The body is unaffected — it is
46
+ * whatever follows the terminator either way — so capping here costs nothing but router
47
+ * detail.
48
+ */
49
+ export const MAX_DIGEST_CHARS = 2400;
50
+
51
+ /**
52
+ * Terminator must appear within this distance of the header for the block to be treated
53
+ * as a digest. Bounds the damage from a model that writes the opening delimiter, forgets
54
+ * the closing one, and happens to write `END COVERAGE DIGEST` much later (or from a
55
+ * report that quotes both delimiters while discussing this very protocol).
56
+ */
57
+ const MAX_DIGEST_BLOCK_CHARS = 8000;
58
+
59
+ /**
60
+ * The `{{digest_section}}` block of the researcher prompt, for a run that has a research
61
+ * lead to route (depth 1+).
62
+ *
63
+ * Left EMPTY for quick research: that mode has no lead and no second round, its report goes
64
+ * straight to the user, and asking for routing metadata there would spend output tokens on
65
+ * a reader that does not exist — while telling the model about a "research lead" that is
66
+ * not part of its run.
67
+ */
68
+ export const RESEARCHER_DIGEST_SECTION = `Your report has three parts in this exact order: a COVERAGE DIGEST block, the report body, and the CITED LINKS list.
69
+
70
+ ### Part 1 — COVERAGE DIGEST (first, mandatory)
71
+
72
+ Open the report with this block, exactly as shown. The research lead reads it to decide what still needs investigating. On the round your report arrives the lead reads the digest alongside the full report and checks one against the other; on every later round it sees ONLY this digest — so anything you leave out of it is invisible to every decision after the first.
73
+
74
+ ${COVERAGE_DIGEST_HEADER}
75
+ Goal: [your assigned goal, restated in one line]
76
+ Covered: [the specific topics you actually established from sources, separated by semicolons — be concrete, name the things you found out, not the areas you looked at]
77
+ Unsubstantiated: [claims you searched for but could not ground in any source; write "none" if there are none]
78
+ Gaps: [what a follow-up researcher would still need to investigate on this goal; write "none" if your goal is fully covered]
79
+ Sources: [the number of entries in your CITED LINKS list]
80
+ ${COVERAGE_DIGEST_TERMINATOR}
81
+
82
+ Rules for the digest:
83
+ - Keep it under 150 words total. It is an index, not a summary — no [N] citations, no findings prose, no URLs.
84
+ - Be honest about \`Gaps\` and \`Unsubstantiated\`. Claiming full coverage you do not have is the single most damaging thing you can write here: it argues for ending the research on this topic, and after the round it arrives there is no report left for the lead to check the claim against.
85
+ - Write both delimiter lines exactly, in capitals, each on its own line.
86
+
87
+ ### Part 2 — Report body
88
+
89
+ `;
90
+
91
+ /** Line-leading delimiter matcher, tolerating the markdown decoration models add. */
92
+ function delimiterRegex(word: string): RegExp {
93
+ return new RegExp(`(^|\\n)([ \\t]*(?:#{1,6}[ \\t]*|>[ \\t]*|\\*\\*[ \\t]*)?)${word}\\b`, 'i');
94
+ }
95
+
96
+ export interface SplitReport {
97
+ /** Digest text WITHOUT the delimiter lines, trimmed and capped. Empty when absent. */
98
+ digest: string;
99
+ /** The report body with the digest block removed. Never empty unless the input was. */
100
+ body: string;
101
+ }
102
+
103
+ /**
104
+ * Split a researcher report into its coverage digest and its body.
105
+ *
106
+ * Both delimiters are required. A header with no terminator is treated as NO digest and
107
+ * the report is returned intact: the alternative — guessing where the block ends — risks
108
+ * swallowing the findings, and a report that reaches the synthesizer whole is strictly
109
+ * better than one that reaches it short.
110
+ */
111
+ export function splitCoverageDigest(report: string): SplitReport {
112
+ if (!report) return { digest: '', body: report };
113
+
114
+ const headerMatch = delimiterRegex(COVERAGE_DIGEST_HEADER).exec(report);
115
+ if (!headerMatch) return { digest: '', body: report };
116
+
117
+ const lineStart = headerMatch.index + headerMatch[1]!.length;
118
+ if (lineStart > DIGEST_HEAD_WINDOW_CHARS) return { digest: '', body: report };
119
+
120
+ // Search for the terminator strictly AFTER the header line, so `END COVERAGE DIGEST`
121
+ // cannot match the header's own trailing text.
122
+ const afterHeaderWord = lineStart + headerMatch[2]!.length + COVERAGE_DIGEST_HEADER.length;
123
+ // Consume the header's WHOLE line, not just the matched word — mirrors the terminator
124
+ // handling below. A model that writes `**COVERAGE DIGEST**` matches on the leading
125
+ // marker; without this, the trailing `**` (or a closing `:`, `##`, etc.) would dangle
126
+ // at the head of the digest text instead of being discarded as delimiter.
127
+ const headerNewline = report.indexOf('\n', afterHeaderWord);
128
+ const afterHeader = headerNewline < 0 ? afterHeaderWord : headerNewline + 1;
129
+ const rest = report.slice(afterHeader);
130
+ const endMatch = delimiterRegex(COVERAGE_DIGEST_TERMINATOR).exec(rest);
131
+ if (!endMatch) return { digest: '', body: report };
132
+
133
+ const endLineStart = endMatch.index + endMatch[1]!.length;
134
+ if (endLineStart > MAX_DIGEST_BLOCK_CHARS) return { digest: '', body: report };
135
+
136
+ const digestRaw = rest.slice(0, endLineStart).trim();
137
+ // Consume the terminator's WHOLE line, not just the matched word: a model that writes
138
+ // `**END COVERAGE DIGEST**` matches on the leading marker and would otherwise leave the
139
+ // trailing `**` dangling at the head of the body. The terminator line is delimiter, so
140
+ // none of it belongs to the report.
141
+ const afterWord = endMatch.index + endMatch[0]!.length;
142
+ const nextNewline = rest.indexOf('\n', afterWord);
143
+ const bodyAfter = nextNewline < 0 ? '' : rest.slice(nextNewline + 1);
144
+
145
+ // Anything the model wrote BEFORE the digest (a preamble) is kept — it is part of the
146
+ // report, and dropping content is never the safe default here.
147
+ const bodyBefore = report.slice(0, lineStart);
148
+ const body = `${bodyBefore}${bodyAfter}`.trim();
149
+
150
+ const digest = digestRaw.length > MAX_DIGEST_CHARS
151
+ ? `${digestRaw.slice(0, MAX_DIGEST_CHARS).trimEnd()}\n(digest truncated)`
152
+ : digestRaw;
153
+
154
+ // A digest is only worth having if a body survives it. If the "body" came out empty the
155
+ // model put the entire report inside the delimiters; return the original so nothing is
156
+ // lost, and let the derived digest below serve routing.
157
+ if (!body) return { digest: '', body: report };
158
+
159
+ return { digest, body };
160
+ }
161
+
162
+ /**
163
+ * Build a minimal digest for a report that carries none.
164
+ *
165
+ * Reached for older/partial reports and for models that ignore the format instruction.
166
+ * The router must never be blind to a researcher that actually ran, so this derives the
167
+ * two facts that can be read off any report mechanically — its opening topic line and how
168
+ * many sources it cited — and says plainly that the rest is unknown. A router told
169
+ * "coverage unknown" delegates; a router told nothing assumes the gap was filled.
170
+ */
171
+ export function deriveCoverageDigest(body: string): string {
172
+ const citationCount = parseCitations(body).length;
173
+
174
+ // The report's first line is its topic title by the researcher prompt's format. Read it
175
+ // from the prose region only — a report that is nothing BUT a CITED LINKS section would
176
+ // otherwise present a URL line as its topic.
177
+ const citedIdx = lastCitedLinksHeaderIndex(body);
178
+ const prose = citedIdx >= 0 ? body.slice(0, citedIdx) : body;
179
+ const firstLine = prose
180
+ .split('\n')
181
+ .map((l) => l.replace(/^[#>*\s]+/, '').trim())
182
+ .find((l) => l.length > 0) ?? '';
183
+
184
+ const topic = firstLine.length > 200 ? `${firstLine.slice(0, 200).trimEnd()}…` : firstLine;
185
+
186
+ return [
187
+ `Covered: ${topic || 'unknown — the report has no readable topic line'}`,
188
+ `Gaps: unknown — this researcher emitted no coverage digest`,
189
+ `Sources: ${citationCount}`,
190
+ ].join('\n');
191
+ }
192
+
193
+ /**
194
+ * Render the router's view of the run: one block per researcher, in report order.
195
+ *
196
+ * Deliberately mirrors the findings block's `### Researcher <id>` shape so the router's
197
+ * researcher ids line up with the ids the synthesizer and the query history already use.
198
+ */
199
+ export function formatDigestsForRouter(digests: ReadonlyMap<string, string>): string {
200
+ if (digests.size === 0) return '';
201
+ return Array.from(digests.entries())
202
+ .map(([id, digest]) => `### Researcher ${id}\n${digest}`)
203
+ .join('\n\n');
204
+ }
@@ -27,11 +27,22 @@ let _configDirName: string | undefined;
27
27
  * Resolution order: explicit env override (`PI_RESEARCH_CONFIG_DIR_NAME`) →
28
28
  * the host `@earendil-works/pi-coding-agent` package (only where it is actually
29
29
  * resolvable, i.e. the main pi process) → the stable default `.pi`.
30
+ *
31
+ * The override is a directory NAME, not a path: it is joined onto
32
+ * `os.homedir()` by every caller (getGlobalConfigDir() in config.ts), which in
33
+ * turn is the base for config.env (API keys, chmod 0600), the state dir
34
+ * (locks, run semaphores), and the knowledge_db. A value containing a path
35
+ * separator or `..` traverses out of HOME entirely — scripts/cleanup.cjs
36
+ * already rejects exactly that in its own copy of this resolution (see its
37
+ * `rawDirName`/`dirName` guard); this must reject it the same way, since this
38
+ * is the copy every actual run resolves secrets and state through.
30
39
  */
31
40
  export function getConfigDirName(): string {
32
41
  if (_configDirName !== undefined) return _configDirName;
33
42
  const override = process.env['PI_RESEARCH_CONFIG_DIR_NAME'];
34
- if (override) return (_configDirName = override);
43
+ if (override && !override.includes('/') && !override.includes('\\') && override !== '.' && override !== '..') {
44
+ return (_configDirName = override);
45
+ }
35
46
  try {
36
47
  const require = createRequire(import.meta.url);
37
48
  const host = require('@earendil-works/pi-coding-agent') as { CONFIG_DIR_NAME?: string };
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Model } from '@earendil-works/pi-ai';
15
- import { extractUsage } from '../types/llm.ts';
15
+ import { extractUsage, type TokenUsage } from '../types/llm.ts';
16
16
  import { logger } from '../logger.ts';
17
17
  import { metrics } from './metrics.ts';
18
18
 
@@ -51,12 +51,35 @@ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
51
51
  );
52
52
  }
53
53
 
54
+ /**
55
+ * Record the prompt-cache split alongside the plain token counter.
56
+ *
57
+ * `llm_tokens_total` folds cached and uncached input into one number, so a run whose
58
+ * prefix cache never hits is indistinguishable from one that hits on every call — the
59
+ * cost line moves, but nothing says why. These two counters are the only signal that
60
+ * prompt caching is working at all, and they are what a caching regression shows up in:
61
+ * a prompt edit that pushes volatile text ahead of stable text drives `cache_read` to
62
+ * zero while `cache_write` keeps climbing.
63
+ *
64
+ * Zero-valued counters are still emitted (when the provider reported the field at all)
65
+ * so "cache is off" and "provider does not report caching" stay distinguishable: the
66
+ * first shows the counter at 0, the second omits it.
67
+ */
68
+ function recordCacheTokens(parsed: Partial<TokenUsage>, labels: Record<string, string>): void {
69
+ if (typeof parsed.cacheRead === 'number') {
70
+ metrics.increment('llm_cache_read_tokens_total', parsed.cacheRead, labels);
71
+ }
72
+ if (typeof parsed.cacheWrite === 'number') {
73
+ metrics.increment('llm_cache_write_tokens_total', parsed.cacheWrite, labels);
74
+ }
75
+ }
76
+
54
77
  /** Structural subset of the run observer — just the token sinks. Kept structural (rather
55
78
  * than importing ResearchObserver from core/) so this foundation-layer helper does not
56
79
  * depend on an upper layer; the full ResearchObserver satisfies it. */
57
80
  export interface TokenSink {
58
81
  onTokensConsumed?: (tokens: number, cost: number) => void;
59
- /** Phase-scoped sinks, fed from the `component` label ('coordinator'/'evaluator');
82
+ /** Phase-scoped sinks, fed from the `component` label ('coordinator', 'router'/'synthesizer');
60
83
  * they drive the TUI coord/eval cost rows and the SDK planning_tokens /
61
84
  * evaluation_tokens events. */
62
85
  onPlanningTokens?: (tokens: number, cost: number) => void;
@@ -64,7 +87,7 @@ export interface TokenSink {
64
87
  }
65
88
 
66
89
  export interface RecordUsageOptions {
67
- /** Metrics `component` label (e.g. 'coordinator', 'evaluator', 'researcher'). */
90
+ /** Metrics `component` label (e.g. 'coordinator', 'router', 'synthesizer', 'researcher'). */
68
91
  component: string;
69
92
  /** Optional complexity label, stringified for the metric labels. */
70
93
  complexity?: number | string;
@@ -83,23 +106,27 @@ export function recordLlmUsage(
83
106
  opts: RecordUsageOptions
84
107
  ): { tokens: number; cost: number } {
85
108
  if (!rawUsage) return { tokens: 0, cost: 0 };
86
- const { tokens, cost } = extractUsage(model, rawUsage);
109
+ const { tokens, cost, parsed } = extractUsage(model, rawUsage);
87
110
  warnIfUnpriced(model, tokens, cost);
88
111
  if (tokens > 0 || cost > 0) {
89
112
  const labels: Record<string, string> = { component: opts.component };
90
113
  if (opts.complexity !== undefined) labels['complexity'] = String(opts.complexity);
91
114
  metrics.increment('llm_tokens_total', tokens, labels);
92
115
  metrics.increment('llm_cost_total', cost, labels);
116
+ recordCacheTokens(parsed, labels);
93
117
  opts.observer?.onTokensConsumed?.(tokens, cost);
94
118
  // Phase-scoped events: coordinator/evaluator call sites label their usage
95
- // 'coordinator'/'evaluator' (see planning-service.ts); route those to the
119
+ // 'coordinator'/'router'/'synthesizer' (see planning-service.ts); route those to the
96
120
  // dedicated observer hooks so the TUI coord/eval cost rows and the SDK
97
121
  // planning_tokens/evaluation_tokens events fire. Before this mapping the hooks
98
122
  // had zero emit sites (regression in c90d7f37) — coordinator/evaluator usage
99
123
  // reached only onTokensConsumed, which the TUI observer does not implement.
100
124
  if (opts.component === 'coordinator') {
101
125
  opts.observer?.onPlanningTokens?.(tokens, cost);
102
- } else if (opts.component === 'evaluator') {
126
+ } else if (opts.component === 'router' || opts.component === 'synthesizer') {
127
+ // Both halves of the research lead feed the one evaluation row. They used to be a
128
+ // single 'evaluator' component; splitting the roles must not split the cost the
129
+ // user sees, so they are summed here rather than given separate sinks.
103
130
  opts.observer?.onEvaluationTokens?.(tokens, cost);
104
131
  }
105
132
  }
@@ -255,6 +255,16 @@ export function redactSecrets(message: string): string {
255
255
  // never negative; it still prefers the larger rawLength when the
256
256
  // REDACT_SCAN_LENGTH cut discarded raw content redaction then shrank.
257
257
  out = `${out.slice(0, keep)}…[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
258
+ } else if (rawLength > scanEnd) {
259
+ // The scanEnd cut (line 220) already dropped `rawLength - scanEnd` raw
260
+ // chars before redaction ever ran. If redaction then net-shrinks the
261
+ // scanned prefix enough that `out.length` no longer exceeds
262
+ // MAX_LOG_MESSAGE_LENGTH (e.g. many long hex/JWT runs each collapsed to
263
+ // the short literal '[REDACTED]'), the branch above never fires and this
264
+ // message would otherwise read as complete — silently hiding the tail of
265
+ // a large scraped page or provider error body with no indication
266
+ // anything was cut.
267
+ out = `${out}…[truncated ${rawLength - scanEnd} chars]`;
258
268
  }
259
269
  return out;
260
270
  }
@@ -133,9 +133,19 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
133
133
  // Researchers launched
134
134
  const researchersLaunched = sumCounter(counters, 'researchers_launched_total');
135
135
 
136
- // Search queries — combine browser search and regular search
136
+ // Search queries. These two counters count the SAME queries, one layer apart:
137
+ // browser-search increments `browser_search_queries_total` exactly once per query
138
+ // across its success/no-results/timeout/error branches, and its only caller then
139
+ // increments `search_queries_total` by successes plus failures — the same set. Summing
140
+ // them reported "N searches" at exactly twice the truth in the report footer and the
141
+ // session metrics view, and three times it on the outer-catch path, which adds
142
+ // `queries.length` a second time.
143
+ //
144
+ // Same shape as the URL counters below, and resolved the same way: the per-query
145
+ // counter IS the count; the caller-level one is a fallback for a search that threw
146
+ // before dispatching anything, where the inner counter never fired at all.
137
147
  const browserSearchQueries = sumCounter(counters, 'browser_search_queries_total');
138
- const searchQueries = sumCounter(counters, 'search_queries_total') + browserSearchQueries;
148
+ const searchQueries = browserSearchQueries || sumCounter(counters, 'search_queries_total');
139
149
 
140
150
  // URLs discovered via search. browser_search_unique_urls_total is the
141
151
  // cross-query deduped subset of the SAME batch counted by
@@ -203,8 +213,14 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
203
213
  }
204
214
  }
205
215
 
206
- // Rounds completed — evaluator runs give us the round count
207
- const roundsCompleted = sumCounter(counters, 'evaluator_runs_total');
216
+ // Rounds completed. This read `evaluator_runs_total`, whose only reference in the
217
+ // whole repository was this line — the emit site was deleted in c90d7f37 and the
218
+ // reader was left behind, so the value was always 0 and `buildResearchSummary`'s
219
+ // `roundsCompleted > 1` gate meant the "N rounds" element had never once rendered for
220
+ // any multi-round run. The orchestrator now emits a counter that says what it means.
221
+ // The old name is still summed so a snapshot captured before this change still reads.
222
+ const roundsCompleted =
223
+ sumCounter(counters, 'research_rounds_completed_total') || sumCounter(counters, 'evaluator_runs_total');
208
224
 
209
225
  // Complexity
210
226
  let complexity = 1;
@@ -83,7 +83,7 @@ export class MetricsRegistry {
83
83
  public increment(name: string, value: number = 1, labels?: Labels): void {
84
84
  const key = this.getKey(name, labels);
85
85
  this.counters.set(key, (this.counters.get(key) ?? 0) + value);
86
- if (name === 'llm_cost_total' || name === 'llm_tokens_total') {
86
+ if (name.startsWith('llm_')) {
87
87
  logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ''}`);
88
88
  }
89
89
  }
@@ -243,6 +243,32 @@ class SessionMetrics {
243
243
  return this.getActive().measure(name, action, labels);
244
244
  }
245
245
 
246
+ /**
247
+ * Emit API that ALWAYS targets the session registry, ignoring any active run
248
+ * context.
249
+ *
250
+ * For process-lifetime callbacks that happen to be *registered* during a run.
251
+ * AsyncLocalStorage resolves at emit time from the async resource that fired,
252
+ * and for a listener installed on a long-lived emitter that resource is the
253
+ * emitter itself — created, for every lazily-initialized subsystem here, inside
254
+ * whichever run first needed it. So a worker-pool error handler installed during
255
+ * run 1 keeps writing into run 1's registry for the rest of the process: those
256
+ * counts disappear from every later run's summary (the registry was discarded
257
+ * when the run ended) and the dead registry stays reachable through the closure.
258
+ *
259
+ * Use this only where the emission is genuinely process-scoped. Anything that
260
+ * describes work a run asked for belongs in the run registry, which is what the
261
+ * plain emit methods above give you.
262
+ */
263
+ public readonly session = {
264
+ increment: (name: string, value: number = 1, labels?: Labels): void =>
265
+ this._session.increment(name, value, labels),
266
+ setGauge: (name: string, value: number, labels?: Labels): void =>
267
+ this._session.setGauge(name, value, labels),
268
+ observe: (name: string, value: number, labels?: Labels): void =>
269
+ this._session.observe(name, value, labels),
270
+ };
271
+
246
272
  // ── Session-level read API ─────────────────────────────────────────────
247
273
 
248
274
  /** Snapshot of the session registry (infrastructure / cross-run metrics). */
@@ -5,7 +5,8 @@
5
5
  */
6
6
 
7
7
  import { runWorkerSearch } from '../infrastructure/browser/task-execution-service.ts';
8
- import { getMaxWorkers } from '../infrastructure/browser/config.ts';
8
+ import { getMaxWorkers, COLD_START_ALLOWANCE_MS } from '../infrastructure/browser/config.ts';
9
+ import { isTaskTimeoutError } from '../infrastructure/browser/browser-error-utils.ts';
9
10
  import { logger } from '../logger.ts';
10
11
  import { safeUnref } from '../utils/safe-unref.ts';
11
12
  import { normalizeUrl } from '../utils/url-utils.ts';
@@ -80,20 +81,28 @@ export async function performSearch(
80
81
  // Hard cap per query so a single Cloudflare block or hung browser worker
81
82
  // cannot stall the entire Promise.all burst.
82
83
  //
83
- // The budget is the worker's nav budget (SEARCH_TIMEOUT_MS) PLUS the
84
- // queue-wait/overhead margin (BROWSER_TASK_TIMEOUT_MS), mirroring the
85
- // scheduler's own task-timeout ceiling in BrowserTaskScheduler.runSearch
86
- // (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS). This clock starts at
87
- // ENQUEUE (before runWorkerSearch queues onto the shared browser pool), so
88
- // a budget of only SEARCH_TIMEOUT_MS would let a query that sat in the
89
- // saturated worker queue burn its ENTIRE nav budget waiting for a slot and
90
- // abort with zero actual search work — exactly the "Query timed out after
91
- // 45000ms (likely blocked or slow startup)" cascade observed under
92
- // concurrent load. Including the margin lets a queued query still use its
93
- // full nav budget once a worker picks it up, and keeps this layer's hard
94
- // cap coherent with the scheduler's so it never preempts the worker's own
95
- // (post-dequeue) deadline.
96
- const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
84
+ // The budget mirrors the scheduler's own task ceiling in
85
+ // BrowserTaskScheduler.runSearch (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS +
86
+ // COLD_START_ALLOWANCE_MS) so this layer never preempts the worker's deadline with
87
+ // a shorter one.
88
+ //
89
+ // Both clocks now start at DISPATCH (see startQueryDeadline below), which is
90
+ // what makes mirroring the value correct. While this one started at enqueue,
91
+ // equal values meant the two raced and whichever fired first reported — which
92
+ // is why the same starvation shows up in the logs under two different messages
93
+ // on different days. The earlier response to that was to widen this budget
94
+ // (nav plus a margin) so a queued query could still use its full nav budget
95
+ // once picked up. That treats the symptom: a margin only buys one extra
96
+ // query's worth of wait, and a burst four times the pool's reach exhausts any
97
+ // constant. Measuring execution instead of waiting is the fix; the sum
98
+ // survives as the budget because a search legitimately costs nav plus overhead.
99
+ //
100
+ // COLD_START_ALLOWANCE_MS is part of the sum for the same reason it is part of
101
+ // the scheduler's own ceiling: the first query dispatched to a freshly created or
102
+ // just-reset worker pays a real browser launch + context creation inline before
103
+ // any navigation starts, and this layer's budget must not fire before the
104
+ // scheduler's own (correct, larger) deadline gets a chance to.
105
+ const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000) + COLD_START_ALLOWANCE_MS;
97
106
 
98
107
  // Deduplicated: resultMap and the failures map are both keyed by the query
99
108
  // string, so two tasks for the same string race last-writer-wins — instance A
@@ -108,11 +117,34 @@ export async function performSearch(
108
117
  // timeout guess that misdirects to the network or system load.
109
118
  let sampleWorkerError = '';
110
119
 
111
- const searchTasks = filteredQueries.map(async (query) => {
120
+ const runQuery = async (query: string): Promise<void> => {
112
121
  const queryStartTime = Date.now();
113
122
  const timeoutController = new AbortController();
114
- const timeoutId = setTimeout(() => timeoutController.abort(), QUERY_TIMEOUT_MS);
115
- safeUnref(timeoutId);
123
+ // Armed by onDispatch below — when the browser queue hands this query to a
124
+ // worker — not here. Arming here charged queue wait to the query's budget, so
125
+ // a burst larger than the pool could serve inside one budget killed its own
126
+ // tail: every query enqueued in the same millisecond, and 90s later the ones
127
+ // still waiting aborted together having done no work. Measured at 78 of 100.
128
+ // The scheduler owns the execution deadline; this is the caller-side mirror of
129
+ // it, and against a follower scheduler — which cannot observe leader dispatch
130
+ // and so never calls onDispatch — it stays disarmed and the client's own
131
+ // bound applies instead.
132
+ let timeoutId: NodeJS.Timeout | undefined;
133
+ const onTaskEvent = (event: 'dispatched' | 'settled') => {
134
+ // Disarm on every event, so the clock only ever runs while a worker is
135
+ // actually holding this query. Arming alone was not enough: retries inside
136
+ // runWorkerSearch dispatch AGAIN, and between a failed attempt and its
137
+ // redispatch there is a backoff sleep, a re-enqueue and a fresh queue wait.
138
+ // A timer left running across that gap aborts the retry for its
139
+ // predecessor's overrun, and reports it as "the browser pool was
140
+ // saturated" — the same charge-the-wait-to-the-work mistake, one attempt
141
+ // removed.
142
+ if (timeoutId) clearTimeout(timeoutId);
143
+ timeoutId = undefined;
144
+ if (event !== 'dispatched') return;
145
+ timeoutId = setTimeout(() => timeoutController.abort(), QUERY_TIMEOUT_MS);
146
+ safeUnref(timeoutId);
147
+ };
116
148
 
117
149
  try {
118
150
  if (signal?.aborted) {
@@ -124,7 +156,7 @@ export async function performSearch(
124
156
  ? AbortSignal.any([signal, timeoutController.signal])
125
157
  : timeoutController.signal;
126
158
 
127
- const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container);
159
+ const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container, undefined, onTaskEvent);
128
160
  const queryDuration = Date.now() - queryStartTime;
129
161
  metrics.observe('browser_search_query_duration_ms', queryDuration);
130
162
 
@@ -168,7 +200,21 @@ export async function performSearch(
168
200
  }
169
201
  } catch (error) {
170
202
  const queryDuration = Date.now() - queryStartTime;
171
- const isTimeout = timeoutController.signal.aborted && !signal?.aborted;
203
+ // Two clocks can end this query, and only one of them used to be recognised.
204
+ // The scheduler's own execution deadline is armed in the same synchronous
205
+ // block as this layer's guard, with the SAME duration derived from the same
206
+ // two config values — so on the leader path the scheduler's timer is always
207
+ // registered first, always fires first, and its rejection clears this layer's
208
+ // timer before Node ever reaches it. `timeoutController.signal.aborted` was
209
+ // therefore false for every real search timeout in the leader path.
210
+ //
211
+ // The consequences all pointed the wrong way: the query was logged at ERROR,
212
+ // counted as `status=error`, and reported to the researcher as "an
213
+ // infrastructure failure" — while `timeoutCount` stayed at zero, making the
214
+ // whole-burst timeout attribution below unreachable. A task timeout is a
215
+ // timeout whichever clock names it.
216
+ const isTimeout = (timeoutController.signal.aborted || isTaskTimeoutError(error))
217
+ && !signal?.aborted;
172
218
  const status = isTimeout ? 'timeout' : 'error';
173
219
 
174
220
  if (isTimeout) timeoutCount++;
@@ -211,9 +257,43 @@ export async function performSearch(
211
257
  clearTimeout(timeoutId);
212
258
  if (onProgress) onProgress(seenUrls.size);
213
259
  }
214
- });
260
+ };
215
261
 
216
- await Promise.all(searchTasks);
262
+ // Dispatch through a bounded number of lanes instead of handing every query to
263
+ // Promise.all at once.
264
+ //
265
+ // The old form started ALL N queries simultaneously, which meant N minus maxWorkers of
266
+ // them sat in the shared browser queue with their per-query deadline ALREADY TICKING —
267
+ // the clock started at enqueue, not when a worker picked the query up. That made the
268
+ // number of queries which could possibly succeed `maxWorkers x (budget / latency)`
269
+ // rather than N. Measured on a level-3 run: 100 queries dispatched into 6 workers went
270
+ // 94 deep in the queue, 78 of them aborted at the 90s mark having never run, and the
271
+ // burst returned 49 results. Worse, each of those was reported to its researcher as a
272
+ // timeout with "retrying the same query later is reasonable", so the next round
273
+ // re-queued them and the burst grew.
274
+ //
275
+ // A lane only picks up its next query when the previous one finishes, so the shared
276
+ // queue never builds a backlog and every query eventually runs. The burst takes longer
277
+ // in wall-clock terms; it no longer discards most of itself to get there.
278
+ //
279
+ // Lanes are not what makes a query's deadline honest — dispatch-armed timers do that,
280
+ // at both this layer and the scheduler's, and they hold no matter how deep the queue
281
+ // gets. Lanes remain because bounding depth is worth having on its own: the pool is
282
+ // shared with scrapes and healthchecks and with other concurrent runs, and a burst that
283
+ // dumps its whole plan into the queue delays all of them.
284
+ const lanes = Math.max(1, Math.min(maxWorkers, filteredQueries.length));
285
+ let nextIndex = 0;
286
+ const runLane = async (): Promise<void> => {
287
+ for (;;) {
288
+ const index = nextIndex++;
289
+ if (index >= filteredQueries.length) return;
290
+ // Stop pulling new work once the caller has cancelled; queries already
291
+ // dispatched settle through their own abort path.
292
+ if (signal?.aborted) return;
293
+ await runQuery(filteredQueries[index]!);
294
+ }
295
+ };
296
+ await Promise.all(Array.from({ length: lanes }, () => runLane()));
217
297
 
218
298
  // A cancelled run is not a broken one. Every query aborts with zero results,
219
299
  // which walks straight into the total-failure branch below and reports