@lincoln504/pi-research 0.1.12 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -1,5 +1,7 @@
1
- import type { AgentSession } from '@mariozechner/pi-coding-agent';
2
- import type { AssistantMessage } from '@mariozechner/pi-ai';
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
- return typeof text === 'string' ? text : '';
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
- return extractText(last);
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, MAX_SCRAPE_CALLS } from '../constants.ts';
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, grep)
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
- const gatheringTools = ['search', 'security_search', 'stackexchange', 'grep'];
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 limit = this.limits[category as keyof ToolLimits];
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 — if reached, return false without incrementing
57
- if (limit !== undefined && usage.callCount >= limit) {
68
+ // Check category limit
69
+ if (catLimit !== undefined && usage.callCount >= catLimit) {
58
70
  logger.debug(
59
- `[tool-usage] category=${category} blocked tool=${toolName} (limit=${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}/${limit ?? 'unlimited'} tool=${toolName}`
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
- const limit = usage.limit;
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
- return `SCRAPE PROTOCOL COMPLETE: You have completed all ${limit} scrape calls (handshake + 3 batches). ` +
90
- `This tool cannot be used again. Proceed immediately to Phase 3: synthesize your findings and submit your report.`;
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 Phase 2: call scrape with your collected URLs now.`;
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: MAX_GATHERING_CALLS,
132
- scrape: MAX_SCRAPE_CALLS,
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
  }