@lincoln504/pi-research 0.1.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -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 +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -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 +1044 -0
  30. package/src/config.ts +1036 -147
  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 +264 -7
  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 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  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,178 @@
1
+ /**
2
+ * Research Session Service
3
+ *
4
+ * Manages the lifecycle of researcher agent sessions.
5
+ * Responsible for:
6
+ * - Session creation and tracking
7
+ * - Session cleanup and abort handling
8
+ * - Active sessions registry management
9
+ */
10
+
11
+ import type { AgentSession } from '@earendil-works/pi-coding-agent';
12
+ import { logger } from '../logger.ts';
13
+ import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
14
+ import { ServiceNames } from '../core/interfaces/service-names.ts';
15
+
16
+ /**
17
+ * Session entry with abort capability
18
+ */
19
+ export interface SessionEntry {
20
+ session: AgentSession;
21
+ abort(): Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * Research Session Service
26
+ *
27
+ * Manages the lifecycle of researcher agent sessions.
28
+ */
29
+ export class ResearchSessionService implements IService {
30
+ readonly name = ServiceNames.RESEARCH_SESSION_SERVICE;
31
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
32
+
33
+ // Map of sessionId -> Map<id, SessionEntry>
34
+ private sessions = new Map<string, Map<string, SessionEntry>>();
35
+
36
+ // Create-on-write: use ONLY from registerSession. Read/delete paths must use peekSessionMap
37
+ // so they never resurrect an empty map (which would leak one entry per completed researchId).
38
+ private getSessionMap(sessionId: string): Map<string, SessionEntry> {
39
+ let sessionMap = this.sessions.get(sessionId);
40
+ if (!sessionMap) {
41
+ sessionMap = new Map<string, SessionEntry>();
42
+ this.sessions.set(sessionId, sessionMap);
43
+ }
44
+ return sessionMap;
45
+ }
46
+
47
+ // Read-only accessor: never inserts. Returns undefined when no sessions exist for the id.
48
+ private peekSessionMap(sessionId: string): Map<string, SessionEntry> | undefined {
49
+ return this.sessions.get(sessionId);
50
+ }
51
+
52
+ /**
53
+ * Register an active researcher session
54
+ */
55
+ registerSession(sessionId: string, id: string, session: AgentSession, abortFn: () => Promise<void>): void {
56
+ this.getSessionMap(sessionId).set(id, { session, abort: abortFn });
57
+ }
58
+
59
+ /**
60
+ * Get an active session by ID
61
+ */
62
+ getSession(sessionId: string, id: string): SessionEntry | undefined {
63
+ return this.peekSessionMap(sessionId)?.get(id);
64
+ }
65
+
66
+ /**
67
+ * Check if a session is active
68
+ */
69
+ hasSession(sessionId: string, id: string): boolean {
70
+ return this.peekSessionMap(sessionId)?.has(id) ?? false;
71
+ }
72
+
73
+ /**
74
+ * Unregister a session (no abort). Prunes the per-run map once empty so a long-lived host
75
+ * does not accumulate empty maps keyed by completed research runs.
76
+ */
77
+ unregisterSession(sessionId: string, id: string): void {
78
+ const sessionMap = this.peekSessionMap(sessionId);
79
+ if (!sessionMap) return;
80
+ sessionMap.delete(id);
81
+ if (sessionMap.size === 0) this.sessions.delete(sessionId);
82
+ }
83
+
84
+ /**
85
+ * Abort and unregister a specific session
86
+ */
87
+ async abortSession(sessionId: string, id: string): Promise<void> {
88
+ const sessionMap = this.peekSessionMap(sessionId);
89
+ if (!sessionMap) return;
90
+ const entry = sessionMap.get(id);
91
+ if (!entry) return;
92
+
93
+ try {
94
+ await entry.abort();
95
+ } catch (err) {
96
+ logger.warn(`[ResearchSessionService] Failed to abort session ${id}:`, err);
97
+ }
98
+ sessionMap.delete(id);
99
+ }
100
+
101
+ /**
102
+ * Abort all active sessions for a specific sessionId, or all sessions if sessionId is omitted
103
+ */
104
+ async abortAllSessions(sessionId?: string): Promise<void> {
105
+ if (sessionId) {
106
+ const sessionMap = this.getSessionMap(sessionId);
107
+ const aborts = Array.from(sessionMap.values()).map((entry, index) =>
108
+ entry.abort().catch((err) => {
109
+ logger.warn(`[ResearchSessionService] Failed to abort session ${index}:`, err);
110
+ })
111
+ );
112
+ await Promise.all(aborts);
113
+ sessionMap.clear();
114
+ this.sessions.delete(sessionId);
115
+ } else {
116
+ const aborts: Promise<void>[] = [];
117
+ for (const sessionMap of this.sessions.values()) {
118
+ aborts.push(...Array.from(sessionMap.values()).map((entry, index) =>
119
+ entry.abort().catch((err) => {
120
+ logger.warn(`[ResearchSessionService] Failed to abort session ${index}:`, err);
121
+ })
122
+ ));
123
+ }
124
+ await Promise.all(aborts);
125
+ this.sessions.clear();
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Get count of active sessions
131
+ */
132
+ getActiveSessionCount(sessionId: string): number {
133
+ return this.peekSessionMap(sessionId)?.size ?? 0;
134
+ }
135
+
136
+ /**
137
+ * Get all active session IDs
138
+ */
139
+ getActiveSessionIds(sessionId: string): string[] {
140
+ const sessionMap = this.peekSessionMap(sessionId);
141
+ return sessionMap ? Array.from(sessionMap.keys()) : [];
142
+ }
143
+
144
+ /**
145
+ * Clean up all sessions (alias for abortAllSessions)
146
+ */
147
+ async cleanup(sessionId?: string): Promise<void> {
148
+ await this.abortAllSessions(sessionId);
149
+ }
150
+
151
+ /**
152
+ * Reset service state
153
+ */
154
+ reset(): void {
155
+ this.sessions.clear();
156
+ }
157
+
158
+ async initialize(): Promise<void> {
159
+ if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
160
+ return;
161
+ }
162
+ this.lifecycle = ServiceLifecycle.INITIALIZING;
163
+ logger.debug('[ResearchSessionService] Initializing...');
164
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
165
+ logger.debug('[ResearchSessionService] Initialized');
166
+ }
167
+
168
+ async dispose(): Promise<void> {
169
+ if (this.lifecycle === ServiceLifecycle.DISPOSED) {
170
+ return;
171
+ }
172
+ this.lifecycle = ServiceLifecycle.DISPOSING;
173
+ logger.debug('[ResearchSessionService] Disposing...');
174
+ await this.cleanup();
175
+ this.lifecycle = ServiceLifecycle.DISPOSED;
176
+ logger.debug('[ResearchSessionService] Disposed');
177
+ }
178
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Research Synthesis Service
3
+ *
4
+ * Responsible for aggregating researcher reports and building the final synthesis.
5
+ * Handles:
6
+ * - Report collection and storage
7
+ * - Fallback synthesis building
8
+ * - Citation extraction and management
9
+ * - Ensuring CITED LINKS section in output
10
+ */
11
+
12
+ import { parseCitations } from '../utils/text-utils.ts';
13
+ import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
14
+ import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
15
+ import { getScrapedLinks, normalizeUrl } from '../utils/shared-links.ts';
16
+ import { logger } from '../logger.ts';
17
+ import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
18
+ import { ServiceNames } from '../core/interfaces/service-names.ts';
19
+ import type { SteeringMessage } from './session-state.ts';
20
+
21
+ // Shown in place of a sources list when a run retrieved no citable web sources at
22
+ // all (no parseable citations AND no successfully-scraped URLs). Keeping the
23
+ // "CITED LINKS" header means downstream consumers and the user always find the
24
+ // section in its usual place; the italic note makes the empty result explicit
25
+ // rather than letting an uncited report read as if it were sourced.
26
+ const NO_SOURCES_NOTE =
27
+ 'CITED LINKS\n_No web sources were successfully retrieved for this report — searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._';
28
+
29
+ /**
30
+ * Research Synthesis Service
31
+ *
32
+ * Aggregates researcher reports and builds final synthesis.
33
+ */
34
+ export class ResearchSynthesisService implements IService {
35
+ readonly name = ServiceNames.RESEARCH_SYNTHESIS_SERVICE;
36
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
37
+
38
+ // Map of sessionId -> Map<reportId, reportContent>
39
+ private sessions = new Map<string, Map<string, string>>();
40
+ // Cap to prevent unbounded growth from orphaned sessions during long-lived Pi
41
+ // sessions. Generous because each session map is small and the normal path clears
42
+ // reports via cleanupResearchServices on run completion — this is only a backstop
43
+ // for runs that never cleaned up (e.g. crashes).
44
+ private static readonly MAX_SESSIONS = 200;
45
+
46
+ private getSessionReports(sessionId: string): Map<string, string> {
47
+ let reports = this.sessions.get(sessionId);
48
+ if (reports) {
49
+ // LRU touch: re-insert to move to the end so an actively-written session is
50
+ // never the eviction victim below (Map preserves insertion order).
51
+ this.sessions.delete(sessionId);
52
+ this.sessions.set(sessionId, reports);
53
+ return reports;
54
+ }
55
+ // Evict the least-recently-used session when at capacity. Because every
56
+ // store/read touches its session to the end (above), the head is the genuinely
57
+ // stalest session, not merely the first-created — so an in-flight run is spared.
58
+ if (this.sessions.size >= ResearchSynthesisService.MAX_SESSIONS) {
59
+ const oldestKey = this.sessions.keys().next().value;
60
+ if (oldestKey !== undefined) {
61
+ logger.warn(`[ResearchSynthesisService] Session limit (${ResearchSynthesisService.MAX_SESSIONS}) reached, evicting least-recently-used: ${oldestKey}`);
62
+ this.sessions.delete(oldestKey);
63
+ }
64
+ }
65
+ reports = new Map<string, string>();
66
+ this.sessions.set(sessionId, reports);
67
+ return reports;
68
+ }
69
+
70
+ /**
71
+ * Store a researcher report
72
+ * @param sessionId - Session identifier
73
+ * @param id - Report identifier (typically "round.researcherId")
74
+ * @param report - The researcher's report content
75
+ */
76
+ storeReport(sessionId: string, id: string, report: string): void {
77
+ this.getSessionReports(sessionId).set(id, report);
78
+ }
79
+
80
+ /**
81
+ * Get a report by ID
82
+ */
83
+ getReport(sessionId: string, id: string): string | undefined {
84
+ return this.getSessionReports(sessionId).get(id);
85
+ }
86
+
87
+ /**
88
+ * Get all reports
89
+ */
90
+ getAllReports(sessionId: string): Map<string, string> {
91
+ return new Map(this.getSessionReports(sessionId));
92
+ }
93
+
94
+ /**
95
+ * Get reports for a specific round
96
+ */
97
+ getReportsForRound(sessionId: string, round: number): Map<string, string> {
98
+ const roundReports = new Map<string, string>();
99
+ const prefix = `${round}.`;
100
+ for (const [key, report] of this.getSessionReports(sessionId).entries()) {
101
+ if (key.startsWith(prefix)) {
102
+ roundReports.set(key, report);
103
+ }
104
+ }
105
+ return roundReports;
106
+ }
107
+
108
+ /**
109
+ * Get total number of reports
110
+ */
111
+ getReportCount(sessionId: string): number {
112
+ return this.getSessionReports(sessionId).size;
113
+ }
114
+
115
+ /**
116
+ * Check if there are any reports
117
+ */
118
+ hasReports(sessionId: string): boolean {
119
+ return this.getSessionReports(sessionId).size > 0;
120
+ }
121
+
122
+ /**
123
+ * Clear reports for a session, or all reports if no sessionId provided
124
+ */
125
+ clearReports(sessionId?: string): void {
126
+ if (sessionId) {
127
+ this.sessions.delete(sessionId);
128
+ } else {
129
+ this.sessions.clear();
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Build fallback synthesis from collected reports
135
+ * @param sessionId - Session identifier
136
+ * @param currentRound - Current research round number
137
+ * @returns Fallback synthesis string
138
+ */
139
+ buildFallbackSynthesis(sessionId: string, currentRound: number = 0): string {
140
+ const reports = this.getSessionReports(sessionId);
141
+ const reportCount = reports.size;
142
+ const roundInfo = currentRound > 0 ? ` (up to Round ${currentRound})` : '';
143
+ let synthesis = `Research Findings${roundInfo}\n\n`;
144
+
145
+ if (reportCount === 0) {
146
+ synthesis += 'No researcher reports were generated before the process stopped.';
147
+ } else {
148
+ synthesis += `This is an automated synthesis of ${reportCount} individual researcher report(s) gathered before the process was interrupted.\n\n`;
149
+ synthesis += Array.from(reports.entries())
150
+ .map(([id, report]) => `Researcher ${id}\n\n${report}`)
151
+ .join('\n\n');
152
+ }
153
+
154
+ return synthesis;
155
+ }
156
+
157
+ /**
158
+ * Append research metadata (model used) to the end of the synthesis
159
+ */
160
+ appendMetadata(synthesis: string, modelId: string): string {
161
+ return `${synthesis.trim()}\n\nResearch performed using ${modelId}`;
162
+ }
163
+
164
+ /**
165
+ * Ensure the synthesis has an accurate and consistent CITED LINKS section.
166
+ * Rebuilds the section from all researcher reports to guarantee sequential numbering [1], [2], [3]...
167
+ * and unique URLs, regardless of what the LLM produced.
168
+ *
169
+ * @param sessionId - Session identifier
170
+ * @param synthesis - The synthesis text to check and potentially augment
171
+ * @returns Synthesis with guaranteed and verified CITED LINKS section
172
+ */
173
+ ensureCitedLinks(sessionId: string, synthesis: string): string {
174
+ const reports = this.getSessionReports(sessionId);
175
+
176
+ // Primary source: citations the researchers wrote into their reports. These are
177
+ // the richest (they carry descriptions and source tags), so they win when present.
178
+ let globalCitations: GlobalCitation[] =
179
+ reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
180
+
181
+ // Robustness fallback: if the reports yielded no parseable citations — e.g. the
182
+ // LLM omitted or mangled its "CITED LINKS" block, or wrote the sources under a
183
+ // heading parseCitations does not recognize — rebuild the sources list from the
184
+ // ground-truth provenance: the URLs the researchers ACTUALLY, successfully
185
+ // scraped this session (registerScrapedLinks only records HTTP-success fetches).
186
+ // This decouples the final sources list from fragile LLM prose formatting so that
187
+ // real fetched sources are never silently lost.
188
+ if (globalCitations.length === 0) {
189
+ const scraped = getScrapedLinks(sessionId);
190
+ if (scraped.length > 0) {
191
+ globalCitations = scraped.map((url, i) => ({ id: i + 1, url, description: '', source: 'Scrape' }));
192
+ logger.warn(
193
+ `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape provenance.`,
194
+ );
195
+ }
196
+ }
197
+
198
+ if (globalCitations.length > 0) {
199
+ const verifiedLinksSection = formatCitedLinks(globalCitations);
200
+ // Replace an existing CITED LINKS section with the verified version — but only
201
+ // a line-leading, all-caps "CITED LINKS" header marks the section. An
202
+ // unanchored /CITED LINKS[\s\S]*$/i would also fire on an incidental
203
+ // "...the cited links below..." in prose and truncate the entire report body
204
+ // from that point (silent content loss). Anchor to the LAST header line.
205
+ const citedHeaderIdx = lastCitedLinksHeaderIndex(synthesis);
206
+ if (citedHeaderIdx >= 0) {
207
+ logger.debug('[ResearchSynthesisService] Replacing existing CITED LINKS with verified version');
208
+ // The verified list is renumbered (dedup by URL, implausible entries dropped),
209
+ // but the body's inline [N] markers were authored against the list being
210
+ // replaced. Remap them by URL identity — parse the synthesis's own list and
211
+ // map each written number to the global id of the same normalized URL. In
212
+ // deep mode the numbering already matches and this is a no-op; in quick mode
213
+ // (where the model's own list is what gets replaced) it prevents every
214
+ // marker after a dropped/deduped entry from pointing at the wrong source.
215
+ const ownCitations = parseCitations(synthesis);
216
+ const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
217
+ const localToGlobal = new Map<number, number>();
218
+ ownCitations.forEach((cit, index) => {
219
+ const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
220
+ if (globalId !== undefined) localToGlobal.set(cit.number ?? index + 1, globalId);
221
+ });
222
+ let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
223
+ const needsRemap = [...localToGlobal].some(([local, global]) => local !== global);
224
+ if (needsRemap) {
225
+ body = body.replace(/\[(\d+)\]/g, (match, p1) => {
226
+ const globalId = localToGlobal.get(parseInt(p1, 10));
227
+ return globalId !== undefined ? `[${globalId}]` : match;
228
+ });
229
+ }
230
+ return `${body}\n\n${verifiedLinksSection}`;
231
+ }
232
+ logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
233
+ return `${synthesis.trim()}\n\n${verifiedLinksSection}`;
234
+ }
235
+
236
+ // Genuinely no sources were retrieved (e.g. every search/scrape was blocked by
237
+ // bot protection, or none returned usable content). Never silently ship an
238
+ // uncited report that looks sourced — but also never destroy a CITED LINKS block
239
+ // the model may have written that we simply could not parse (preserve as-is).
240
+ if (/CITED LINKS/i.test(synthesis)) {
241
+ return synthesis;
242
+ }
243
+ logger.warn('[ResearchSynthesisService] No citations and no scrape provenance — appending explicit no-sources note.');
244
+ return `${synthesis.trim()}\n\n${NO_SOURCES_NOTE}`;
245
+ }
246
+
247
+ /**
248
+ * Append steering guidance to the end of the synthesis.
249
+ * Only active (consumed-by-orchestrator) steering messages are included;
250
+ * queued and popped messages are excluded.
251
+ *
252
+ * @param synthesis - The synthesis text
253
+ * @param steeringMessages - Steering messages captured for the session
254
+ * @returns Synthesis with steering guidance appended
255
+ */
256
+ appendSteeringGuidance(synthesis: string, steeringMessages: SteeringMessage[]): string {
257
+ const texts = (steeringMessages ?? [])
258
+ .filter(m => m.status === 'active')
259
+ .map(m => m.text);
260
+
261
+ if (texts.length === 0) {
262
+ return synthesis;
263
+ }
264
+
265
+ const guidanceSection = [
266
+ 'The following guidance was provided by the user during the research process and influenced these results:',
267
+ '',
268
+ ...texts,
269
+ ].join('\n');
270
+
271
+ return `${synthesis.trim()}\n\n${guidanceSection}`;
272
+ }
273
+
274
+ /**
275
+ * Extract all citations from all reports
276
+ * @param sessionId - Session identifier
277
+ * @returns Array of unique citations across all reports
278
+ */
279
+ extractAllCitations(sessionId: string): Array<{ url: string; description: string; source?: string }> {
280
+ const seen = new Set<string>();
281
+ const allCitations: Array<{ url: string; description: string; source?: string }> = [];
282
+ const reports = this.getSessionReports(sessionId);
283
+
284
+ for (const report of reports.values()) {
285
+ const citations = parseCitations(report);
286
+ for (const cit of citations) {
287
+ if (!seen.has(cit.url)) {
288
+ seen.add(cit.url);
289
+ allCitations.push(cit);
290
+ }
291
+ }
292
+ }
293
+
294
+ return allCitations;
295
+ }
296
+
297
+ /**
298
+ * Get citations for a specific round
299
+ * @param sessionId - Session identifier
300
+ * @param round - Round number
301
+ * @returns Array of citations from reports in the specified round
302
+ */
303
+ extractCitationsForRound(sessionId: string, round: number): Array<{ url: string; description: string; source?: string }> {
304
+ const seen = new Set<string>();
305
+ const citations: Array<{ url: string; description: string; source?: string }> = [];
306
+ const roundReports = this.getReportsForRound(sessionId, round);
307
+
308
+ for (const report of roundReports.values()) {
309
+ const parsedCitations = parseCitations(report);
310
+ for (const cit of parsedCitations) {
311
+ if (!seen.has(cit.url)) {
312
+ seen.add(cit.url);
313
+ citations.push(cit);
314
+ }
315
+ }
316
+ }
317
+
318
+ return citations;
319
+ }
320
+
321
+ /**
322
+ * Reset service state
323
+ */
324
+ reset(): void {
325
+ this.sessions.clear();
326
+ }
327
+
328
+ async initialize(): Promise<void> {
329
+ if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
330
+ return;
331
+ }
332
+ this.lifecycle = ServiceLifecycle.INITIALIZING;
333
+ logger.debug('[ResearchSynthesisService] Initializing...');
334
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
335
+ logger.debug('[ResearchSynthesisService] Initialized');
336
+ }
337
+
338
+ async dispose(): Promise<void> {
339
+ if (this.lifecycle === ServiceLifecycle.DISPOSED) {
340
+ return;
341
+ }
342
+ this.lifecycle = ServiceLifecycle.DISPOSING;
343
+ logger.debug('[ResearchSynthesisService] Disposing...');
344
+ this.sessions.clear();
345
+ this.lifecycle = ServiceLifecycle.DISPOSED;
346
+ logger.debug('[ResearchSynthesisService] Disposed');
347
+ }
348
+ }