@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
@@ -1,123 +1,228 @@
1
1
  /**
2
- * Shared Links Pool Management
2
+ * Shared Links Registry
3
3
  *
4
- * Centralized deterministic link state management system.
5
- * Single source of truth: global allScrapedLinks array.
6
- * No per-session state - everything flows through global state.
4
+ * Provides a global registry of links scraped across all researchers
5
+ * in a session to prevent redundant work.
7
6
  */
8
7
 
9
- import * as crypto from 'node:crypto';
8
+ import { normalizeUrl } from './url-utils.ts';
9
+ export { normalizeUrl };
10
+ import { logger } from '../logger.ts';
11
+ import { safeUnref } from './safe-unref.ts';
12
+ import { truncateWithMarker } from './text-utils.ts';
13
+ import { MAX_SCRAPE_CONTENT_CHARS_PER_DOC } from '../constants.ts';
14
+ import { randomUUID } from 'node:crypto';
15
+
16
+ const sessionLinks = new Map<string, Set<string>>();
17
+ const sessionScrapedContent = new Map<string, Map<string, string>>();
18
+ // Per-researcher scrape tracking: researchId → researcherId → Set<normalizedUrl>
19
+ const researcherScrapes = new Map<string, Map<string, Set<string>>>();
20
+
21
+ // FIX (Issue 12): Track creation timestamps for orphaned-entry cleanup.
22
+ const sessionTimestamps = new Map<string, number>();
23
+ const SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
24
+ // FIX (#20): Cap per-session cached content to prevent unbounded memory growth
25
+ const MAX_CACHED_CONTENT_PER_SESSION = 500;
26
+
27
+ // Periodic cleanup of orphaned sessions (every 15 minutes)
28
+ let cleanupInterval: NodeJS.Timeout | null = null;
29
+
30
+ function startCleanupTimer(): void {
31
+ if (cleanupInterval) return;
32
+ cleanupInterval = setInterval(() => {
33
+ const now = Date.now();
34
+ let cleaned = 0;
35
+ for (const [researchId, createdAt] of sessionTimestamps.entries()) {
36
+ if (now - createdAt > SESSION_MAX_AGE_MS) {
37
+ sessionLinks.delete(researchId);
38
+ sessionScrapedContent.delete(researchId);
39
+ sessionTimestamps.delete(researchId);
40
+ researcherScrapes.delete(researchId);
41
+ cleaned++;
42
+ }
43
+ }
44
+ if (cleaned > 0) {
45
+ logger.debug(`[Shared Links] Cleaned up ${cleaned} orphaned session(s) older than ${SESSION_MAX_AGE_MS / 60000} minutes`);
46
+ }
47
+ }, 15 * 60 * 1000);
48
+ if (cleanupInterval) safeUnref(cleanupInterval);
49
+ }
50
+
51
+ // Start on module load
52
+ startCleanupTimer();
10
53
 
11
54
  /**
12
- * Generate a 4-character alphanumeric hash
55
+ * Cache the raw markdown of a scraped URL during a research session.
56
+ * This is used later to embed the raw text alongside the agent's summary.
13
57
  */
14
- function generate4CharHash(): string {
15
- // Use randomBytes for Node.js environments, fallback to Math.random for test environments
16
- let hash: string;
17
- try {
18
- const bytes = crypto.randomBytes(2);
19
- hash = bytes.toString('hex').substring(0, 4);
20
- } catch {
21
- // Fallback for test environments where crypto.randomBytes might not be available
22
- // Generate a valid 4-character hex string
23
- const randomHex = Math.floor(Math.random() * 65536).toString(16).padStart(4, '0');
24
- hash = randomHex;
25
- }
26
- return hash;
58
+ export function cacheScrapedContent(researchId: string, url: string, content: string) {
59
+ if (!sessionScrapedContent.has(researchId)) {
60
+ sessionScrapedContent.set(researchId, new Map());
61
+ }
62
+ // Refresh on every write so the orphan-cleanup TTL is last-activity based, not
63
+ // creation based — otherwise a research session running longer than the 2h
64
+ // SESSION_MAX_AGE_MS has its dedup/content pools wiped mid-run.
65
+ sessionTimestamps.set(researchId, Date.now());
66
+ const cache = sessionScrapedContent.get(researchId)!;
67
+ const key = normalizeUrl(url);
68
+ // FIX (#20): Evict oldest entry only when at capacity AND inserting a NEW key.
69
+ // Re-caching an existing URL must not evict an unrelated entry (net size loss).
70
+ if (!cache.has(key) && cache.size >= MAX_CACHED_CONTENT_PER_SESSION) {
71
+ const firstKey = cache.keys().next().value;
72
+ if (firstKey !== undefined) cache.delete(firstKey);
73
+ }
74
+ // Byte-bound each entry: the cache was previously only COUNT-capped (500
75
+ // entries), so 500 × multi-MB documents could grow unbounded in memory —
76
+ // and cached entries are re-served verbatim into researcher LLM sessions
77
+ // (scrape duplicate path) and embedded by the knowledge-store writer.
78
+ cache.set(key, truncateWithMarker(content, MAX_SCRAPE_CONTENT_CHARS_PER_DOC));
27
79
  }
28
80
 
29
81
  /**
30
- * Generate a unique session ID with 4-char hash
31
- * @param baseId Base identifier (e.g., request ID)
32
- * @returns Unique session ID (e.g., "abc123-x9k2")
82
+ * Retrieve the cached raw markdown for a scraped URL.
33
83
  */
34
- export function generateSessionId(baseId: string): string {
35
- const hash = generate4CharHash();
36
- return `${baseId}-${hash}`;
84
+ export function getCachedScrapedContent(researchId: string, url: string): string | undefined {
85
+ return sessionScrapedContent.get(researchId)?.get(normalizeUrl(url));
37
86
  }
38
87
 
39
88
  /**
40
- * Normalize a URL for comparison (lowercase, remove trailing slash)
89
+ * Generate a unique research ID based on the parent Pi session.
41
90
  */
42
- export function normalizeUrl(url: string): string {
43
- try {
44
- const normalized = url.toLowerCase().trim();
45
- // Remove trailing slash except for root
46
- if (normalized.length > 1 && normalized.endsWith('/')) {
47
- return normalized.slice(0, -1);
48
- }
49
- return normalized;
50
- } catch {
51
- return url.toLowerCase().trim();
52
- }
91
+ export function generateSessionId(piSessionId: string): string {
92
+ return `${piSessionId}-${randomUUID().replace(/-/g, '').substring(0, 8)}`;
53
93
  }
54
94
 
55
95
  /**
56
- * Deduplicate a list of URLs against already-scraped URLs.
57
- * Uses normalized comparison for accuracy.
58
- *
59
- * @param urls - URLs to check
60
- * @param alreadyScraped - Array of already-scraped URLs
61
- * @returns Object with kept URLs and removed duplicates
96
+ * Register links as scraped for a specific session.
62
97
  */
63
- export function deduplicateUrls(urls: string[], alreadyScraped: string[]): {
64
- kept: string[];
65
- duplicates: string[];
66
- } {
67
- const normalizedAlreadyScraped = new Set(
68
- alreadyScraped.map(normalizeUrl)
69
- );
70
-
71
- const kept: string[] = [];
72
- const duplicates: string[] = [];
73
-
74
- for (const url of urls) {
75
- const normalized = normalizeUrl(url);
76
- if (normalizedAlreadyScraped.has(normalized)) {
77
- duplicates.push(url);
78
- } else {
79
- kept.push(url);
98
+ export function registerScrapedLinks(researchId: string, links: string[]) {
99
+ if (!sessionLinks.has(researchId)) {
100
+ sessionLinks.set(researchId, new Set());
80
101
  }
81
- }
102
+ sessionTimestamps.set(researchId, Date.now()); // last-activity TTL refresh
103
+ const pool = sessionLinks.get(researchId)!;
104
+ links.forEach(l => pool.add(normalizeUrl(l)));
105
+ }
106
+
107
+ /**
108
+ * Get all scraped links for a session.
109
+ */
110
+ export function getScrapedLinks(researchId: string): string[] {
111
+ return Array.from(sessionLinks.get(researchId) || []);
112
+ }
113
+
114
+ /**
115
+ * Deduplicate a list of candidate URLs against already scraped links.
116
+ */
117
+ export function deduplicateUrls(urls: string[], researchId: string): { kept: string[], duplicates: string[] } {
118
+ const pool = sessionLinks.get(researchId) || new Set();
119
+ const kept: string[] = [];
120
+ const duplicates: string[] = [];
121
+
122
+ urls.forEach(url => {
123
+ const normalized = normalizeUrl(url);
124
+ if (pool.has(normalized)) {
125
+ duplicates.push(url); // Keep original URL for logging/display
126
+ } else {
127
+ kept.push(url);
128
+ }
129
+ });
130
+
131
+ return { kept, duplicates };
132
+ }
82
133
 
83
- return { kept, duplicates };
134
+
135
+ /**
136
+ * Cleanup session data.
137
+ */
138
+ export function cleanupSharedLinks(researchId: string) {
139
+ sessionLinks.delete(researchId);
140
+ sessionScrapedContent.delete(researchId);
141
+ sessionTimestamps.delete(researchId);
142
+ researcherScrapes.delete(researchId);
143
+ logger.debug(`[Shared Links] Cleaned up session: ${researchId}`);
84
144
  }
85
145
 
86
146
  /**
87
- * Format all completed reports into a shared links section for researcher prompts.
88
- * Extracts links from CITED LINKS and SCRAPE CANDIDATES sections.
147
+ * Cleanup ALL session data and stop the background timer.
148
+ * Used during system shutdown to prevent memory leaks.
89
149
  */
90
- export function formatSharedLinksFromState(aspects: Record<string, { id: string; query: string; report?: string }>): string {
91
- const completed = Object.values(aspects).filter(a => a.report);
92
- if (completed.length === 0) return '';
93
-
94
- let output = '## Shared Links from Previous Research\n\n';
95
- output += 'The following links have already been examined by your siblings:\n\n';
96
-
97
- for (const aspect of completed) {
98
- const response = aspect.report!;
99
- output += `### Aspect: ${aspect.query} (ID: ${aspect.id})\n\n`;
100
-
101
- // Extract CITED LINKS
102
- const citedMatch = response.match(/###?\s*CITED\s*LINKS?\s*\n([\s\S]*?)(?=\n###|$)/i);
103
- if (citedMatch) {
104
- output += '#### CITED LINKS\n';
105
- output += citedMatch[1]!.trim() + '\n\n';
150
+ export function clearAllSharedLinks(): void {
151
+ if (cleanupInterval) {
152
+ clearInterval(cleanupInterval);
153
+ cleanupInterval = null;
106
154
  }
155
+ sessionLinks.clear();
156
+ sessionScrapedContent.clear();
157
+ sessionTimestamps.clear();
158
+ researcherScrapes.clear();
159
+ logger.debug('[Shared Links] All global state cleared');
160
+ }
107
161
 
108
- // Extract SCRAPE CANDIDATES
109
- const candidatesMatch = response.match(/###?\s*SCRAPE\s*CANDIDATES?\s*\n([\s\S]*?)(?=\n###|$)/i);
110
- if (candidatesMatch) {
111
- output += '#### SCRAPE CANDIDATES\n';
112
- output += candidatesMatch[1]!.trim() + '\n\n';
162
+ /**
163
+ * Register that a specific researcher scraped a set of URLs.
164
+ */
165
+ export function registerResearcherScrapes(researchId: string, researcherId: string, urls: string[]) {
166
+ if (!researcherScrapes.has(researchId)) {
167
+ researcherScrapes.set(researchId, new Map());
113
168
  }
114
- }
169
+ sessionTimestamps.set(researchId, Date.now()); // keep this session reapable + TTL fresh
170
+ const map = researcherScrapes.get(researchId)!;
171
+ if (!map.has(researcherId)) {
172
+ map.set(researcherId, new Set());
173
+ }
174
+ const set = map.get(researcherId)!;
175
+ urls.forEach(url => set.add(normalizeUrl(url)));
176
+ }
177
+
178
+ /**
179
+ * Check if a specific researcher scraped a given URL.
180
+ */
181
+ export function didResearcherScrape(researchId: string, researcherId: string, url: string): boolean {
182
+ return researcherScrapes.get(researchId)?.get(researcherId)?.has(normalizeUrl(url)) ?? false;
183
+ }
115
184
 
116
- output += '**Review these links before scraping to avoid redundancy.**\n';
117
- return output;
185
+ /**
186
+ * Get all URLs scraped by a specific researcher.
187
+ */
188
+ export function getResearcherScrapes(researchId: string, researcherId: string): string[] {
189
+ return Array.from(researcherScrapes.get(researchId)?.get(researcherId) ?? []);
118
190
  }
119
191
 
120
192
  /**
121
- * Legacy stub for compatibility
193
+ * Build the Session URL Pool footer for scrape responses.
194
+ * Shows URLs previously scraped in the session, filtered to exclude the current batch.
195
+ * Capped at MAX_FOOTER_URLS (20).
122
196
  */
123
- export function cleanupSharedLinks(_sessionId: string): void {}
197
+ const MAX_FOOTER_URLS = 20;
198
+
199
+ export function buildSessionPoolFooter(researchId: string, currentBatchUrls: string[], researcherId?: string): string {
200
+ const pool = sessionLinks.get(researchId);
201
+ if (!pool || pool.size === 0) return '';
202
+
203
+ const currentNormalized = new Set(currentBatchUrls.map(u => normalizeUrl(u)));
204
+ // Also exclude URLs the current researcher already scraped in earlier batches
205
+ if (researcherId) {
206
+ const ownScrapes = researcherScrapes.get(researchId)?.get(researcherId);
207
+ if (ownScrapes) {
208
+ for (const url of ownScrapes) {
209
+ currentNormalized.add(url);
210
+ }
211
+ }
212
+ }
213
+ const eligible = Array.from(pool).filter(u => !currentNormalized.has(u));
214
+ if (eligible.length === 0) return '';
215
+
216
+ const capped = eligible.slice(-MAX_FOOTER_URLS);
217
+ const overflow = eligible.length - capped.length;
218
+
219
+ let footer = '\n---\n## Session URL Pool\n';
220
+ footer += 'Info only — URLs scraped by other researchers in this session. Do NOT use these in your report.\n';
221
+ for (const url of capped) {
222
+ footer += `- ${url}\n`;
223
+ }
224
+ if (overflow > 0) {
225
+ footer += `*...and ${overflow} more*\n`;
226
+ }
227
+ return footer;
228
+ }
@@ -1,9 +1,24 @@
1
1
  import { logger } from '../logger.ts';
2
+ import { safeUnref } from './safe-unref.ts';
2
3
 
3
4
  export type CleanupTask = () => Promise<void> | void;
4
5
 
6
+ // Minimal interface matching Node.js EventEmitter and process
7
+ interface EventEmitterLike {
8
+ on(event: string | symbol, listener: (...args: any[]) => void): any;
9
+ off?(event: string | symbol, listener: (...args: any[]) => void): any;
10
+ removeListener?(event: string | symbol, listener: (...args: any[]) => void): any;
11
+ }
12
+
13
+ interface EventListenerCleanup {
14
+ target: EventEmitterLike;
15
+ event: string;
16
+ listener: (...args: any[]) => void;
17
+ }
18
+
5
19
  export class ShutdownManager {
6
20
  private tasks: CleanupTask[] = [];
21
+ private eventListeners: EventListenerCleanup[] = [];
7
22
  private cleanupPromise: Promise<void> | null = null;
8
23
 
9
24
  register(task: CleanupTask) {
@@ -14,6 +29,30 @@ export class ShutdownManager {
14
29
  this.tasks.push(task);
15
30
  }
16
31
 
32
+ registerEventListener<T extends EventEmitterLike>(
33
+ target: T,
34
+ event: string,
35
+ listener: (...args: any[]) => void
36
+ ) {
37
+ target.on(event, listener);
38
+ this.eventListeners.push({ target, event, listener });
39
+ }
40
+
41
+ unregisterEventListener<T extends EventEmitterLike>(
42
+ target: T,
43
+ event: string,
44
+ listener: (...args: any[]) => void
45
+ ) {
46
+ if (target.off) {
47
+ target.off(event, listener);
48
+ } else if (target.removeListener) {
49
+ target.removeListener(event, listener);
50
+ }
51
+ this.eventListeners = this.eventListeners.filter(
52
+ el => el.target !== target || el.event !== event || el.listener !== listener
53
+ );
54
+ }
55
+
17
56
  async runCleanup(reason: string): Promise<void> {
18
57
  if (this.cleanupPromise !== null) {
19
58
  return this.cleanupPromise;
@@ -46,9 +85,35 @@ export class ShutdownManager {
46
85
  try {
47
86
  await this.cleanupPromise;
48
87
  } finally {
88
+ // Remove all registered event listeners to allow clean process exit
89
+ for (const { target, event, listener } of this.eventListeners) {
90
+ try {
91
+ if (target.off) {
92
+ target.off(event, listener);
93
+ } else if (target.removeListener) {
94
+ target.removeListener(event, listener);
95
+ }
96
+ } catch (error) {
97
+ logger.error('[ShutdownManager] Error removing event listener:', error);
98
+ }
99
+ }
100
+ this.eventListeners = [];
49
101
  this.cleanupPromise = null;
50
102
  }
51
103
  }
104
+
105
+ /**
106
+ * Force exit the process after a timeout if shutdown is hanging
107
+ * This should be called after runCleanup() with a short delay
108
+ */
109
+ forceExitAfter(timeoutMs: number, code = 0) {
110
+ const timer = setTimeout(() => {
111
+ logger.warn(`[ShutdownManager] Forcing exit after ${timeoutMs}ms timeout`);
112
+ process.env['PI_PROCESS_EXITING'] = '1';
113
+ process.exit(code);
114
+ }, timeoutMs);
115
+ safeUnref(timer); // unref() allows Node.js to exit if this is the only timer
116
+ }
52
117
  }
53
118
 
54
119
  export const shutdownManager = new ShutdownManager();