@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
package/src/utils/text-utils.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type { AgentSession } from '@
|
|
2
|
-
import type { AssistantMessage } from '@
|
|
1
|
+
import type { AgentSession } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { AssistantMessage } from '@earendil-works/pi-ai';
|
|
3
|
+
import { logger } from '../logger.ts';
|
|
4
|
+
import { stripTrailingLlmPunctuation } from './url-utils.ts';
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Text Utilities
|
|
@@ -11,7 +13,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai';
|
|
|
11
13
|
* Extract text content from a message
|
|
12
14
|
*
|
|
13
15
|
* Handles various message content formats:
|
|
14
|
-
* - String content
|
|
16
|
+
* - String content (with tag-based thinking removal)
|
|
15
17
|
* - Array of content blocks (TextContent, ThinkingContent, ToolCall, etc.)
|
|
16
18
|
* - Accepts any object with a content field for robustness
|
|
17
19
|
*
|
|
@@ -29,7 +31,7 @@ export function extractText(message: unknown): string {
|
|
|
29
31
|
|
|
30
32
|
// Handle string content
|
|
31
33
|
if (typeof content === 'string') {
|
|
32
|
-
return content;
|
|
34
|
+
return stripThinkingTags(content);
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
// Handle array of content blocks
|
|
@@ -40,7 +42,8 @@ export function extractText(message: unknown): string {
|
|
|
40
42
|
.map((b) => {
|
|
41
43
|
const blockObj = b as Record<string, unknown>;
|
|
42
44
|
const text = blockObj['text'];
|
|
43
|
-
|
|
45
|
+
// Ensure we also strip tags if the text block itself contains them (rare but possible)
|
|
46
|
+
return typeof text === 'string' ? stripThinkingTags(text) : '';
|
|
44
47
|
})
|
|
45
48
|
.filter((t: string) => t.length > 0) // Filter empty strings
|
|
46
49
|
.join('\n');
|
|
@@ -53,6 +56,51 @@ export function extractText(message: unknown): string {
|
|
|
53
56
|
return '';
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Truncate content to at most `maxChars` characters, appending an explicit
|
|
61
|
+
* `[content truncated: showing N of M chars]` marker so an LLM consuming the
|
|
62
|
+
* text knows it is incomplete.
|
|
63
|
+
*
|
|
64
|
+
* Guarantees:
|
|
65
|
+
* - Content at or under the cap is returned unchanged (no marker).
|
|
66
|
+
* - Truncated output (body + marker) never exceeds `maxChars`, which makes the
|
|
67
|
+
* function idempotent — re-applying the same cap to already-truncated output
|
|
68
|
+
* is a no-op (no nested markers).
|
|
69
|
+
* - Cuts at a clean boundary (last newline/space) when one exists in the final
|
|
70
|
+
* 20% of the kept text, so words/lines are not split mid-token.
|
|
71
|
+
*/
|
|
72
|
+
export function truncateWithMarker(content: string, maxChars: number): string {
|
|
73
|
+
if (content.length <= maxChars) return content;
|
|
74
|
+
|
|
75
|
+
const total = content.length;
|
|
76
|
+
const makeMarker = (kept: number) => `\n\n[content truncated: showing ${kept} of ${total} chars]`;
|
|
77
|
+
// Reserve marker room using the longest possible marker (kept ≤ total, so the
|
|
78
|
+
// digit count of `kept` never exceeds that of `total`).
|
|
79
|
+
const keep = Math.max(0, maxChars - makeMarker(total).length);
|
|
80
|
+
let body = content.slice(0, keep);
|
|
81
|
+
// Prefer a clean boundary near the cut point.
|
|
82
|
+
const boundary = Math.max(body.lastIndexOf('\n'), body.lastIndexOf(' '));
|
|
83
|
+
if (boundary > keep * 0.8) body = body.slice(0, boundary);
|
|
84
|
+
return body + makeMarker(body.length);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Robustly remove thinking/reasoning tags from a string.
|
|
89
|
+
* Handles <thought>, <thinking>, and <reasoning> tags (case-insensitive).
|
|
90
|
+
*/
|
|
91
|
+
export function stripThinkingTags(text: string): string {
|
|
92
|
+
if (!text) return '';
|
|
93
|
+
|
|
94
|
+
// Replace tags and their content: <thought>...</thought>, etc.
|
|
95
|
+
// Using dotAll (s flag) to handle multi-line thinking blocks.
|
|
96
|
+
// Also handles unclosed tags at the end of the string (truncated responses).
|
|
97
|
+
return text
|
|
98
|
+
.replace(/<thought>[\s\S]*?(?:<\/thought>|$)/gi, '')
|
|
99
|
+
.replace(/<thinking>[\s\S]*?(?:<\/thinking>|$)/gi, '')
|
|
100
|
+
.replace(/<reasoning>[\s\S]*?(?:<\/reasoning>|$)/gi, '')
|
|
101
|
+
.trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
/**
|
|
57
105
|
* Ensures the assistant completed successfully and returns the text content.
|
|
58
106
|
* Throws an error if the assistant failed or stopped with an error.
|
|
@@ -70,7 +118,9 @@ export function ensureAssistantResponse(session: AgentSession, label: string): s
|
|
|
70
118
|
throw new Error(`${label}: No assistant response found`);
|
|
71
119
|
}
|
|
72
120
|
|
|
73
|
-
// Check for explicit error stop reason or error message
|
|
121
|
+
// Check for explicit error stop reason or error message. An aborted session with an
|
|
122
|
+
// errorMessage is an interruption, not a provider error — fall through so any partial
|
|
123
|
+
// text it produced is salvaged below (and, if empty, reported as an abort not a model fault).
|
|
74
124
|
if (last.stopReason === 'error' || (last.errorMessage && last.stopReason !== 'aborted')) {
|
|
75
125
|
const msg = last.errorMessage || 'Unknown error';
|
|
76
126
|
if (msg.includes('429')) {
|
|
@@ -79,5 +129,212 @@ export function ensureAssistantResponse(session: AgentSession, label: string): s
|
|
|
79
129
|
throw new Error(`${label}: Provider error - ${msg}`);
|
|
80
130
|
}
|
|
81
131
|
|
|
82
|
-
|
|
132
|
+
// Warn if the response was truncated due to token limit
|
|
133
|
+
if (last.stopReason === 'length') {
|
|
134
|
+
const text = extractText(last);
|
|
135
|
+
if (text.trim()) {
|
|
136
|
+
logger.warn(`${label}: Response truncated by token limit — returning partial result`);
|
|
137
|
+
return text;
|
|
138
|
+
}
|
|
139
|
+
throw new Error(`${label}: Response was truncated by token limit and produced no usable text.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const text = extractText(last);
|
|
143
|
+
if (!text.trim()) {
|
|
144
|
+
// An aborted session with no salvageable text was interrupted mid-turn (quit/SIGTERM
|
|
145
|
+
// during research, or a timeout abort) before the model emitted its final answer. That
|
|
146
|
+
// is NOT a model-capability failure — surface an explicit "Aborted" so callers skip
|
|
147
|
+
// retries (see researcher-executor) instead of blaming the model with the message below.
|
|
148
|
+
if (last.stopReason === 'aborted') {
|
|
149
|
+
throw new Error(`${label}: Aborted`);
|
|
150
|
+
}
|
|
151
|
+
// zero text content blocks in the final assistant message. This typically means
|
|
152
|
+
// all tool calls failed, or the session ended without a visible response.
|
|
153
|
+
throw new Error(
|
|
154
|
+
`${label}: produced no text output — the model returned no final text block after its tool calls. ` +
|
|
155
|
+
`This is usually a model-capability issue (a very small or thinking-only model that doesn't emit a final ` +
|
|
156
|
+
`answer after using tools), or every tool call in the turn failed. Try a more capable model or retry.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return text;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Format a timestamp as a human-readable relative time string.
|
|
164
|
+
*/
|
|
165
|
+
export function formatTimeAgo(isoTimestamp: string): string {
|
|
166
|
+
const parsed = new Date(isoTimestamp).getTime();
|
|
167
|
+
if (Number.isNaN(parsed)) return 'unknown';
|
|
168
|
+
const diffMs = Date.now() - parsed;
|
|
169
|
+
const diffMinutes = Math.floor(diffMs / 60_000);
|
|
170
|
+
if (diffMinutes < 1) return 'just now';
|
|
171
|
+
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
172
|
+
const diffHours = Math.floor(diffMinutes / 60);
|
|
173
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
174
|
+
return `${Math.floor(diffHours / 24)}d ago`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Format a duration in ms as a human-readable string (e.g., "12.3s", "1m 23s", "2h 15m").
|
|
179
|
+
*/
|
|
180
|
+
export function formatDuration(ms: number): string {
|
|
181
|
+
if (ms < 1000) return `${ms}ms`;
|
|
182
|
+
const seconds = ms / 1000;
|
|
183
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
184
|
+
const minutes = Math.floor(seconds / 60);
|
|
185
|
+
const remainingSeconds = Math.round(seconds % 60);
|
|
186
|
+
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
|
|
187
|
+
const hours = Math.floor(minutes / 60);
|
|
188
|
+
const remainingMinutes = minutes % 60;
|
|
189
|
+
return `${hours}h ${remainingMinutes}m`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
import * as path from 'node:path';
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Consistently normalize a workspace path for database storage and filtering.
|
|
196
|
+
* Resolves to absolute path and removes trailing separators.
|
|
197
|
+
*/
|
|
198
|
+
export function normalizeWorkspacePath(wsPath: string): string {
|
|
199
|
+
if (!wsPath) return '';
|
|
200
|
+
const resolved = path.resolve(wsPath);
|
|
201
|
+
// Normalize trailing slash: /path/to/dir/ -> /path/to/dir
|
|
202
|
+
return (resolved.endsWith(path.sep) && resolved.length > 1)
|
|
203
|
+
? resolved.slice(0, -1)
|
|
204
|
+
: resolved;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface Citation {
|
|
208
|
+
url: string;
|
|
209
|
+
description: string;
|
|
210
|
+
source?: string;
|
|
211
|
+
/** The number the report actually wrote for this entry (e.g. 3 for `[3]`). Used to remap
|
|
212
|
+
* inline `[N]` by the written label, not list position, so non-sequential numbering maps right. */
|
|
213
|
+
number?: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A citation URL is only plausible if it parses and has a real host (a dot in
|
|
218
|
+
* the hostname). This rejects fragments like `https://www` that occur when an
|
|
219
|
+
* LLM soft-wraps or truncates a URL in its CITED LINKS — without this guard such
|
|
220
|
+
* a fragment becomes a garbage citation AND shifts every downstream global ID.
|
|
221
|
+
*/
|
|
222
|
+
function isPlausibleCitationUrl(url: string): boolean {
|
|
223
|
+
try {
|
|
224
|
+
const u = new URL(url);
|
|
225
|
+
return (u.protocol === 'http:' || u.protocol === 'https:') && u.hostname.includes('.');
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Offset of the LAST line-leading "CITED LINKS" section header in a report, or -1
|
|
233
|
+
* when none is present.
|
|
234
|
+
*
|
|
235
|
+
* ONLY an all-caps "CITED LINKS" at the start of a line (optionally after markdown
|
|
236
|
+
* heading `#` / blockquote `>` / bold `**` markers and whitespace) counts as the
|
|
237
|
+
* section marker — NOT an incidental lowercase "...the cited links below..." in
|
|
238
|
+
* prose. An unanchored, case-insensitive /CITED LINKS/i match would fire on such a
|
|
239
|
+
* mention and, with a trailing `[\s\S]*$`, silently truncate (or mis-parse) the
|
|
240
|
+
* whole report body from that point. Matching a header line and taking the LAST
|
|
241
|
+
* occurrence (the real trailing section) avoids that while staying idempotent
|
|
242
|
+
* against the `CITED LINKS\n...` block that formatCitedLinks() emits.
|
|
243
|
+
*/
|
|
244
|
+
export function lastCitedLinksHeaderIndex(text: string): number {
|
|
245
|
+
// Case-insensitive but LINE-LEADING: a mid-prose "...the cited links below..." has
|
|
246
|
+
// non-whitespace before it on the line, so it cannot match — only a header at the
|
|
247
|
+
// start of a line (after optional markdown markers) does.
|
|
248
|
+
const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
|
|
249
|
+
let last = -1;
|
|
250
|
+
let m: RegExpExecArray | null;
|
|
251
|
+
while ((m = re.exec(text)) !== null) {
|
|
252
|
+
// Offset of the 'C' in CITED LINKS = match start + leading newline + markers.
|
|
253
|
+
last = m.index + m[1]!.length + m[2]!.length;
|
|
254
|
+
}
|
|
255
|
+
return last;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Parses the CITED LINKS section from a researcher report, extracting URLs, sources, and their descriptions.
|
|
260
|
+
* Handles both single-line 'URL - description' and multi-line 'Source:'/'Description:' formats.
|
|
261
|
+
*/
|
|
262
|
+
export function parseCitations(report: string): Citation[] {
|
|
263
|
+
const headerIdx = lastCitedLinksHeaderIndex(report);
|
|
264
|
+
if (headerIdx < 0) return [];
|
|
265
|
+
const section = report.slice(headerIdx);
|
|
266
|
+
|
|
267
|
+
const citations: Citation[] = [];
|
|
268
|
+
// Match a list marker [N] or N. (optionally **-wrapped) ONLY at the start of a line.
|
|
269
|
+
// Entries in the CITED LINKS section are always line-leading, so anchoring to line
|
|
270
|
+
// start (^ with the `m` flag) is loss-free — and critically it stops the bare "N."
|
|
271
|
+
// form from matching a digits-then-dot run INSIDE a URL (e.g. `report-2024.pdf`,
|
|
272
|
+
// `/v2.0/`, or an IP literal `192.168.1.10`), which previously truncated or dropped
|
|
273
|
+
// those citations. The split and the parallel number-capture regex MUST stay
|
|
274
|
+
// structurally identical so blocks[i] and writtenNumbers[i] remain index-aligned.
|
|
275
|
+
const blocks = section.split(/^[^\S\n]*(?:\*\*)?[^\S\n]*(?:\[\d+\]|\d+\.)[^\S\n]*(?:\*\*)?/m).slice(1);
|
|
276
|
+
// Capture the written number for each delimiter in parallel, using a SEPARATE global+capturing
|
|
277
|
+
// regex (adding a capture group to the split pattern above would inject the captures into the
|
|
278
|
+
// split output and break block indexing). blocks[i] is the content after delimiter i, so
|
|
279
|
+
// writtenNumbers[i] is blocks[i]'s label — used to remap inline [N] by label, not position.
|
|
280
|
+
const writtenNumbers = [...section.matchAll(/^[^\S\n]*(?:\*\*)?[^\S\n]*(?:\[(\d+)\]|(\d+)\.)[^\S\n]*(?:\*\*)?/gm)]
|
|
281
|
+
.map(m => parseInt(m[1] ?? m[2] ?? '', 10));
|
|
282
|
+
|
|
283
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
284
|
+
const block = blocks[bi]!;
|
|
285
|
+
const writtenNumber = Number.isFinite(writtenNumbers[bi]) ? writtenNumbers[bi] : undefined;
|
|
286
|
+
const lines = block.trim().split('\n');
|
|
287
|
+
if (lines.length === 0) continue;
|
|
288
|
+
|
|
289
|
+
const firstLine = lines[0]!.trim();
|
|
290
|
+
let url: string;
|
|
291
|
+
let desc = '';
|
|
292
|
+
let source = '';
|
|
293
|
+
|
|
294
|
+
// Handles both plain inline format (URL — desc) and the format produced by
|
|
295
|
+
// the researcher prompt template: URL [Source: Scrape] — desc.
|
|
296
|
+
// Group 1: URL, Group 2: optional source tag contents, Group 3: description.
|
|
297
|
+
const inlineMatch = /^(https?:\/\/[^\s\n]+)(?:\s+\[Source:\s*([^\]]*)\])?(?:\s*[—–-]\s*([^\n]*))?/.exec(firstLine);
|
|
298
|
+
if (inlineMatch) {
|
|
299
|
+
// Balance-aware trailing-punctuation strip (shared with normalizeUrl): a BLIND
|
|
300
|
+
// ')' strip here truncated balanced-paren URLs — e.g. Wikipedia's
|
|
301
|
+
// "…/Python_(programming_language)" — before normalizeUrl ever saw them, shipping
|
|
302
|
+
// a broken 404 in the CITED LINKS section. stripTrailingLlmPunctuation removes a
|
|
303
|
+
// ')' only when unbalanced and preserves unreserved trailing '_'/'~'.
|
|
304
|
+
url = stripTrailingLlmPunctuation(inlineMatch[1]!.trim());
|
|
305
|
+
source = (inlineMatch[2]?.trim() || '');
|
|
306
|
+
desc = (inlineMatch[3]?.trim() || '').replace(/[*_~`]+$/, '');
|
|
307
|
+
} else {
|
|
308
|
+
const candidateUrl = stripTrailingLlmPunctuation(firstLine.split(/\s+/)[0]!.trim());
|
|
309
|
+
if (!candidateUrl.startsWith('http')) continue;
|
|
310
|
+
url = candidateUrl;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const descLines: string[] = [];
|
|
314
|
+
let foundDescTag = false;
|
|
315
|
+
for (let i = 1; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i]!.trim();
|
|
317
|
+
if (line.match(/^Source:/i)) {
|
|
318
|
+
source = line.replace(/^Source:\s*/i, '').trim();
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (!foundDescTag) {
|
|
322
|
+
if (line.match(/^Description:/i)) {
|
|
323
|
+
foundDescTag = true;
|
|
324
|
+
descLines.push(line.replace(/^Description:\s*/i, ''));
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
descLines.push(line);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (descLines.length > 0) {
|
|
332
|
+
desc = descLines.join('\n').trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (url && isPlausibleCitationUrl(url)) {
|
|
336
|
+
citations.push({ url, description: desc, source, number: writtenNumber });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return citations;
|
|
83
340
|
}
|
|
@@ -7,13 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
|
-
import { MAX_GATHERING_CALLS,
|
|
10
|
+
import { MAX_GATHERING_CALLS, MAX_GREP_CALLS, getMaxGatheringCalls, getMaxScrapeBatches } from '../constants.ts';
|
|
11
|
+
import type { Config } from '../config.ts';
|
|
11
12
|
|
|
12
13
|
export interface ToolLimits {
|
|
13
|
-
// Combined gathering limit (search, security_search, stackexchange,
|
|
14
|
+
// Combined gathering limit (search, security_search, stackexchange, youtube_transcript)
|
|
14
15
|
gathering?: number;
|
|
15
16
|
// Scrape limit
|
|
16
17
|
scrape?: number;
|
|
18
|
+
// Specific tool limits (overrides category limit if stricter)
|
|
19
|
+
search?: number;
|
|
20
|
+
// youtube_transcript is capped at one call per researcher
|
|
21
|
+
youtube_transcript?: number;
|
|
22
|
+
// grep is local/free ripgrep — its own generous budget, not the shared gathering pool
|
|
23
|
+
grep?: number;
|
|
17
24
|
// read tool (default pi file read) has no limit
|
|
18
25
|
read?: number;
|
|
19
26
|
}
|
|
@@ -22,6 +29,7 @@ export interface ToolUsage {
|
|
|
22
29
|
category: string;
|
|
23
30
|
callCount: number;
|
|
24
31
|
limit?: number;
|
|
32
|
+
toolCounts: Map<string, number>;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export class ToolUsageTracker {
|
|
@@ -36,7 +44,9 @@ export class ToolUsageTracker {
|
|
|
36
44
|
* Get category for a tool
|
|
37
45
|
*/
|
|
38
46
|
private getCategory(toolName: string): string {
|
|
39
|
-
|
|
47
|
+
// grep is intentionally NOT here: it is local/free ripgrep, not a rate-limited web
|
|
48
|
+
// API, so it gets its own dedicated budget instead of competing for the gathering pool.
|
|
49
|
+
const gatheringTools = ['search', 'security_search', 'stackexchange', 'youtube_transcript'];
|
|
40
50
|
if (gatheringTools.includes(toolName)) {
|
|
41
51
|
return 'gathering';
|
|
42
52
|
}
|
|
@@ -50,48 +60,101 @@ export class ToolUsageTracker {
|
|
|
50
60
|
*/
|
|
51
61
|
recordCall(toolName: string): boolean {
|
|
52
62
|
const category = this.getCategory(toolName);
|
|
53
|
-
const
|
|
63
|
+
const catLimit = this.limits[category as keyof ToolLimits];
|
|
64
|
+
const toolLimit = this.limits[toolName as keyof ToolLimits];
|
|
65
|
+
|
|
54
66
|
const usage = this.getUsage(category);
|
|
55
67
|
|
|
56
|
-
// Check limit
|
|
57
|
-
if (
|
|
68
|
+
// Check category limit
|
|
69
|
+
if (catLimit !== undefined && usage.callCount >= catLimit) {
|
|
58
70
|
logger.debug(
|
|
59
|
-
`[tool-usage] category=${category} blocked tool=${toolName} (limit=${
|
|
71
|
+
`[tool-usage] category=${category} blocked tool=${toolName} (limit=${catLimit})`
|
|
60
72
|
);
|
|
61
73
|
return false;
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
// Check specific tool limit
|
|
77
|
+
const toolCount = usage.toolCounts.get(toolName) || 0;
|
|
78
|
+
if (toolLimit !== undefined && toolCount >= toolLimit) {
|
|
79
|
+
logger.debug(
|
|
80
|
+
`[tool-usage] tool=${toolName} blocked (limit=${toolLimit})`
|
|
81
|
+
);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
// Allowed — increment and log
|
|
65
86
|
usage.callCount++;
|
|
87
|
+
usage.toolCounts.set(toolName, toolCount + 1);
|
|
88
|
+
|
|
66
89
|
logger.debug(
|
|
67
|
-
`[tool-usage] category=${category} calls=${usage.callCount}/${
|
|
90
|
+
`[tool-usage] category=${category} calls=${usage.callCount}/${catLimit ?? 'unlimited'} tool=${toolName} count=${toolCount + 1}/${toolLimit ?? 'unlimited'}`
|
|
68
91
|
);
|
|
69
92
|
return true;
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
/**
|
|
73
|
-
* Get current call count for a tool
|
|
96
|
+
* Get current call count for a tool's category
|
|
74
97
|
*/
|
|
75
98
|
getCallCount(toolName: string): number {
|
|
76
99
|
const category = this.getCategory(toolName);
|
|
77
100
|
return this.getUsage(category).callCount;
|
|
78
101
|
}
|
|
79
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Get current call count for a specific tool
|
|
105
|
+
*/
|
|
106
|
+
getToolCallCount(toolName: string): number {
|
|
107
|
+
const category = this.getCategory(toolName);
|
|
108
|
+
const usage = this.getUsage(category);
|
|
109
|
+
return usage.toolCounts.get(toolName) || 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get the specific limit configured for a tool
|
|
114
|
+
*/
|
|
115
|
+
getToolLimit(toolName: string): number | undefined {
|
|
116
|
+
return this.limits[toolName as keyof ToolLimits];
|
|
117
|
+
}
|
|
118
|
+
|
|
80
119
|
/**
|
|
81
120
|
* Get limit-reached message for a blocked tool
|
|
82
121
|
*/
|
|
83
122
|
getLimitMessage(toolName: string): string {
|
|
84
123
|
const category = this.getCategory(toolName);
|
|
85
124
|
const usage = this.getUsage(category);
|
|
86
|
-
|
|
125
|
+
|
|
126
|
+
const toolLimit = this.limits[toolName as keyof ToolLimits];
|
|
127
|
+
const catLimit = usage.limit;
|
|
128
|
+
|
|
129
|
+
if (toolName === 'search' && toolLimit === 1) {
|
|
130
|
+
return `SEARCH LIMIT REACHED: You have already used your one search call. ` +
|
|
131
|
+
`Proceed to scraping: use the scrape tool for full deep-dives into your best search results.`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (toolName === 'youtube_transcript' && toolLimit === 1) {
|
|
135
|
+
return `YOUTUBE TRANSCRIPT LIMIT REACHED: You have already used your one youtube_transcript call. ` +
|
|
136
|
+
`Continue with your other gathering tools (search, scrape) and proceed to synthesis.`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (toolName === 'grep') {
|
|
140
|
+
return `CODE SEARCH LIMIT REACHED: You have used all ${this.limits.grep ?? MAX_GREP_CALLS} local code-search calls. ` +
|
|
141
|
+
`Proceed with your other tools (search, scrape) and move toward synthesis.`;
|
|
142
|
+
}
|
|
87
143
|
|
|
88
144
|
if (category === 'scrape') {
|
|
89
|
-
|
|
90
|
-
|
|
145
|
+
const limit = catLimit ?? getMaxScrapeBatches();
|
|
146
|
+
const effectiveText = limit > 99 ? 'unlimited' : `${limit} batch${limit === 1 ? '' : 'es'}`;
|
|
147
|
+
// Generate batch list based on actual limit, but cap display at 6 batches to avoid overly long messages
|
|
148
|
+
const displayLimit = Math.min(limit, 6);
|
|
149
|
+
const batchList = displayLimit <= 3 ? 'Batch 1, Batch 2, Batch 3' :
|
|
150
|
+
Array.from({ length: displayLimit }, (_, i) => `Batch ${i + 1}`).join(', ') + (limit > 6 ? ', ...' : '');
|
|
151
|
+
return `SCRAPE PROTOCOL COMPLETE: You have completed all ${effectiveText} (${batchList}). ` +
|
|
152
|
+
`This tool cannot be used again. Proceed immediately to synthesis: compile your findings and submit your full report.`;
|
|
91
153
|
}
|
|
154
|
+
const limit = catLimit ?? MAX_GATHERING_CALLS;
|
|
92
155
|
return `GATHERING LIMIT REACHED: All ${limit} gathering calls have been used. ` +
|
|
93
156
|
`This tool and all other gathering tools cannot be used again. ` +
|
|
94
|
-
`Proceed to
|
|
157
|
+
`Proceed to Step 2: call scrape with your collected URLs now.`;
|
|
95
158
|
}
|
|
96
159
|
|
|
97
160
|
/**
|
|
@@ -103,6 +166,7 @@ export class ToolUsageTracker {
|
|
|
103
166
|
category,
|
|
104
167
|
callCount: 0,
|
|
105
168
|
limit: this.limits[category as keyof ToolLimits],
|
|
169
|
+
toolCounts: new Map(),
|
|
106
170
|
});
|
|
107
171
|
}
|
|
108
172
|
return this.usage.get(category)!;
|
|
@@ -126,10 +190,13 @@ export class ToolUsageTracker {
|
|
|
126
190
|
/**
|
|
127
191
|
* Create default tool limits for a researcher
|
|
128
192
|
*/
|
|
129
|
-
export function createDefaultToolLimits(): ToolLimits {
|
|
193
|
+
export function createDefaultToolLimits(config?: Config): ToolLimits {
|
|
130
194
|
return {
|
|
131
|
-
gathering:
|
|
132
|
-
scrape:
|
|
195
|
+
gathering: getMaxGatheringCalls(config),
|
|
196
|
+
scrape: getMaxScrapeBatches(config),
|
|
197
|
+
search: 1,
|
|
198
|
+
youtube_transcript: 1,
|
|
199
|
+
grep: MAX_GREP_CALLS,
|
|
133
200
|
read: undefined,
|
|
134
201
|
};
|
|
135
202
|
}
|