@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.
- package/LICENSE +1 -1
- package/README.md +55 -138
- package/dist/cli.mjs +23375 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1195 -0
- package/src/config.ts +1032 -155
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +258 -11
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- 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
|
+
}
|