@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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -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 +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -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 +1044 -0
- package/src/config.ts +1036 -147
- 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 +264 -7
- 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 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- 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,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
|
+
}
|