@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,974 @@
1
+ /**
2
+ * Research Knowledge Search Tool
3
+ *
4
+ * A satellite tool for the main pi agent that searches the local vector
5
+ * database for previously researched information. This is NOT a researcher
6
+ * sub-agent tool — the research orchestration already injects historical
7
+ * URLs into researcher prompts via the store_section mechanism.
8
+ *
9
+ * Architecture:
10
+ *
11
+ * Phase 1: Strict Contracts — rigid JSON schema for background LLM output
12
+ * Phase 2: Candidate Retrieval — vector search → URL dedup → ranked candidates
13
+ * (descriptions only, no full-document rebuild yet)
14
+ * Phase 3: Conversational Continuity — pi SDK's buildSessionContext pipeline
15
+ * Phase 4.5: Relevance Triage — cheap, model-agnostic LLM judgement over the
16
+ * short descriptions; an empty result is an instant miss (no rebuild,
17
+ * no synthesis), and a triage fault fails open to all candidates
18
+ * Phase 5: Safe Data Rehydration — rebuildDocument for the relevant URLs, then
19
+ * stateless background synthesis (completeSimple + agentic repair)
20
+ * Phase 6: Orchestration Steering — tri-state result (yes/maybe/no)
21
+ *
22
+ * Registration: pi.registerTool() in src/index.ts (alongside research & health).
23
+ * NOT in createResearchTools() — sub-researchers already get knowledge store
24
+ * data injected via store_section in their prompts.
25
+ */
26
+
27
+ import type {
28
+ ToolDefinition,
29
+ AgentToolResult,
30
+ ExtensionContext,
31
+ } from '@earendil-works/pi-coding-agent';
32
+ import { Type, type Static } from 'typebox';
33
+ import { Value } from 'typebox/value';
34
+ import { completeSimple } from '../core/llm/pi-ai-completion.ts';
35
+ import type { Model } from '@earendil-works/pi-ai';
36
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
37
+ import { buildSafeOptions, validateAndExtractText } from '../core/llm/llm-utils.ts';
38
+ import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
39
+ import { withTimeout } from '../core/llm/llm-timeout.ts';
40
+ import { abortableDelay } from '../web-research/retry-utils.ts';
41
+ import { ServiceNames } from '../core/service-interfaces.ts';
42
+ import type { IKnowledgeStoreService } from '../core/service-interfaces.ts';
43
+ import type { ResearchKnowledgeSynthesisResponse, KnowledgeRelevanceTriage } from './research-knowledge-types.ts';
44
+ import {
45
+ ResearchKnowledgeSynthesisResponseSchema,
46
+ ResearchKnowledgeSynthesisResponseSchemaAsTSchema,
47
+ KnowledgeRelevanceTriageSchema,
48
+ } from './research-knowledge-types.ts';
49
+ import { logger } from '../logger.ts';
50
+ import { metrics } from '../utils/metrics.ts';
51
+ import { extractJson } from '../utils/json-utils.ts';
52
+ import { repairJsonWithLlm } from '../core/llm/agentic-repair.ts';
53
+ import { loadPrompt } from '../core/llm/prompts.ts';
54
+ import { formatParentContext } from '../orchestration/session-context.ts';
55
+ import { getConfig, type ConfigInterface, type Config } from '../config.ts';
56
+ import { createKnowledgeSearchPanel } from '../tui/knowledge-search-panel.ts';
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Phase 1: Tool Parameters
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const ResearchKnowledgeSearchParams = Type.Object({
63
+ queries: Type.Array(Type.String(), {
64
+ minItems: 1,
65
+ maxItems: 5,
66
+ description:
67
+ 'Search queries for the knowledge store (1-5 queries).',
68
+ }),
69
+ });
70
+
71
+ type ResearchKnowledgeSearchParams = Static<typeof ResearchKnowledgeSearchParams>;
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Constants
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /** Hard limit on total reference text size (chars) to prevent token overflow.
78
+ * Exported for unit tests (bundle-neutral: internal symbol, tree-shaken). */
79
+ export const MAX_REFERENCE_CHARS = 120_000;
80
+
81
+ /** Maximum number of unique URLs to rebuild documents for.
82
+ * Exported for unit tests (bundle-neutral: internal symbol, tree-shaken). */
83
+ export const MAX_DOCUMENTS = 10;
84
+
85
+ /** Maximum number of candidate URLs (with descriptions) fed to the cheap relevance
86
+ * triage. Larger than MAX_DOCUMENTS because descriptions are tiny — the triage can
87
+ * survey a wider net before the full-document rebuild narrows to MAX_DOCUMENTS.
88
+ * Exported for unit tests. */
89
+ export const MAX_CANDIDATES = 24;
90
+
91
+ /** Per-candidate description length (chars) shown to the triage LLM. Descriptions
92
+ * are already short; this bounds a pathologically long one. Exported for tests. */
93
+ export const TRIAGE_DESCRIPTION_MAX_CHARS = 600;
94
+
95
+ /** Output-token budget for the triage call. Its output is just a small index array,
96
+ * so this is intentionally tiny (keeps a miss fast and cheap). */
97
+ const TRIAGE_MAX_TOKENS = 2048;
98
+
99
+ /**
100
+ * Render the explicit search queries for the triage/synthesis prompts. The queries
101
+ * are the PRIMARY relevance signal: retrieval is query-driven, but the relevance
102
+ * triage and the synthesis LLM previously saw only the conversation history — which
103
+ * is empty in the CLI / SDK / agent-skill paths (createMockContext has no session
104
+ * branch), leaving them blind to the actual question and prone to non-deterministic
105
+ * false "no results". Surfacing the queries makes the search reliable on every
106
+ * surface, and robust for weaker models than the host's.
107
+ */
108
+ export function formatQueriesBlock(queries: string[]): string {
109
+ const cleaned = queries.map((q) => q.trim()).filter((q) => q.length > 0);
110
+ if (cleaned.length === 0) return '(no explicit query provided)';
111
+ return cleaned.map((q) => `- ${q}`).join('\n');
112
+ }
113
+
114
+ /** Widget ID for the knowledge search TUI panel */
115
+ const KNOWLEDGE_WIDGET_ID = 'pi-research-knowledge-search';
116
+
117
+ /**
118
+ * System string returned when the answer is NOT found in the research
119
+ * knowledge store. The exact phrasing is critical — the main pi agent
120
+ * reads this as a signal to pivot to live web research.
121
+ */
122
+ export const RESEARCH_KNOWLEDGE_MISS_STRING =
123
+ 'No results found. Live research can get the info.';
124
+
125
+ /**
126
+ * System string appended when the knowledge store returned partial results.
127
+ * The host agent gets a synthesis of what was found but is also told to
128
+ * continue with live research for a more complete answer.
129
+ */
130
+ export const RESEARCH_KNOWLEDGE_MAYBE_STRING =
131
+ 'Partial results found in knowledge store. Live research can fill gaps.';
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // TUI Helpers
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Show the knowledge search TUI widget (bordered box with "searching knowledge store").
139
+ * Only renders in TUI mode with UI available.
140
+ */
141
+ function showKnowledgeSearchWidget(ctx: ExtensionContext): void {
142
+ if (ctx.mode !== 'tui' || !ctx.hasUI) return;
143
+ try {
144
+ const panelFactory = createKnowledgeSearchPanel();
145
+ ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, panelFactory as any, { placement: 'aboveEditor' });
146
+ } catch (err) {
147
+ logger.debug(`[research-knowledge-search] Failed to show TUI widget: ${err}`);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Remove the knowledge search TUI widget.
153
+ */
154
+ function hideKnowledgeSearchWidget(ctx: ExtensionContext): void {
155
+ if (ctx.mode !== 'tui' || !ctx.hasUI) return;
156
+ try {
157
+ ctx.ui.setWidget(KNOWLEDGE_WIDGET_ID, undefined as any, { placement: 'aboveEditor' });
158
+ } catch (err) {
159
+ logger.debug(`[research-knowledge-search] Failed to hide TUI widget: ${err}`);
160
+ }
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Phase 2: Candidate retrieval → cheap relevance triage → document rehydration
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** A single retrieved candidate: a URL plus its short synthesis-description.
168
+ * The description is what the cheap triage LLM reads (NOT the full document). */
169
+ export interface KnowledgeCandidate {
170
+ url: string;
171
+ description: string;
172
+ provenance: string;
173
+ }
174
+
175
+ /**
176
+ * Run the vector search for every query and deduplicate the resulting URLs into
177
+ * a ranked candidate list, WITHOUT rebuilding any full documents. Each candidate
178
+ * carries its short synthesis-description — enough for the cheap relevance triage
179
+ * to judge, at a fraction of the token cost of the full rebuilt markdown.
180
+ *
181
+ * Deduplication uses a Map keyed on first-appearance order, so the earliest (most
182
+ * relevant) query result for each URL — and its provenance/description — is kept.
183
+ */
184
+ export async function collectCandidateUrls(
185
+ queries: string[],
186
+ store: import('../core/interfaces/knowledge-interfaces.ts').IKnowledgeStore,
187
+ ): Promise<{ candidates: KnowledgeCandidate[]; provenanceByUrl: Map<string, string> }> {
188
+ const order = new Map<string, number>();
189
+ const provenanceByUrl = new Map<string, string>();
190
+ const descriptionByUrl = new Map<string, string>();
191
+
192
+ let failedQueries = 0;
193
+ for (const query of queries) {
194
+ try {
195
+ const results = await store.findRelevantUrls(query, { limit: 20 });
196
+ for (const entry of results) {
197
+ if (!order.has(entry.url)) {
198
+ order.set(entry.url, order.size);
199
+ provenanceByUrl.set(entry.url, entry.provenance || 'unknown');
200
+ descriptionByUrl.set(entry.url, entry.description || '');
201
+ }
202
+ }
203
+ } catch (err) {
204
+ failedQueries++;
205
+ logger.debug(`[research-knowledge-search] Vector search failed for query "${query}": ${err}`);
206
+ }
207
+ }
208
+
209
+ // Distinguish a backend outage from a genuine empty: if EVERY query threw (the store
210
+ // reported content but the vector index/embedder is faulting), an empty candidate list
211
+ // is a false miss. We still fail open (the caller reports a miss → host does live
212
+ // research), but warn so the outage is not silent — the symmetric detection the
213
+ // web-search path already has for "all queries failed".
214
+ if (queries.length > 0 && failedQueries === queries.length) {
215
+ logger.warn(
216
+ `[research-knowledge-search] All ${queries.length} vector search(es) failed — ` +
217
+ `treating as a miss, but this may be a knowledge-store outage rather than a genuine empty.`,
218
+ );
219
+ }
220
+
221
+ // Sort by first-appearance order (lower = more relevant), cap the candidate pool.
222
+ const candidates = [...order.keys()]
223
+ .sort((a, b) => (order.get(a) ?? Infinity) - (order.get(b) ?? Infinity))
224
+ .slice(0, MAX_CANDIDATES)
225
+ .map((url) => ({
226
+ url,
227
+ description: descriptionByUrl.get(url) || '',
228
+ provenance: provenanceByUrl.get(url) || 'unknown',
229
+ }));
230
+
231
+ return { candidates, provenanceByUrl };
232
+ }
233
+
234
+ /**
235
+ * Rebuild pristine full documents for the given URLs via the store's native
236
+ * rebuildDocument() (synthesis-description metadata blocks → unfragmented original
237
+ * markdown, not stitched vector chunks), enforcing the total character budget.
238
+ * `urls` is expected to already be ranked (most relevant first); it is capped at
239
+ * MAX_DOCUMENTS. The returned `urls` is the capped input list (including any whose
240
+ * rebuild yielded nothing) so callers can report how many documents were considered.
241
+ */
242
+ export async function rebuildDocuments(
243
+ urls: string[],
244
+ provenanceByUrl: Map<string, string>,
245
+ store: import('../core/interfaces/knowledge-interfaces.ts').IKnowledgeStore,
246
+ ): Promise<{ text: string; urls: string[] }> {
247
+ const sortedUrls = urls.slice(0, MAX_DOCUMENTS);
248
+ const documentParts: string[] = [];
249
+ let totalChars = 0;
250
+
251
+ for (const url of sortedUrls) {
252
+ try {
253
+ const rebuilt = await store.rebuildDocument(url);
254
+ if (!rebuilt) {
255
+ logger.debug(`[research-knowledge-search] Could not rebuild document for ${url}`);
256
+ continue;
257
+ }
258
+
259
+ const provenance = provenanceByUrl.get(url) || 'unknown';
260
+ const ageLine = typeof rebuilt.ageDays === 'number' ? `#### Cache age: ~${rebuilt.ageDays} day(s) old\n` : '';
261
+ const header = `\n---\n### Source: ${url}\n#### Provenance: ${provenance}\n${ageLine}`;
262
+ const docText = rebuilt.text || '';
263
+ const entry = header + docText;
264
+
265
+ // Token budget enforcement: if this document would exceed the limit,
266
+ // truncate it and stop adding any further (lower-ranked) documents.
267
+ if (totalChars + entry.length > MAX_REFERENCE_CHARS) {
268
+ const remaining = MAX_REFERENCE_CHARS - totalChars - header.length;
269
+ if (remaining > 500) {
270
+ documentParts.push(header + docText.slice(0, remaining) + '\n[TRUNCATED]');
271
+ }
272
+ break;
273
+ }
274
+
275
+ documentParts.push(entry);
276
+ totalChars += entry.length;
277
+ } catch (err) {
278
+ logger.debug(`[research-knowledge-search] Failed to rebuild ${url}: ${err}`);
279
+ }
280
+ }
281
+
282
+ return { text: documentParts.join('\n'), urls: sortedUrls };
283
+ }
284
+
285
+ /**
286
+ * Convenience composition: collect candidates for the queries and rebuild ALL of
287
+ * them (no triage). This is the permissive fall-back used when the relevance-triage
288
+ * LLM is unavailable or fails — behaviourally identical to the pre-triage tool.
289
+ */
290
+ export async function assembleReferenceDocuments(
291
+ queries: string[],
292
+ store: import('../core/interfaces/knowledge-interfaces.ts').IKnowledgeStore,
293
+ ): Promise<{ text: string; urls: string[] }> {
294
+ const { candidates, provenanceByUrl } = await collectCandidateUrls(queries, store);
295
+ if (candidates.length === 0) {
296
+ return { text: '', urls: [] };
297
+ }
298
+ return rebuildDocuments(candidates.map((c) => c.url), provenanceByUrl, store);
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Phase 3: Conversational Continuity
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Serialize the current conversation branch using pi SDK's native pipeline.
307
+ * Produces a dense conversational transcript the background LLM can use
308
+ * to understand the user's overarching intent.
309
+ */
310
+ async function serializeConversationHistory(ctx: ExtensionContext): Promise<string> {
311
+ try {
312
+ return await formatParentContext(ctx);
313
+ } catch (err) {
314
+ logger.debug(`[research-knowledge-search] Failed to serialize conversation history: ${err}`);
315
+ return '(Conversation context unavailable)';
316
+ }
317
+ }
318
+
319
+ import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
320
+ import { safeGetApiKeyAndHeaders } from '../core/llm/model-registry-factory.ts';
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Phase 4: Stateless Background Execution + Agentic Repair
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * Resolve the model to use for the background synthesis LLM.
328
+ * Priority: RESEARCH_MODEL → ctx.model
329
+ */
330
+ function resolveSynthesisModel(ctx: ExtensionContext, config: Config): { model?: Model<any>; error?: string } {
331
+ try {
332
+ const model = resolveResearchModel({
333
+ modelRegistry: ctx.modelRegistry,
334
+ hostModel: ctx.model as Model<any>,
335
+ cwd: ctx.cwd,
336
+ config,
337
+ });
338
+ return { model };
339
+ } catch (err) {
340
+ return { error: String(err) };
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Validate a raw object against the response schema using TypeBox
346
+ * coercion and checking.
347
+ */
348
+ function validateResponse(raw: unknown): ResearchKnowledgeSynthesisResponse | null {
349
+ if (!raw || typeof raw !== 'object') return null;
350
+
351
+ try {
352
+ const coerced = Value.Convert(ResearchKnowledgeSynthesisResponseSchema, raw);
353
+ if (Value.Check(ResearchKnowledgeSynthesisResponseSchema, coerced)) {
354
+ return coerced as ResearchKnowledgeSynthesisResponse;
355
+ }
356
+ } catch {
357
+ // Fall through
358
+ }
359
+
360
+ return null;
361
+ }
362
+
363
+ /**
364
+ * Invoke the background LLM via completeSimple — a purely stateless call
365
+ * with no agent session, no side effects, and no UI updates.
366
+ *
367
+ * The response undergoes a two-phase validation:
368
+ * 1. Direct JSON extraction with full TypeBox schema validation
369
+ * 2. Agentic repair fallback (re-prompts LLM with schema enforcement)
370
+ *
371
+ * If both phases fail, returns a safe default (answer_status: "no").
372
+ */
373
+ /**
374
+ * Total attempts (1 initial + retries) for the background synthesis LLM call.
375
+ * The synthesis call was previously single-shot: a transient provider failure (a 429
376
+ * rate-limit, a timeout, a thinking-only/empty response, a 5xx) was caught by the tool's
377
+ * outer handler and reported as a knowledge-store MISS, silently escalating the host agent
378
+ * to a slow, costly live web run instead of serving the store. Matching the researcher
379
+ * provider-retry default of 2 retries.
380
+ */
381
+ const KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS = 3;
382
+ const KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS = 1000;
383
+
384
+ /**
385
+ * Whether an LLM-call failure is transient and worth retrying. Mirrors the rate-limit/timeout
386
+ * signals surfaced by validateAndExtractText (llm-utils.ts) and the withTimeout wrapper.
387
+ * Exported for unit testing.
388
+ */
389
+ export function isTransientSynthesisError(message: string): boolean {
390
+ const m = message.toLowerCase();
391
+ // Numeric codes are matched on a word boundary so a token count embedded in a NON-transient
392
+ // error (e.g. "maximum context length ... you requested 67500 tokens" → "500", or "13100
393
+ // tokens" → "1310") does not trigger a pointless 3× retry before the call correctly fails.
394
+ if (/\b(429|500|502|503|504|1310)\b/.test(m)) return true;
395
+ return (
396
+ m.includes('rate limit') ||
397
+ m.includes('timed out') ||
398
+ m.includes('timeout') ||
399
+ m.includes('returned no text content') ||
400
+ m.includes('overloaded') ||
401
+ m.includes('econnreset') ||
402
+ m.includes('fetch failed') ||
403
+ // undici mid-stream aborts of the synthesis/triage streaming response: a dropped
404
+ // connection surfaces as `TypeError: terminated` (cause "other side closed" / UND_ERR_SOCKET).
405
+ m.includes('terminated') ||
406
+ m.includes('other side closed') ||
407
+ m.includes('socket hang up') ||
408
+ m.includes('und_err')
409
+ );
410
+ }
411
+
412
+ /** Exported for unit testing of the transient-retry loop. */
413
+ export async function runBackgroundExtraction(
414
+ model: Model<any>,
415
+ auth: { apiKey: string; headers?: Record<string, string> },
416
+ conversationHistory: string,
417
+ queries: string[],
418
+ referenceDocuments: string,
419
+ llmTimeout: number,
420
+ maxTokens: number,
421
+ thinkingLevel: Config['LLM_THINKING_LEVEL'],
422
+ signal?: AbortSignal,
423
+ ): Promise<ResearchKnowledgeSynthesisResponse> {
424
+ const promptTemplate = loadPrompt('research-knowledge-search-extractor');
425
+ if (!promptTemplate) {
426
+ throw new Error('research-knowledge-search prompt template not found');
427
+ }
428
+
429
+ // Use FUNCTION replacers: referenceDocuments is rebuilt knowledge-store text (scraped web pages)
430
+ // and conversationHistory/queries are arbitrary user text, all of which routinely contain `$`. A
431
+ // plain string replacement interprets `$&`, `` $` ``, `$'`, `$$` as substitution patterns and
432
+ // corrupts the extractor prompt (duplicating/eating template text) → wrong yes/maybe/no
433
+ // classification. A function replacer inserts the value literally. (Same class as commit 78c16f98.)
434
+ const systemPrompt = promptTemplate
435
+ .replace('{{queries}}', () => formatQueriesBlock(queries))
436
+ .replace('{{conversation_history}}', () => conversationHistory)
437
+ .replace('{{reference_documents}}', () => referenceDocuments);
438
+
439
+ const userMessage =
440
+ 'Analyze the reference documents above and extract the answer using the required JSON format.';
441
+
442
+ // Phase 4a: Stateless LLM call — no AgentSession, no side-effects.
443
+ // llmTimeout is resolved by the caller from the iface-aware Config so a
444
+ // per-interface overlay (pi.env / cli.env) is honored here.
445
+ // Bounded transient-retry: a 429/timeout/empty response on this single synthesis call must
446
+ // not be swallowed into a knowledge-store MISS (which forces a needless live web run). A
447
+ // genuine cancellation (signal) or a non-transient error (e.g. malformed-but-present text,
448
+ // which flows on to the repair path) is NOT retried.
449
+ let responseText: string;
450
+ let attempt = 0;
451
+ for (;;) {
452
+ attempt++;
453
+ try {
454
+ const response = await withTimeout(
455
+ completeSimple(model, {
456
+ systemPrompt,
457
+ messages: [
458
+ { role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
459
+ ],
460
+ }, buildSafeOptions(model, {
461
+ apiKey: auth.apiKey,
462
+ headers: auth.headers,
463
+ signal
464
+ }, maxTokens, thinkingLevel)),
465
+ llmTimeout,
466
+ 'knowledge-search-extraction',
467
+ );
468
+
469
+ // Track token and cost metrics for the background synthesis call
470
+ recordLlmUsage(model, (response as any).usage, { component: 'knowledge_search' });
471
+
472
+ responseText = validateAndExtractText(response, 'Knowledge Extraction');
473
+ break;
474
+ } catch (err) {
475
+ // A real cancellation propagates immediately — no retry, no fallback.
476
+ if (signal?.aborted) throw err;
477
+ const msg = err instanceof Error ? err.message : String(err);
478
+ if (attempt >= KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS || !isTransientSynthesisError(msg)) {
479
+ throw err;
480
+ }
481
+ const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
482
+ metrics.increment('research_knowledge_search_synthesis_retries_total', 1);
483
+ logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
484
+ await abortableDelay(delay, signal);
485
+ }
486
+ }
487
+
488
+ // Phase 4b: Direct JSON extraction + TypeBox validation
489
+ const extracted = extractJson<ResearchKnowledgeSynthesisResponse>(responseText, 'object');
490
+ if (extracted.success && extracted.value) {
491
+ const validated = validateResponse(extracted.value);
492
+ if (validated) {
493
+ return validated;
494
+ }
495
+ // Log why validation failed
496
+ try {
497
+ const errors = [...Value.Errors(ResearchKnowledgeSynthesisResponseSchema, extracted.value)];
498
+ const errorDetail = errors.map((e: any) => `${e.path}: ${e.message}`).join(', ');
499
+ logger.debug(`[research-knowledge-search] Schema validation failed: ${errorDetail}`);
500
+ } catch (validationErr) {
501
+ logger.debug(`[research-knowledge-search] TypeBox validation error: ${validationErr}`);
502
+ }
503
+ }
504
+
505
+ // Phase 4c: Agentic Repair — re-prompt the LLM with the schema embedded
506
+ logger.warn('[research-knowledge-search] Background LLM response malformed, attempting agentic repair');
507
+
508
+ const repaired = await repairJsonWithLlm<ResearchKnowledgeSynthesisResponse>(
509
+ responseText,
510
+ completeSimple,
511
+ auth,
512
+ {
513
+ model,
514
+ schema: ResearchKnowledgeSynthesisResponseSchemaAsTSchema,
515
+ context: 'Knowledge search extraction — synthesizing answer from reference documents',
516
+ serviceName: 'ResearchKnowledgeSearch',
517
+ signal,
518
+ // Match the budget of the call being repaired so a large synthesis is not re-truncated
519
+ // at repairJsonWithLlm's smaller default (16384). Mirrors the planning-service repairs.
520
+ maxTokens,
521
+ thinkingLevel,
522
+ onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'knowledge_search' }),
523
+ },
524
+ );
525
+
526
+ if (repaired) {
527
+ const validated = validateResponse(repaired);
528
+ if (validated) return validated;
529
+ }
530
+
531
+ // Phase 4d: Safe fallback — treat as not found rather than crashing. This is a
532
+ // fail-open (the host just falls back to live research), so warn — matching the
533
+ // other fail-open paths in this file — rather than error.
534
+ logger.warn('[research-knowledge-search] Agentic repair failed, returning NOT_FOUND');
535
+ return { answer_status: 'no', synthesis: '', citations: [] };
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Phase 3.5: Cheap, model-agnostic relevance triage (descriptions only)
540
+ // ---------------------------------------------------------------------------
541
+
542
+ /**
543
+ * Ask the LLM which candidates are actually relevant, reading ONLY their short
544
+ * descriptions. This is the model-agnostic replacement for an embedding-similarity
545
+ * gate: the vector index always returns SOME nearest neighbour, so we let the LLM —
546
+ * which judged the original "Belize" miss correctly — decide relevance on cheap text
547
+ * before paying to rebuild full documents and run the large synthesis call.
548
+ *
549
+ * Returns:
550
+ * - `string[]` (possibly empty) — triage ran: the relevant candidate URLs. An empty
551
+ * array authorises an instant miss with NO rebuild and NO synthesis LLM call.
552
+ * - `null` — triage could NOT run (no prompt, non-transient error, or malformed
553
+ * output). The caller then FAILS OPEN (uses all candidates), so a triage fault
554
+ * never wrongly hides real knowledge — worst case is the old, slower behaviour.
555
+ *
556
+ * A genuine cancellation (signal) propagates as a throw.
557
+ * Exported for unit testing.
558
+ */
559
+ export async function triageRelevantUrls(
560
+ model: Model<any>,
561
+ auth: { apiKey: string; headers?: Record<string, string> },
562
+ conversationHistory: string,
563
+ queries: string[],
564
+ candidates: KnowledgeCandidate[],
565
+ llmTimeout: number,
566
+ thinkingLevel: Config['LLM_THINKING_LEVEL'],
567
+ signal?: AbortSignal,
568
+ ): Promise<string[] | null> {
569
+ if (candidates.length === 0) return [];
570
+
571
+ const promptTemplate = loadPrompt('research-knowledge-relevance-triage');
572
+ if (!promptTemplate) {
573
+ logger.warn('[research-knowledge-search] Triage prompt missing; failing open to full synthesis');
574
+ return null;
575
+ }
576
+
577
+ const candidateBlock = candidates
578
+ .map((c, i) => {
579
+ const desc = (c.description || '(no description available)').slice(0, TRIAGE_DESCRIPTION_MAX_CHARS);
580
+ return `[${i}] ${c.url}\n${desc}`;
581
+ })
582
+ .join('\n\n');
583
+
584
+ // FUNCTION replacers so literal `$`/`$&` in queries, descriptions or history are not
585
+ // treated as regex substitution patterns (same class as the synthesis prompt).
586
+ const systemPrompt = promptTemplate
587
+ .replace('{{queries}}', () => formatQueriesBlock(queries))
588
+ .replace('{{conversation_history}}', () => conversationHistory)
589
+ .replace('{{candidates}}', () => candidateBlock);
590
+
591
+ const userMessage = 'Return the JSON object listing the indices of the relevant candidates.';
592
+
593
+ let responseText: string;
594
+ let attempt = 0;
595
+ for (;;) {
596
+ attempt++;
597
+ try {
598
+ const response = await withTimeout(
599
+ completeSimple(model, {
600
+ systemPrompt,
601
+ messages: [
602
+ { role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
603
+ ],
604
+ }, buildSafeOptions(model, {
605
+ apiKey: auth.apiKey,
606
+ headers: auth.headers,
607
+ signal,
608
+ }, TRIAGE_MAX_TOKENS, thinkingLevel)),
609
+ llmTimeout,
610
+ 'knowledge-relevance-triage',
611
+ );
612
+
613
+ recordLlmUsage(model, (response as any).usage, { component: 'knowledge_triage' });
614
+
615
+ responseText = validateAndExtractText(response, 'Knowledge Triage');
616
+ break;
617
+ } catch (err) {
618
+ if (signal?.aborted) throw err;
619
+ const msg = err instanceof Error ? err.message : String(err);
620
+ if (attempt >= KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS || !isTransientSynthesisError(msg)) {
621
+ // Fail open: a triage fault must not hide real knowledge.
622
+ logger.warn(`[research-knowledge-search] Triage failed (${msg}); failing open to full synthesis`);
623
+ return null;
624
+ }
625
+ const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
626
+ metrics.increment('research_knowledge_search_triage_retries_total', 1);
627
+ logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
628
+ await abortableDelay(delay, signal);
629
+ }
630
+ }
631
+
632
+ // Coerce-then-validate exactly like validateResponse(): a single Value.Convert
633
+ // wrapped in try/catch (Convert can throw on some malformed inputs), then check
634
+ // the coerced value. A throw or a failed check falls through to the fail-open
635
+ // path below — a triage fault must never hide real knowledge.
636
+ const extracted = extractJson<KnowledgeRelevanceTriage>(responseText, 'object');
637
+ let coerced: KnowledgeRelevanceTriage | null = null;
638
+ if (extracted.success && extracted.value) {
639
+ try {
640
+ const converted = Value.Convert(KnowledgeRelevanceTriageSchema, extracted.value);
641
+ if (Value.Check(KnowledgeRelevanceTriageSchema, converted)) {
642
+ coerced = converted as KnowledgeRelevanceTriage;
643
+ }
644
+ } catch { /* fall through to malformed handling */ }
645
+ }
646
+ if (!coerced) {
647
+ logger.warn('[research-knowledge-search] Triage output malformed; failing open to full synthesis');
648
+ return null;
649
+ }
650
+
651
+ const seen = new Set<string>();
652
+ const relevantUrls: string[] = [];
653
+ for (const idx of coerced.relevant_indices) {
654
+ const candidate = Number.isInteger(idx) && idx >= 0 && idx < candidates.length ? candidates[idx] : undefined;
655
+ if (candidate && !seen.has(candidate.url)) {
656
+ seen.add(candidate.url);
657
+ relevantUrls.push(candidate.url);
658
+ }
659
+ }
660
+ return relevantUrls;
661
+ }
662
+
663
+ // ---------------------------------------------------------------------------
664
+ // Phase 5: Orchestration Steering
665
+ // ---------------------------------------------------------------------------
666
+
667
+ /**
668
+ * Build the tool result based on the answer status.
669
+ *
670
+ * - "yes": Return the synthesis with citations. No live research needed.
671
+ * - "maybe": Return the synthesis with citations, BUT append a message
672
+ * telling the host agent to also do live research to fill gaps.
673
+ * - "no": Return the miss string, authorizing live research.
674
+ */
675
+ export function buildSteeringResult(
676
+ result: ResearchKnowledgeSynthesisResponse,
677
+ urls: string[],
678
+ ): AgentToolResult<unknown> {
679
+ const status = result.answer_status;
680
+
681
+ // "no" — nothing found, pivot to live research
682
+ if (status === 'no') {
683
+ metrics.increment('research_knowledge_search_total', 1, { status: 'not_found' });
684
+ return {
685
+ content: [{ type: 'text', text: RESEARCH_KNOWLEDGE_MISS_STRING }],
686
+ details: { source: 'research_knowledge_search', found: false, answerStatus: 'no' },
687
+ };
688
+ }
689
+
690
+ // "yes" or "maybe" — build the report
691
+ const synthesis = result.synthesis || '';
692
+ let report = synthesis;
693
+
694
+ if (result.citations.length > 0) {
695
+ report += '\n\n### Sources\n';
696
+ for (let i = 0; i < result.citations.length; i++) {
697
+ report += `${i + 1}. ${result.citations[i]}\n`;
698
+ }
699
+ report += '\n---';
700
+ }
701
+
702
+ if (status === 'maybe') {
703
+ metrics.increment('research_knowledge_search_total', 1, { status: 'partial' });
704
+ report += '\n\n' + RESEARCH_KNOWLEDGE_MAYBE_STRING;
705
+ return {
706
+ content: [{ type: 'text', text: report }],
707
+ details: {
708
+ source: 'research_knowledge_search',
709
+ found: true,
710
+ answerStatus: 'maybe',
711
+ citations: result.citations,
712
+ documentsSearched: urls.length,
713
+ },
714
+ };
715
+ }
716
+
717
+ // "yes" — complete answer
718
+ metrics.increment('research_knowledge_search_total', 1, { status: 'found' });
719
+ metrics.increment('research_knowledge_search_citations_total', result.citations.length);
720
+ return {
721
+ content: [{ type: 'text', text: report }],
722
+ details: {
723
+ source: 'research_knowledge_search',
724
+ found: true,
725
+ answerStatus: 'yes',
726
+ citations: result.citations,
727
+ documentsSearched: urls.length,
728
+ },
729
+ };
730
+ }
731
+
732
+ // ---------------------------------------------------------------------------
733
+ // Error-path result builder
734
+ // ---------------------------------------------------------------------------
735
+
736
+ function missResult(reason: string): AgentToolResult<unknown> {
737
+ metrics.increment('research_knowledge_search_total', 1, { status: reason });
738
+ logger.info('[research-knowledge-search] Knowledge store miss:', reason);
739
+
740
+ let text = RESEARCH_KNOWLEDGE_MISS_STRING;
741
+ switch (reason) {
742
+ case 'store_empty':
743
+ text = 'No results found (knowledge store is empty). Live research can get the info.';
744
+ break;
745
+ case 'store_disabled':
746
+ text = 'No results found (knowledge store is disabled in settings). Live research can get the info.';
747
+ break;
748
+ case 'store_not_ready':
749
+ text = 'No results found (knowledge store is initializing). Live research can get the info.';
750
+ break;
751
+ case 'store_unavailable':
752
+ text = 'No results found (knowledge store unavailable). Live research can get the info.';
753
+ break;
754
+ case 'no_results':
755
+ text = 'No results found (no matching content). Live research can get the info.';
756
+ break;
757
+ case 'no_model':
758
+ text = 'No results found (no model configured). Live research can get the info.';
759
+ break;
760
+ case 'auth_failed':
761
+ text = 'No results found (authentication error). Live research can get the info.';
762
+ break;
763
+ }
764
+
765
+ return {
766
+ content: [{ type: 'text', text }],
767
+ details: { source: 'research_knowledge_search', found: false, answerStatus: 'no', reason },
768
+ };
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // Tool Factory
773
+ // ---------------------------------------------------------------------------
774
+
775
+ /**
776
+ * Create the Research Knowledge Search tool definition.
777
+ *
778
+ * This tool is a top-level satellite of the main `research` tool, registered
779
+ * via pi.registerTool() in src/index.ts — NOT in createResearchTools().
780
+ */
781
+ export function createResearchKnowledgeSearchTool(iface?: ConfigInterface): ToolDefinition {
782
+ return {
783
+ name: 'research_knowledge_search',
784
+ label: 'Research Knowledge Search',
785
+ description:
786
+ 'Search the research knowledge store for previously investigated information. ' +
787
+ 'Check this first for a web-research question, before the live `research` tool — ' +
788
+ 'it is a local, instant lookup. Go to live research only if it returns a partial or no answer.',
789
+ promptSnippet: 'Search research knowledge store',
790
+ promptGuidelines: [
791
+ 'Check `research_knowledge_search` first for research tasks, before `research`.',
792
+ 'A bare "research X" request still starts here: check what is known, then go live only if needed.',
793
+ 'If status is "yes" (complete), answer from it; no live research needed.',
794
+ 'If status is "maybe" (partial), use the synthesis and call `research` to fill gaps.',
795
+ 'If status is "no" (empty), call `research` for live investigation.',
796
+ 'Do not call knowledge search and research for the same query in the same turn — wait for the result.',
797
+ ],
798
+ parameters: ResearchKnowledgeSearchParams,
799
+ executionMode: 'parallel',
800
+ async execute(
801
+ _toolCallId: string,
802
+ params: unknown,
803
+ signal: AbortSignal | undefined,
804
+ _onUpdate: any,
805
+ ctx: ExtensionContext,
806
+ ): Promise<AgentToolResult<unknown>> {
807
+ const startTime = Date.now();
808
+
809
+ // Defensive validation (same pattern as the sibling search/scrape tools):
810
+ // a string `queries` would otherwise iterate per-character downstream.
811
+ if (!Value.Check(ResearchKnowledgeSearchParams, params)) {
812
+ metrics.increment('research_knowledge_search_total', 1, { status: 'invalid_params' });
813
+ return {
814
+ content: [{ type: 'text', text: 'Invalid parameters for research_knowledge_search. Expected an array of 1-5 query strings.' }],
815
+ details: { error: 'invalid_parameters' },
816
+ };
817
+ }
818
+
819
+ const p = params as ResearchKnowledgeSearchParams;
820
+ const container = tryGetServiceContainerFromCtx(ctx);
821
+ // Prefer a config the caller already resolved and placed on the context
822
+ // (the SDK/CLI seed ctx.config with their hermetic/overlay-resolved Config),
823
+ // falling back to reading the per-interface overlay (pi.env / cli.env)
824
+ // from disk. This keeps RESEARCH_MODEL + LLM_TIMEOUT_MS for synthesis
825
+ // consistent with the run path and honors ignoreGlobalConfig.
826
+ const config = (ctx as { config?: Config }).config ?? getConfig(ctx.cwd, iface);
827
+
828
+ // Show the TUI widget immediately
829
+ showKnowledgeSearchWidget(ctx);
830
+
831
+ try {
832
+ // Live-config gate: honor a mid-session Knowledge Mode switch to 'none'
833
+ // immediately. An already-INITIALIZED store would otherwise keep serving
834
+ // searches the user just disabled (the reverse direction, none→enabled,
835
+ // revives via the getService call below).
836
+ if (config.KNOWLEDGE_STORE_MODE === 'none') {
837
+ return missResult('store_disabled');
838
+ }
839
+
840
+ // ----------------------------------------------------------
841
+ // Service resolution via central registry
842
+ // ----------------------------------------------------------
843
+ let storeService: IKnowledgeStoreService;
844
+ try {
845
+ storeService = await getService<IKnowledgeStoreService>(ServiceNames.KNOWLEDGE_STORE, ctx, container);
846
+ } catch {
847
+ return missResult('store_unavailable');
848
+ }
849
+
850
+ // Do NOT short-circuit on isReady() here. Immediately after Knowledge Mode is enabled
851
+ // via /research-config (no Pi restart), the store is still coming up — the getService
852
+ // above triggers the revival, but the native embed stack + init lock take a few seconds,
853
+ // during which isReady() is briefly false and would return a spurious "initializing"
854
+ // miss (the user then thinks a restart is required). getStore() awaits initialization to
855
+ // completion, so gate readiness on its result instead: it returns the store once up,
856
+ // or null when the store is genuinely disabled/failed.
857
+ const store = await storeService.getStore();
858
+ if (!store) {
859
+ // null = disabled (mode still 'none') or a genuine init failure — distinguish for the
860
+ // user-facing message so a real failure doesn't read as "disabled in settings".
861
+ const lifecycle = (storeService as any).lifecycle;
862
+ return missResult(lifecycle === 'disabled' ? 'store_disabled' : 'store_not_ready');
863
+ }
864
+
865
+ const count = await store.count();
866
+ if (count === 0) {
867
+ return missResult('store_empty');
868
+ }
869
+
870
+ // ----------------------------------------------------------
871
+ // Phase 2: Vector retrieval → ranked candidates (descriptions only, cheap)
872
+ // ----------------------------------------------------------
873
+ const { candidates, provenanceByUrl } = await collectCandidateUrls(p.queries, store);
874
+ if (candidates.length === 0) {
875
+ return missResult('no_results');
876
+ }
877
+
878
+ // ----------------------------------------------------------
879
+ // Phase 3: Conversational continuity via pi SDK pipeline
880
+ // ----------------------------------------------------------
881
+ const conversationHistory = await serializeConversationHistory(ctx);
882
+
883
+ // ----------------------------------------------------------
884
+ // Phase 4: Model routing (needed for both triage and synthesis)
885
+ // ----------------------------------------------------------
886
+ const { model, error: modelError } = resolveSynthesisModel(ctx, config);
887
+ if (modelError || !model) {
888
+ // Use a FIXED metric reason: modelError is a raw error string (stack fragments /
889
+ // model ids), and passing it as the { status } label would explode metric label
890
+ // cardinality (one unbounded time-series per distinct error text). Log the detail.
891
+ if (modelError) logger.warn(`[research-knowledge-search] Synthesis model resolution failed: ${modelError}`);
892
+ return missResult('no_model');
893
+ }
894
+
895
+ const authResult = await safeGetApiKeyAndHeaders(ctx.modelRegistry, model);
896
+ if (!authResult.ok) {
897
+ logger.warn(`[research-knowledge-search] Model auth failed: ${authResult.error}`);
898
+ return missResult('auth_failed');
899
+ }
900
+ const auth = { apiKey: authResult.apiKey || '', headers: authResult.headers };
901
+
902
+ // ----------------------------------------------------------
903
+ // Phase 4.5: Cheap relevance triage over descriptions. This is the
904
+ // model-agnostic replacement for an embedding-similarity gate: the vector
905
+ // index always returns *some* nearest neighbour, so we let the LLM judge the
906
+ // short descriptions before paying to rebuild full documents + synthesize.
907
+ // ----------------------------------------------------------
908
+ const triaged = await triageRelevantUrls(
909
+ model, auth, conversationHistory, p.queries, candidates,
910
+ config.LLM_TIMEOUT_MS, config.LLM_THINKING_LEVEL, signal,
911
+ );
912
+ // null = triage unavailable/failed → fail open to all candidates (never hide
913
+ // real knowledge). A non-null result is authoritative, including an empty one.
914
+ const chosenUrls = triaged === null ? candidates.map((c) => c.url) : triaged;
915
+ logger.debug(
916
+ `[research-knowledge-search] Triage: ${candidates.length} candidates → ` +
917
+ `${triaged === null ? 'FAIL-OPEN (all)' : `${chosenUrls.length} relevant`}`,
918
+ );
919
+ if (chosenUrls.length === 0) {
920
+ // Triage judged nothing relevant → instant miss, NO document rebuild and NO
921
+ // synthesis call. This is the fast path that fixes the slow-empty behaviour.
922
+ const durationMs = Date.now() - startTime;
923
+ metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'triaged_out' });
924
+ return missResult('no_results');
925
+ }
926
+
927
+ // ----------------------------------------------------------
928
+ // Phase 5: Rebuild ONLY the relevant documents → full synthesis
929
+ // ----------------------------------------------------------
930
+ const { text: referenceText, urls } = await rebuildDocuments(chosenUrls, provenanceByUrl, store);
931
+ if (!referenceText || referenceText.length === 0) {
932
+ return missResult('no_results');
933
+ }
934
+
935
+ const result = await runBackgroundExtraction(
936
+ model,
937
+ auth,
938
+ conversationHistory,
939
+ p.queries,
940
+ referenceText,
941
+ config.LLM_TIMEOUT_MS,
942
+ // Synthesis budget, not planning: this call distills up to ~30k tokens of
943
+ // reference documents into an answer, so the smaller PLANNING_MAX_TOKENS
944
+ // ceiling truncated the output. SYNTHESIS_MAX_TOKENS is the right bound.
945
+ config.SYNTHESIS_MAX_TOKENS,
946
+ config.LLM_THINKING_LEVEL,
947
+ signal,
948
+ );
949
+
950
+ const durationMs = Date.now() - startTime;
951
+ metrics.observe('research_knowledge_search_duration_ms', durationMs);
952
+
953
+ // ----------------------------------------------------------
954
+ // Phase 6: Orchestration Steering
955
+ // ----------------------------------------------------------
956
+ return buildSteeringResult(result, urls);
957
+ } catch (error) {
958
+ const durationMs = Date.now() - startTime;
959
+ metrics.observe('research_knowledge_search_duration_ms', durationMs, { status: 'error' });
960
+ metrics.increment('research_knowledge_search_total', 1, { status: 'error' });
961
+
962
+ logger.error('[research-knowledge-search] Tool execution failed:', error);
963
+
964
+ return {
965
+ content: [{ type: 'text', text: RESEARCH_KNOWLEDGE_MISS_STRING }],
966
+ details: { source: 'research_knowledge_search', found: false, answerStatus: 'no', error: String(error) },
967
+ };
968
+ } finally {
969
+ // Always remove the TUI widget when done
970
+ hideKnowledgeSearchWidget(ctx);
971
+ }
972
+ },
973
+ };
974
+ }