@lincoln504/pi-research 0.1.13 → 1.0.1

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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Planning Service
3
+ *
4
+ * Coordinates the research planning and evaluation phases.
5
+ * Interacts with the LLM to generate research agendas and evaluate findings.
6
+ */
7
+
8
+ import { ServiceLifecycle } from './service-registry.ts';
9
+ import { ServiceNames } from './service-interfaces.ts';
10
+ import type {
11
+ IPlanningService,
12
+ ResearchPlan,
13
+ UpdatePlanOptions,
14
+ GenerateQueriesOptions,
15
+ ResearcherConfig,
16
+ GeneratePlanOptions
17
+ } from './service-interfaces.ts';
18
+ import { logger } from '../logger.ts';
19
+ import { completeSimple } from './llm/pi-ai-completion.ts';
20
+ import { injectCurrentDate } from './llm/inject-date.ts';
21
+ import { loadPrompt } from './llm/prompts.ts';
22
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
23
+ import { normalizeCitations } from '../utils/citation-utils.ts';
24
+ import { repairJsonWithLlm } from './llm/agentic-repair.ts';
25
+ import { buildSafeOptions, validateAndExtractText } from './llm/llm-utils.ts';
26
+ import { safeGetApiKeyAndHeaders } from './llm/model-registry-factory.ts';
27
+ import { withTimeout } from './llm/llm-timeout.ts';
28
+ import { retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
29
+ import { getConfig } from '../config.ts';
30
+ import {
31
+ PlanningResponseSchemaAsTSchema,
32
+ EvaluationResponseSchemaAsTSchema,
33
+ } from './planning-constants.ts';
34
+ import {
35
+ getTeamSize as _getTeamSize,
36
+ getQueryBudget as _getQueryBudget,
37
+ getMaxRounds as _getMaxRounds,
38
+ getComplexityGuidance as _getComplexityGuidance,
39
+ getEvaluatorComplexityGuidance as _getEvaluatorComplexityGuidance,
40
+ getRoundPhaseGuidance as _getRoundPhaseGuidance,
41
+ parseJsonPlan as _parseJsonPlan,
42
+ buildFallbackCoordinatorPlan as _buildFallbackCoordinatorPlan,
43
+ capResearcherQueries as _capResearcherQueries,
44
+ generateResearchers as _generateResearchers,
45
+ } from './planning-utils.ts';
46
+
47
+ // The coordinator and evaluator LLM calls are single points of failure for a research
48
+ // run, yet (unlike researcher sub-agents) they had no retry — so one transient transport
49
+ // failure (an undici "terminated" mid-stream abort, ECONNRESET, 429, 5xx) aborted the whole
50
+ // run. Retry them a bounded number of times. Kept low so that a genuine provider outage
51
+ // (repeated full LLM_TIMEOUT_MS) still degrades to the deterministic fallback plan within a
52
+ // sane wall-clock budget rather than multiplying a 5-minute timeout many times over.
53
+ const LLM_MAX_RETRIES = 2;
54
+ const LLM_RETRY_INITIAL_DELAY_MS = 1000;
55
+ const LLM_RETRY_MAX_DELAY_MS = 8000;
56
+
57
+ /**
58
+ * Whether a failed coordinator/evaluator LLM call should be RETRIED. Retries only fast,
59
+ * transient transport failures: an undici mid-stream abort ("terminated"), a dropped socket,
60
+ * a 5xx / 429 / provider-overload, or an empty ("no text content") response.
61
+ *
62
+ * Deliberately does NOT retry an app-level LLM timeout ("...timed out after Nms"): the attempt
63
+ * already ran the full LLM_TIMEOUT_MS, so an immediate re-run is unlikely to succeed, would
64
+ * leave the prior (still-running) stream orphaned and billed, and would multiply a multi-minute
65
+ * wait. A timeout instead degrades straight to the fallback plan (see the catch blocks, which
66
+ * treat "timed out" as degradable). A deliberate cancellation is never retried — detected by
67
+ * AbortSignal state / AbortError name, not by the message (withTimeout emits the same "cancelled
68
+ * or timed out" wording for a user-abort and a real timeout, and abortableDelay is the backstop).
69
+ *
70
+ * Exported for direct unit testing of the abort/timeout guards.
71
+ */
72
+ export function isRetriableLlmError(error: unknown, signal?: AbortSignal): boolean {
73
+ if (signal?.aborted) return false;
74
+ if (error instanceof Error && error.name === 'AbortError') return false;
75
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
76
+ if (message.includes('timed out')) return false; // app timeout → degrade, don't retry
77
+ if (message.includes('no text content')) return true;
78
+ return isTransientError(error);
79
+ }
80
+
81
+ /**
82
+ * Whether an exhausted coordinator/evaluator failure should DEGRADE to the deterministic
83
+ * fallback plan rather than abort the run. Broader than the retry set: it also covers the
84
+ * app-level timeout (which is intentionally not retried). Only a genuinely non-transient error
85
+ * (bad request, missing auth) stays fatal — a fallback plan can't run without a working model.
86
+ */
87
+ function isDegradableLlmError(error: unknown, signal?: AbortSignal): boolean {
88
+ if (signal?.aborted) return false;
89
+ const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
90
+ if (message.includes('timed out')) return true;
91
+ return isRetriableLlmError(error, signal);
92
+ }
93
+
94
+ /**
95
+ * Salvage raw evaluator text as report content when JSON parse AND agentic repair
96
+ * both fail. A truncated/garbled JSON envelope must NOT be shipped verbatim as the
97
+ * final report (the user would see raw `{"action":"synthesize","content":"...` cut
98
+ * off mid-string). Returning '' here lets the orchestrator's reports-based fallback
99
+ * synthesis take over. Genuine prose (a model that answered in text instead of JSON)
100
+ * is preserved — only JSON-envelope-looking blobs and too-short scraps are rejected.
101
+ */
102
+ export function salvageReportText(raw: string): string {
103
+ const trimmed = raw.trim();
104
+ if (trimmed.length <= 50) return '';
105
+ // Looks like a (broken) JSON envelope rather than a report — suppress it.
106
+ if (trimmed.startsWith('{') && /"(?:action|content|researchers)"\s*:/.test(trimmed)) {
107
+ return '';
108
+ }
109
+ return raw;
110
+ }
111
+
112
+ /**
113
+ * Planning Service Implementation
114
+ */
115
+ export class PlanningService implements IPlanningService {
116
+ readonly name = ServiceNames.PLANNING;
117
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
118
+
119
+ // State per research session
120
+ private currentPlans = new Map<string, ResearchPlan>();
121
+ private totalResearchersPlanned = new Map<string, number>();
122
+ private queryHistory = new Map<string, Set<string>>();
123
+
124
+ async initialize(): Promise<void> {
125
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
126
+ }
127
+
128
+ async dispose(): Promise<void> {
129
+ this.currentPlans.clear();
130
+ this.totalResearchersPlanned.clear();
131
+ this.queryHistory.clear();
132
+ this.lifecycle = ServiceLifecycle.DISPOSED;
133
+ }
134
+
135
+ /**
136
+ * Check if the service is ready
137
+ */
138
+ isReady(): boolean {
139
+ return this.lifecycle === ServiceLifecycle.INITIALIZED;
140
+ }
141
+
142
+ /**
143
+ * Clear planning state for a specific research ID
144
+ */
145
+ clearPlanningState(researchId?: string): void {
146
+ if (researchId) {
147
+ this.currentPlans.delete(researchId);
148
+ this.totalResearchersPlanned.delete(researchId);
149
+ this.queryHistory.delete(researchId);
150
+ } else {
151
+ this.currentPlans.clear();
152
+ this.totalResearchersPlanned.clear();
153
+ this.queryHistory.clear();
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Get the current plan for a session
159
+ */
160
+ getCurrentPlan(researchId: string): ResearchPlan | null {
161
+ return this.currentPlans.get(researchId) || null;
162
+ }
163
+
164
+ /**
165
+ * Get total number of researchers planned across all rounds
166
+ */
167
+ getTotalResearchersPlanned(researchId: string): number {
168
+ return this.totalResearchersPlanned.get(researchId) || 0;
169
+ }
170
+
171
+ /**
172
+ * Increment total number of researchers planned
173
+ */
174
+ incrementTotalResearchersPlanned(researchId: string, count: number): void {
175
+ const current = this.getTotalResearchersPlanned(researchId);
176
+ this.totalResearchersPlanned.set(researchId, current + count);
177
+ }
178
+
179
+ /**
180
+ * Get query history for a session
181
+ */
182
+ getQueryHistory(researchId: string): string[] {
183
+ const history = this.queryHistory.get(researchId);
184
+ return history ? Array.from(history) : [];
185
+ }
186
+
187
+ /**
188
+ * Record search queries to history to prevent duplication in later rounds
189
+ */
190
+ addToQueryHistory(researchId: string, queries: string[]): void {
191
+ if (!this.queryHistory.has(researchId)) {
192
+ this.queryHistory.set(researchId, new Set());
193
+ }
194
+ const history = this.queryHistory.get(researchId)!;
195
+ for (const q of queries) {
196
+ if (q && q.trim()) {
197
+ history.add(q.toLowerCase().trim());
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Interface pass-throughs for planning utilities
204
+ */
205
+ getTeamSize(complexity: 1 | 2 | 3): number {
206
+ return _getTeamSize(complexity);
207
+ }
208
+
209
+ getQueryBudget(complexity: 1 | 2 | 3): number {
210
+ return _getQueryBudget(complexity);
211
+ }
212
+
213
+ getComplexityGuidance(complexity: 1 | 2 | 3, maxTeamSize: number, queryBudget: number): string {
214
+ return _getComplexityGuidance(complexity, maxTeamSize, queryBudget);
215
+ }
216
+
217
+ getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3): string {
218
+ return _getEvaluatorComplexityGuidance(complexity);
219
+ }
220
+
221
+ getRoundPhaseGuidance(currentRound: number, maxRounds: number, complexity: 1 | 2 | 3, maxTeamSize: number): string {
222
+ return _getRoundPhaseGuidance(currentRound, maxRounds, complexity, maxTeamSize);
223
+ }
224
+
225
+ parseJsonPlan(text: string): ResearchPlan {
226
+ return _parseJsonPlan(text);
227
+ }
228
+
229
+ buildFallbackCoordinatorPlan(rawText: string, query: string): ResearchPlan {
230
+ return _buildFallbackCoordinatorPlan('PlanningService', rawText, query);
231
+ }
232
+
233
+ capResearcherQueries(plan: ResearchPlan, complexity: 1 | 2 | 3, serviceName: string): ResearchPlan {
234
+ return _capResearcherQueries(plan, complexity, serviceName);
235
+ }
236
+
237
+ generateResearchers(plan: ResearchPlan, query: string, complexity: 1 | 2 | 3): ResearcherConfig[] {
238
+ return _generateResearchers(plan, query, complexity);
239
+ }
240
+
241
+ /**
242
+ * Helper to populate prompt templates with placeholders.
243
+ */
244
+ private populatePrompt(template: string, replacements: Record<string, string | number>): string {
245
+ let result = template;
246
+ for (const [key, value] of Object.entries(replacements)) {
247
+ const placeholder = `{{${key}}}`;
248
+ result = result.split(placeholder).join(String(value));
249
+ }
250
+ return result;
251
+ }
252
+
253
+ /**
254
+ * Generate initial research plan
255
+ */
256
+ async generatePlan(options: GeneratePlanOptions): Promise<ResearchPlan> {
257
+ const { sessionId, query, complexity, model, signal, observer, steeringMessages, modelRegistry } = options;
258
+
259
+ logger.log(`[PlanningService] Generating initial plan for: "${query}" (Complexity: ${complexity})`);
260
+
261
+ const config = options.config ?? getConfig(options.cwd);
262
+ const promptTemplate = loadPrompt('system-coordinator');
263
+
264
+ const maxTeamSize = this.getTeamSize(complexity);
265
+ const queryBudget = this.getQueryBudget(complexity);
266
+ const complexityGuidance = this.getComplexityGuidance(complexity, maxTeamSize, queryBudget);
267
+
268
+ // Inject steering if present
269
+ let steeringSection = '';
270
+ if (steeringMessages && steeringMessages.length > 0) {
271
+ steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Apply these rules and instructions to your plan and decisions)\n' +
272
+ steeringMessages.map(m => `- ${m}`).join('\n');
273
+ }
274
+
275
+ const disabledToolsSection = options.excludeTools && options.excludeTools.length > 0
276
+ ? `\n## DISABLED TOOLS\nThe following tools are DISABLED for this session: ${options.excludeTools.join(', ')}. Do NOT reference or attempt to use them.\n`
277
+ : '';
278
+
279
+ const systemPrompt = injectCurrentDate(promptTemplate, 'coordinator');
280
+ const populatedPrompt = this.populatePrompt(systemPrompt, {
281
+ root_query: query,
282
+ additional_considerations: steeringSection,
283
+ complexity_label: complexity === 1 ? 'Level 1 (Normal)' : complexity === 2 ? 'Level 2 (Deep)' : 'Level 3 (Ultra)',
284
+ max_team_size: maxTeamSize,
285
+ query_budget: queryBudget,
286
+ complexity_guidance: complexityGuidance,
287
+ disabled_tools_section: disabledToolsSection,
288
+ youtube_query_every_n: config.YOUTUBE_QUERY_EVERY_N,
289
+ });
290
+
291
+ const userMessage = `Generate the initial research plan for: "${query}"`;
292
+
293
+ try {
294
+ const authResult = await safeGetApiKeyAndHeaders(modelRegistry, model);
295
+ if (!authResult.ok) {
296
+ throw new Error(`Failed to get API key for planning: ${authResult.error}`);
297
+ }
298
+
299
+ const llmTimeout = config.LLM_TIMEOUT_MS;
300
+
301
+ // Retry the transport call + response validation on transient failures. A mid-stream
302
+ // undici "terminated" abort surfaces as a stopReason:'error' response that only throws
303
+ // inside validateAndExtractText, so validation MUST sit inside the retried region. The
304
+ // agentic-repair / fallback path below stays OUTSIDE the retry — it handles present-but-
305
+ // malformed JSON (a different failure) and would otherwise be re-paid on every attempt.
306
+ const { response, responseText } = await retryWithBackoff(
307
+ async () => {
308
+ const response = await withTimeout(
309
+ completeSimple(model, {
310
+ systemPrompt: populatedPrompt,
311
+ messages: [
312
+ { role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
313
+ ],
314
+ }, buildSafeOptions(model, {
315
+ apiKey: authResult.apiKey || '',
316
+ headers: authResult.headers,
317
+ signal
318
+ }, config.PLANNING_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
319
+ llmTimeout, 'coordinator-generatePlan',
320
+ );
321
+ const responseText = validateAndExtractText(response, 'Coordinator');
322
+ return { response, responseText };
323
+ },
324
+ {
325
+ maxRetries: LLM_MAX_RETRIES,
326
+ initialDelay: LLM_RETRY_INITIAL_DELAY_MS,
327
+ maxDelay: LLM_RETRY_MAX_DELAY_MS,
328
+ label: 'coordinator-generatePlan',
329
+ signal,
330
+ isTransientError: (err) => isRetriableLlmError(err, signal),
331
+ },
332
+ );
333
+
334
+ // Track usage once, on the successful attempt (failed attempts throw before here).
335
+ recordLlmUsage(model, (response as any).usage, { component: 'coordinator', complexity, observer });
336
+
337
+ // Extract and validate JSON
338
+ let plan: ResearchPlan | null = null;
339
+ try {
340
+ plan = this.parseJsonPlan(responseText);
341
+ } catch {
342
+ logger.warn('[PlanningService] Initial plan JSON malformed, attempting agentic repair');
343
+ const repaired = await repairJsonWithLlm<ResearchPlan>(
344
+ responseText,
345
+ completeSimple,
346
+ { apiKey: authResult.apiKey || '', headers: authResult.headers },
347
+ {
348
+ model,
349
+ schema: PlanningResponseSchemaAsTSchema,
350
+ context: `Research planning for: ${query}`,
351
+ serviceName: 'PlanningService',
352
+ signal,
353
+ maxTokens: config.PLANNING_MAX_TOKENS,
354
+ thinkingLevel: config.LLM_THINKING_LEVEL,
355
+ onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'coordinator', complexity, observer }),
356
+ }
357
+ );
358
+ if (repaired) {
359
+ // Validate repaired JSON
360
+ try {
361
+ plan = this.parseJsonPlan(JSON.stringify(repaired));
362
+ } catch {
363
+ plan = null;
364
+ }
365
+ }
366
+ }
367
+
368
+ if (!plan) {
369
+ logger.warn('[PlanningService] Failed to generate valid plan, building fallback');
370
+ plan = this.buildFallbackCoordinatorPlan(responseText, query);
371
+ }
372
+
373
+ // Final safety cap
374
+ plan = this.capResearcherQueries(plan, complexity, this.name);
375
+ // The coordinator (round 1) MUST yield runnable researchers. If the model emitted empty or
376
+ // absent query arrays, capResearcherQueries drops them all and force-synthesizes — but at
377
+ // round 1 there are zero reports, so that is a silent no-op run ("no summary generated",
378
+ // no error). buildFallbackCoordinatorPlan only fires on a null plan, so a parseable-but-
379
+ // empty plan slips through. Fall back to the single-researcher plan so the run investigates.
380
+ if (!plan.researchers || plan.researchers.length === 0) {
381
+ logger.warn('[PlanningService] Coordinator produced no runnable researchers (empty queries); using single-researcher fallback');
382
+ plan = this.capResearcherQueries(this.buildFallbackCoordinatorPlan(responseText, query), complexity, this.name);
383
+ }
384
+ if (plan.action !== 'synthesize') {
385
+ plan.action = 'delegate';
386
+ }
387
+ this.currentPlans.set(sessionId, plan);
388
+
389
+ return plan;
390
+ } catch (err) {
391
+ // A genuine cancellation must propagate so the orchestrator can abort cleanly.
392
+ // Log it at debug, not error — a quit-mid-run is a clean stop, and an ERROR
393
+ // line here previously read like an infrastructure failure.
394
+ if (signal?.aborted) {
395
+ logger.debug('[PlanningService] Plan generation aborted (external signal)');
396
+ throw err;
397
+ }
398
+ // A transient coordinator failure — the call timed out, or the model returned an
399
+ // empty / thinking-only response — used to abort the entire run on the very first
400
+ // call (this path runs BEFORE the in-band JSON-parse fallback above). Degrade like
401
+ // updatePlanForRound: build the deterministic single-researcher plan so the run can
402
+ // still proceed. Hard failures (missing auth, an explicit provider error / rate
403
+ // limit) stay fatal — a fallback plan can't run without a working model anyway.
404
+ // After retries are exhausted, a still-transient failure (timeout, empty response, or a
405
+ // transport abort like "terminated") degrades to the deterministic fallback plan so the
406
+ // run proceeds; only a genuinely non-transient error (missing auth, explicit provider
407
+ // rejection) stays fatal — a fallback plan can't run without a working model anyway.
408
+ const isTransient = isDegradableLlmError(err, signal);
409
+ if (!isTransient) {
410
+ logger.error('[PlanningService] Failed to generate plan:', err);
411
+ throw err;
412
+ }
413
+ logger.warn('[PlanningService] Coordinator call failed transiently; building fallback plan so the run can proceed');
414
+ let plan = this.buildFallbackCoordinatorPlan('', query);
415
+ plan = this.capResearcherQueries(plan, complexity, this.name);
416
+ if (plan.action !== 'synthesize') {
417
+ plan.action = 'delegate';
418
+ }
419
+ this.currentPlans.set(sessionId, plan);
420
+ return plan;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Update plan / evaluate progress after a round
426
+ */
427
+ async updatePlanForRound(options: UpdatePlanOptions): Promise<ResearchPlan> {
428
+ const { sessionId, query, complexity, round, model, reports, mustSynthesize, signal, observer, steeringMessages, modelRegistry } = options;
429
+
430
+ logger.log(`[PlanningService] Evaluating Round ${round} findings for: "${query}"`);
431
+
432
+ const config = options.config ?? getConfig(options.cwd);
433
+ const promptTemplate = loadPrompt('system-lead-evaluator');
434
+
435
+ const maxTeamSize = this.getTeamSize(complexity);
436
+ const queryBudget = this.getQueryBudget(complexity);
437
+ const maxRounds = _getMaxRounds(complexity);
438
+ const complexityGuidance = this.getEvaluatorComplexityGuidance(complexity);
439
+ const roundPhaseGuidance = this.getRoundPhaseGuidance(round, maxRounds, complexity, maxTeamSize);
440
+
441
+ // Inject steering if present
442
+ let steeringSection = '';
443
+ if (steeringMessages && steeringMessages.length > 0) {
444
+ steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Ensure findings follow these rules)\n' +
445
+ steeringMessages.map(m => `- ${m}`).join('\n');
446
+ }
447
+
448
+ const disabledToolsSection = options.excludeTools && options.excludeTools.length > 0
449
+ ? `\n## DISABLED TOOLS\nThe following tools are DISABLED for this session: ${options.excludeTools.join(', ')}. Do NOT reference or attempt to use them.\n`
450
+ : '';
451
+
452
+ const previousPlan = options.previousPlan;
453
+ const initialAgendaSection = previousPlan && previousPlan.researchers && previousPlan.researchers.length > 0
454
+ ? `\n## Initial Research Agenda\n${previousPlan.researchers.map(r => `- ${r.name}: ${r.goal}`).join('\n')}\n`
455
+ : '';
456
+
457
+ const previousQueries = this.getQueryHistory(sessionId);
458
+ const previousQueriesSection = previousQueries.length > 0
459
+ ? `\n## Previously Executed Queries\n${previousQueries.map(q => `- ${q}`).join('\n')}\n`
460
+ : '';
461
+
462
+ const systemPrompt = injectCurrentDate(promptTemplate, 'evaluator');
463
+ const populatedPrompt = this.populatePrompt(systemPrompt, {
464
+ root_query: query,
465
+ round_number: round,
466
+ max_rounds: maxRounds,
467
+ complexity_label: complexity === 1 ? 'Level 1 (Normal)' : complexity === 2 ? 'Level 2 (Deep)' : 'Level 3 (Ultra)',
468
+ initial_agenda_section: initialAgendaSection,
469
+ previous_queries_section: previousQueriesSection,
470
+ additional_considerations: steeringSection,
471
+ disabled_tools_section: disabledToolsSection,
472
+ complexity_guidance: complexityGuidance,
473
+ round_phase_guidance: roundPhaseGuidance,
474
+ max_team_size: maxTeamSize,
475
+ query_budget: queryBudget,
476
+ youtube_query_every_n: config.YOUTUBE_QUERY_EVERY_N,
477
+ });
478
+
479
+ // Normalize citations across all reports to ONE global numbering BEFORE the
480
+ // evaluator/synthesis LLM sees them, and hand it the matching Global Source
481
+ // List. The evaluator prompt promises exactly this ("reports have already
482
+ // been normalized to these global numbers"); doing it here is what makes the
483
+ // synthesized body's [N] line up with the final CITED LINKS list that
484
+ // ensureCitedLinks() regenerates from the same normalization.
485
+ const { normalizedReports, globalCitations } = normalizeCitations(reports);
486
+ const globalSourceList = globalCitations.length > 0
487
+ ? 'GLOBAL SOURCE LIST (use these exact [N] numbers for every inline citation):\n' +
488
+ globalCitations
489
+ .map((c) => `[${c.id}] ${c.url}${c.source ? ` [Source: ${c.source}]` : ''}${c.description ? ` — ${c.description}` : ''}`)
490
+ .join('\n') +
491
+ '\n\n---\n\n'
492
+ : '';
493
+ const findings = globalSourceList + Array.from(normalizedReports.entries())
494
+ .map(([id, report]) => `### Researcher ${id}\n${report}`)
495
+ .join('\n\n');
496
+
497
+ const userMessage = mustSynthesize
498
+ ? `Research budget exhausted. Synthesize final report now based on findings:\n\n${findings}`
499
+ : `Evaluate the following findings and decide next steps (delegate more researchers or synthesize final report):\n\n${findings}`;
500
+
501
+ try {
502
+ const authResult = await safeGetApiKeyAndHeaders(modelRegistry, model);
503
+ if (!authResult.ok) {
504
+ throw new Error(`Failed to get API key for evaluation: ${authResult.error}`);
505
+ }
506
+
507
+ const llmTimeout = config.LLM_TIMEOUT_MS;
508
+
509
+ // Retry the transport call + validation on transient failures (see generatePlan for the
510
+ // rationale — a "terminated" abort only throws at validateAndExtractText, so validation
511
+ // must be inside the retried region; repair/fallback stays outside).
512
+ const { response, responseText } = await retryWithBackoff(
513
+ async () => {
514
+ const response = await withTimeout(
515
+ completeSimple(model, {
516
+ systemPrompt: populatedPrompt,
517
+ messages: [
518
+ { role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
519
+ ],
520
+ // The evaluator's response carries the final report whenever it synthesizes —
521
+ // which it may choose to do on ANY round (voluntary early finish), not just the
522
+ // forced final round. So every evaluator call gets the full synthesis budget; a
523
+ // small decision response simply uses a fraction of it. Clamped to the model.
524
+ }, buildSafeOptions(model, {
525
+ apiKey: authResult.apiKey || '',
526
+ headers: authResult.headers,
527
+ signal
528
+ }, config.SYNTHESIS_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
529
+ llmTimeout, 'evaluator-updatePlanForRound',
530
+ );
531
+ const responseText = validateAndExtractText(response, 'Evaluator');
532
+ return { response, responseText };
533
+ },
534
+ {
535
+ maxRetries: LLM_MAX_RETRIES,
536
+ initialDelay: LLM_RETRY_INITIAL_DELAY_MS,
537
+ maxDelay: LLM_RETRY_MAX_DELAY_MS,
538
+ label: 'evaluator-updatePlanForRound',
539
+ signal,
540
+ isTransientError: (err) => isRetriableLlmError(err, signal),
541
+ },
542
+ );
543
+
544
+ // Track usage once, on the successful attempt (failed attempts throw before here).
545
+ recordLlmUsage(model, (response as any).usage, { component: 'evaluator', complexity, observer });
546
+
547
+ // Extract and validate JSON
548
+ let plan: ResearchPlan | null = null;
549
+ try {
550
+ plan = this.parseJsonPlan(responseText);
551
+ } catch {
552
+ logger.warn('[PlanningService] Evaluation JSON malformed, attempting agentic repair');
553
+ const repaired = await repairJsonWithLlm<ResearchPlan>(
554
+ responseText,
555
+ completeSimple,
556
+ { apiKey: authResult.apiKey || '', headers: authResult.headers },
557
+ {
558
+ model,
559
+ schema: EvaluationResponseSchemaAsTSchema,
560
+ context: `Research evaluation for: ${query} (Round ${round})`,
561
+ serviceName: 'PlanningService',
562
+ signal,
563
+ maxTokens: config.SYNTHESIS_MAX_TOKENS,
564
+ thinkingLevel: config.LLM_THINKING_LEVEL,
565
+ onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'evaluator', complexity, observer }),
566
+ }
567
+ );
568
+ if (repaired) {
569
+ // Validate repaired JSON
570
+ try {
571
+ plan = this.parseJsonPlan(JSON.stringify(repaired));
572
+ } catch {
573
+ plan = null;
574
+ }
575
+ }
576
+ }
577
+
578
+ if (!plan) {
579
+ // A parse failure here used to ALWAYS default to synthesize, which prematurely
580
+ // ended a run on a single transient/garbled evaluator response (with rounds still
581
+ // remaining) — and put the raw, often-truncated text into the report body.
582
+ // Only finalize early when synthesis was already mandatory (final round); otherwise,
583
+ // if there is a prior agenda to continue, delegate another round rather than giving up.
584
+ // Round budget (maxRounds) still bounds this, so it cannot loop indefinitely.
585
+ if (mustSynthesize) {
586
+ logger.warn('[PlanningService] Failed to generate valid evaluation on the final round; synthesizing from raw text');
587
+ const safeContent = salvageReportText(responseText);
588
+ plan = { action: 'synthesize', content: safeContent, researchers: [] };
589
+ } else if (previousPlan?.researchers && previousPlan.researchers.length > 0) {
590
+ logger.warn('[PlanningService] Evaluation unparseable mid-research; continuing the prior agenda rather than synthesizing early');
591
+ plan = { action: 'delegate', content: '', researchers: previousPlan.researchers };
592
+ } else {
593
+ logger.warn('[PlanningService] Evaluation unparseable and no prior agenda to continue; falling back to synthesize');
594
+ const safeContent = salvageReportText(responseText);
595
+ plan = { action: 'synthesize', content: safeContent, researchers: [] };
596
+ }
597
+ }
598
+
599
+ const finalPlan = plan as ResearchPlan;
600
+
601
+ // Respect mustSynthesize flag
602
+ if (mustSynthesize) {
603
+ finalPlan.action = 'synthesize';
604
+ }
605
+
606
+ // Final safety cap if delegating
607
+ if (finalPlan.action === 'delegate') {
608
+ const capped = this.capResearcherQueries(finalPlan, complexity, this.name);
609
+ this.currentPlans.set(sessionId, capped);
610
+ return capped;
611
+ }
612
+
613
+ this.currentPlans.set(sessionId, finalPlan);
614
+ return finalPlan;
615
+ } catch (err) {
616
+ // A genuine cancellation must propagate so the orchestrator can abort cleanly.
617
+ // Re-throw BEFORE logging so a quit-mid-run is a clean stop, not a red
618
+ // "Failed to update plan" line (matching generatePlan's abort hygiene).
619
+ if (signal?.aborted) throw err;
620
+ logger.error('[PlanningService] Failed to update plan:', err);
621
+ // Otherwise degrade gracefully: a transient evaluator failure (timeout, empty
622
+ // or provider error) reaches here BEFORE the JSON-parse fallback above and used
623
+ // to throw — aborting the whole run with no decision (looks like "the evaluator
624
+ // never came"). Mirror that fallback: continue the prior agenda mid-run, else
625
+ // synthesize from whatever reports were already collected. maxRounds still bounds it.
626
+ if (!mustSynthesize && previousPlan?.researchers && previousPlan.researchers.length > 0) {
627
+ logger.warn('[PlanningService] Evaluator call failed mid-research; continuing the prior agenda rather than aborting the run');
628
+ const fallback = this.capResearcherQueries(
629
+ { action: 'delegate', content: '', researchers: previousPlan.researchers },
630
+ complexity,
631
+ this.name,
632
+ );
633
+ this.currentPlans.set(sessionId, fallback);
634
+ return fallback;
635
+ }
636
+ logger.warn('[PlanningService] Evaluator call failed with no prior agenda; synthesizing from collected reports');
637
+ const fallback: ResearchPlan = { action: 'synthesize', content: '', researchers: [] };
638
+ this.currentPlans.set(sessionId, fallback);
639
+ return fallback;
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Generate queries for a researcher
645
+ */
646
+ async generateQueries(options: GenerateQueriesOptions): Promise<string[]> {
647
+ return options.researcher.queries || [];
648
+ }
649
+ }