@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
@@ -18,12 +18,13 @@ import {
18
18
  formatUsersTable,
19
19
  formatSitesTable,
20
20
  formatCompactQuestions,
21
- } from './output/index.ts';
21
+ } from './output/table.ts';
22
22
  import {
23
23
  formatUsersCompact,
24
24
  formatSitesCompact,
25
25
  } from './output/compact.ts';
26
- import type { ExtensionContext, AgentToolResult } from '@mariozechner/pi-coding-agent';
26
+ import type { ExtensionContext, AgentToolResult } from '@earendil-works/pi-coding-agent';
27
+ import { logger } from '../logger.ts';
27
28
 
28
29
  function notify(
29
30
  ctx: ExtensionContext,
@@ -40,7 +41,7 @@ function loadConfig(): StackExchangeConfig {
40
41
  return {
41
42
  defaultSite: 'stackoverflow.com',
42
43
  apiKey: process.env['STACKEXCHANGE_API_KEY'] ?? null,
43
- requestTimeout: 30000, // 30 seconds
44
+ requestTimeout: 10000, // 10 seconds
44
45
  };
45
46
  }
46
47
 
@@ -75,7 +76,7 @@ export async function stackexchangeCommand(options: {
75
76
  'error',
76
77
  );
77
78
  return {
78
- content: [{ type: 'text', text: `❌ Stack Exchange API quota exhausted (${quota.remaining}/${quota.max} remaining). Please wait until the quota resets.` }],
79
+ content: [{ type: 'text', text: `[Error] Stack Exchange API quota exhausted (${quota.remaining}/${quota.max} remaining). Please wait until the quota resets.` }],
79
80
  details: {
80
81
  quota,
81
82
  command,
@@ -124,22 +125,28 @@ export async function stackexchangeCommand(options: {
124
125
  output = formatTable(result);
125
126
  }
126
127
 
127
- // Add quota info
128
+ // Add source and quota info
128
129
  const quota = client.getQuotaInfo();
129
- output += `\n---\n**API Quota:** ${quota.remaining}/${quota.max} remaining\n`;
130
+ output += `\n---\n**Source: Stack Exchange**\n**API Quota:** ${quota.remaining}/${quota.max} remaining\n`;
131
+
132
+ // Grounding signal for the researcher-executor grounding gate: how many real items this
133
+ // command returned. search yields a Question[] (0 = nothing found = NOT grounding); get/
134
+ // user/site yield a single populated object. See researcher-executor.ts grounding accumulation.
135
+ const groundingHits = Array.isArray(result) ? result.length : (result ? 1 : 0);
130
136
 
131
137
  return {
132
138
  content: [{ type: 'text', text: output }],
133
139
  details: {
134
140
  quota,
135
141
  command,
142
+ groundingHits,
136
143
  },
137
144
  };
138
145
  } catch (error) {
139
146
  const message = error instanceof Error ? error.message : String(error);
140
147
  notify(ctx, `Stack Exchange error: ${message}`, 'error');
141
148
  return {
142
- content: [{ type: 'text', text: `❌ Stack Exchange error: ${message}` }],
149
+ content: [{ type: 'text', text: `[Error] Stack Exchange error: ${message}` }],
143
150
  details: {
144
151
  quota: client.getQuotaInfo(),
145
152
  command,
@@ -149,7 +156,7 @@ export async function stackexchangeCommand(options: {
149
156
  }
150
157
 
151
158
  /**
152
- * Execute search command
159
+ * Execute search command with pagination support
153
160
  */
154
161
  async function executeSearch(
155
162
  params: Record<string, unknown>,
@@ -160,29 +167,73 @@ async function executeSearch(
160
167
  const query = params['query'] as string | undefined;
161
168
  const site = (params['site'] as string | undefined) ?? config.defaultSite;
162
169
  const limit = Math.min((params['limit'] as number | undefined) ?? 10, 100);
170
+ // Cap the LLM-supplied page count so a large value can't fan out into a quota-burning
171
+ // paging storm against the rate-limited API (mirrors the `limit` clamp). Only the upper
172
+ // bound is imposed — a 0/negative value still means "no pages" (loop runs zero times),
173
+ // preserving the pre-cap behavior.
174
+ const maxPages = Math.min(10, (params['maxPages'] as number | undefined) ?? 5);
163
175
  const tagsInput = params['tags'] as string | null;
164
176
  // Convert tags: "tag1,tag2" is converted to semicolon-separated for API
165
177
  const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0).join(';') : undefined;
166
178
 
167
- // Build query parameters
168
- const queryParams = {
169
- order: 'desc' as const,
170
- sort: 'relevance' as const,
171
- q: query ?? undefined,
172
- tagged: tags ?? undefined,
173
- pagesize: limit,
174
- site,
175
- };
179
+ const allQuestions: Question[] = [];
180
+ // Fetch up to `limit` per page (API max 30) so the common case needs a single
181
+ // request. maxPages is only a safety cap for when a page returns short of the
182
+ // limit — it is NOT a target, so we must not divide the limit across it (that
183
+ // made the default limit=10 spend 5 calls of 2 items each, burning quota and
184
+ // raising the odds of a mid-search backoff).
185
+ const pageSize = Math.min(30, limit); // Up to 30 per page
186
+
187
+ for (let page = 1; page <= maxPages && allQuestions.length < limit; page++) {
188
+ // Stop paging once cancelled: the API is rate-limited (a shared per-window quota),
189
+ // so continuing to fetch pages for an abandoned run burns quota that a live run
190
+ // needs. Return whatever earlier pages already produced (graceful, matches the
191
+ // per-page-failure behavior below).
192
+ if (signal?.aborted) break;
193
+
194
+ // Build query parameters with pagination
195
+ const queryParams = {
196
+ order: 'desc' as const,
197
+ sort: 'relevance' as const,
198
+ q: query ?? undefined,
199
+ tagged: tags ?? undefined,
200
+ pagesize: pageSize,
201
+ page,
202
+ site,
203
+ // Include the question body inline. SE quota is charged per request, not per
204
+ // filter, so this enriches each result at zero extra quota cost and the table
205
+ // renderer already displays q.body when present.
206
+ filter: Filters.WITH_BODY,
207
+ };
176
208
 
177
- const searchParams = buildSearchQuery(queryParams);
209
+ const searchParams = buildSearchQuery(queryParams);
178
210
 
179
- // Make request
180
- const response = await client.request<Question>(
181
- { method: 'GET', endpoint: '/search/advanced', params: searchParams },
182
- signal,
183
- );
211
+ // Make request. If a later page fails (rate limit, HTTP error) but we already
212
+ // have results from earlier pages, return those rather than discarding the whole
213
+ // search — only rethrow when we have nothing to show.
214
+ let response;
215
+ try {
216
+ response = await client.request<Question>(
217
+ { method: 'GET', endpoint: '/search/advanced', params: searchParams },
218
+ signal,
219
+ );
220
+ } catch (err) {
221
+ if (allQuestions.length > 0) {
222
+ logger.warn(`[stackexchange] Pagination stopped at page ${page} after error; returning ${allQuestions.length} partial result(s):`, err);
223
+ break;
224
+ }
225
+ throw err;
226
+ }
184
227
 
185
- return response.items;
228
+ if (response.items.length === 0) {
229
+ // No more results
230
+ break;
231
+ }
232
+
233
+ allQuestions.push(...response.items);
234
+ }
235
+
236
+ return allQuestions.slice(0, limit);
186
237
  }
187
238
 
188
239
  /**
@@ -209,7 +260,7 @@ async function executeGet(
209
260
  });
210
261
 
211
262
  const response = await client.request<Question>(
212
- { method: 'GET', endpoint: `/questions/${id}`, params: questionParams },
263
+ { method: 'GET', endpoint: `/questions/${encodeURIComponent(id)}`, params: questionParams },
213
264
  signal,
214
265
  );
215
266
 
@@ -230,7 +281,7 @@ async function executeGet(
230
281
  });
231
282
 
232
283
  const answersResponse = await client.request<Answer>(
233
- { method: 'GET', endpoint: `/questions/${id}/answers`, params: answersParams },
284
+ { method: 'GET', endpoint: `/questions/${encodeURIComponent(id)}/answers`, params: answersParams },
234
285
  signal,
235
286
  );
236
287
 
@@ -261,7 +312,10 @@ async function executeUser(
261
312
  });
262
313
 
263
314
  const response = await client.request<User>(
264
- { method: 'GET', endpoint: `/users/${id}`, params: userParams },
315
+ // Escape the LLM-supplied id in the path (consistent with executeGet) — it is
316
+ // host-confined to the StackExchange API base, but an unescaped id could otherwise
317
+ // inject extra path/query segments.
318
+ { method: 'GET', endpoint: `/users/${encodeURIComponent(String(id))}`, params: userParams },
265
319
  signal,
266
320
  );
267
321
 
@@ -2,7 +2,7 @@
2
2
  * Compact output formatter for Stack Exchange results
3
3
  */
4
4
 
5
- import type { Question, Answer, User, Site } from '../types';
5
+ import type { Question, Answer, User, Site } from '../types.ts';
6
6
 
7
7
  export function formatQuestionsCompact(questions: Question[]): string {
8
8
  if (questions.length === 0) {
@@ -12,7 +12,7 @@ export function formatQuestionsCompact(questions: Question[]): string {
12
12
  const lines: string[] = [];
13
13
  let index = 1;
14
14
  for (const q of questions) {
15
- const accepted = q.accepted_answer_id ? '✓' : ' ';
15
+ const accepted = q.accepted_answer_id ? '[accepted]' : ' ';
16
16
  lines.push(`${index}. ${accepted} [${q.title}](${q.link}) (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views)`);
17
17
  index++;
18
18
  }
@@ -27,9 +27,9 @@ export function formatAnswersCompact(answers: Answer[]): string {
27
27
  const lines: string[] = [];
28
28
  let index = 1;
29
29
  for (const a of answers) {
30
- const accepted = a.is_accepted ? '✓' : ' ';
31
- const authorName = a.owner?.display_name ?? 'Unknown';
32
- lines.push(`${index}. ${accepted} by ${authorName} (${a.score} pts)`);
30
+ const accepted = a.is_accepted ? '[accepted]' : ' ';
31
+ const displayName = a.owner?.display_name ?? 'Unknown';
32
+ lines.push(`${index}. ${accepted} by ${displayName} (${a.score} pts)`);
33
33
  index++;
34
34
  }
35
35
  return lines.join('\n');
@@ -43,7 +43,8 @@ export function formatUsersCompact(users: User[]): string {
43
43
  const lines: string[] = [];
44
44
  let index = 1;
45
45
  for (const u of users) {
46
- lines.push(`${index}. ${u.display_name} (rep: ${u.reputation}, 🥇${u.badge_counts.gold} 🥈${u.badge_counts.silver} 🥉${u.badge_counts.bronze})`);
46
+ const bc = u.badge_counts ?? { gold: 0, silver: 0, bronze: 0 };
47
+ lines.push(`${index}. ${u.display_name} (rep: ${u.reputation}, gold:${bc.gold} silver:${bc.silver} bronze:${bc.bronze})`);
47
48
  index++;
48
49
  }
49
50
  return lines.join('\n');
@@ -2,7 +2,7 @@
2
2
  * Table output formatter for Stack Exchange results
3
3
  */
4
4
 
5
- import type { Question, Answer, User, Site } from '../types';
5
+ import type { Question, Answer, User, Site } from '../types.ts';
6
6
 
7
7
  export function formatQuestionsTable(questions: Question[]): string {
8
8
  if (questions.length === 0) {
@@ -16,7 +16,7 @@ export function formatQuestionsTable(questions: Question[]): string {
16
16
  output += `## ${q.title}\n\n`;
17
17
  output += `- **Score:** ${q.score}\n`;
18
18
  output += `- **Views:** ${q.view_count}\n`;
19
- output += `- **Answers:** ${q.answer_count}${q.accepted_answer_id ? ' ✓' : ''}\n`;
19
+ output += `- **Answers:** ${q.answer_count}${q.accepted_answer_id ? ' [accepted]' : ''}\n`;
20
20
  output += `- **Tags:** ${q.tags.join(', ')}\n`;
21
21
  if (q.owner) {
22
22
  output += `- **Author:** ${q.owner.display_name} (rep: ${q.owner.reputation})\n`;
@@ -24,8 +24,12 @@ export function formatQuestionsTable(questions: Question[]): string {
24
24
  output += `- **Link:** ${q.link}\n`;
25
25
  output += `- **Created:** ${new Date(q.creation_date * 1000).toLocaleString()}\n`;
26
26
 
27
- if (q.body && q.body.length < 1000) {
28
- output += `- **Body:** ${q.body.substring(0, 500)}...\n`;
27
+ if (q.body) {
28
+ // Truncate long bodies to a preview rather than dropping them entirely — the
29
+ // previous `length < 1000` guard omitted the Body line for the longest
30
+ // questions (the ones most in need of truncation).
31
+ const body = q.body.length > 500 ? `${q.body.substring(0, 500)}...` : q.body;
32
+ output += `- **Body:** ${body}\n`;
29
33
  }
30
34
 
31
35
  output += '\n---\n\n';
@@ -45,7 +49,7 @@ export function formatAnswersTable(answers: Answer[]): string {
45
49
  for (const a of answers) {
46
50
  const authorName = a.owner?.display_name ?? 'Unknown';
47
51
  output += `## Answer by ${authorName}\n\n`;
48
- output += `- **Score:** ${a.score}${a.is_accepted ? ' ✓ (Accepted)' : ''}\n`;
52
+ output += `- **Score:** ${a.score}${a.is_accepted ? ' [Accepted]' : ''}\n`;
49
53
  if (a.owner) {
50
54
  output += `- **Author:** ${a.owner.display_name} (rep: ${a.owner.reputation})\n`;
51
55
  }
@@ -73,7 +77,7 @@ export function formatUsersTable(users: User[]): string {
73
77
  for (const u of users) {
74
78
  output += `## ${u.display_name}\n\n`;
75
79
  output += `- **Reputation:** ${u.reputation}\n`;
76
- output += `- **Badges:** 🥇 ${u.badge_counts.gold} 🥈 ${u.badge_counts.silver} 🥉 ${u.badge_counts.bronze}\n`;
80
+ output += `- **Badges:** gold:${u.badge_counts.gold} silver:${u.badge_counts.silver} bronze:${u.badge_counts.bronze}\n`;
77
81
  output += `- **User ID:** ${u.user_id}\n`;
78
82
  output += `- **Member since:** ${new Date(u.creation_date * 1000).toLocaleDateString()}\n`;
79
83
  if (u.location) {
@@ -115,7 +119,7 @@ export function formatCompactQuestions(questions: Question[]): string {
115
119
  let output = '';
116
120
  let index = 1;
117
121
  for (const q of questions) {
118
- const accepted = q.accepted_answer_id ? '✓' : ' ';
122
+ const accepted = q.accepted_answer_id ? '[accepted]' : '';
119
123
  output += `${index}. [${q.title}](${q.link}) ${accepted} (score: ${q.score}, answers: ${q.answer_count})\n`;
120
124
  index++;
121
125
  }
@@ -156,13 +156,3 @@ export const Filters = {
156
156
  // Get answers with body
157
157
  ANSWERS_WITH_BODY: '!9_bDE(fI5',
158
158
  };
159
-
160
- /**
161
- * Parse IDs from query string (handle both single IDs and comma/semicolon separated)
162
- */
163
- export function parseIds(ids: string | number | (string | number)[]): string {
164
- if (Array.isArray(ids)) {
165
- return ids.join(';');
166
- }
167
- return String(ids);
168
- }
@@ -5,6 +5,8 @@
5
5
 
6
6
  import type { StackExchangeWrapper } from './types.ts';
7
7
  import { logger } from '../logger.ts';
8
+ import { CircuitBreaker } from '../utils/circuit-breaker.ts';
9
+ import { metrics } from '../utils/metrics.ts';
8
10
 
9
11
  const API_BASE = 'https://api.stackexchange.com/2.3';
10
12
 
@@ -21,6 +23,7 @@ export class StackExchangeClient {
21
23
  private quotaMax = 300;
22
24
  private requestCount = 0;
23
25
  private lastBackoff: number | null = null;
26
+ private circuitBreaker: CircuitBreaker;
24
27
 
25
28
  constructor(
26
29
  apiKey: string | null,
@@ -28,93 +31,165 @@ export class StackExchangeClient {
28
31
  ) {
29
32
  this._apiKey = apiKey;
30
33
  this._timeout = timeout;
34
+ this.circuitBreaker = new CircuitBreaker({
35
+ failureThreshold: 5,
36
+ resetTimeoutMs: 10000,
37
+ name: 'StackExchange API',
38
+ isTransientError: (err) => {
39
+ if (err instanceof Error) {
40
+ // Count network errors and 5xx errors, but not 4xx client errors (except 429).
41
+ // Match status codes on a word boundary so an incidental "50"/"429" inside a
42
+ // larger number (e.g. a token count) does not spuriously look transient — same
43
+ // class as the word-boundary fix in isTransientSynthesisError.
44
+ const msg = err.message.toLowerCase();
45
+ return msg.includes('timeout') || msg.includes('network') || msg.includes('econn') || /\b5\d\d\b/.test(msg) || /\b429\b/.test(msg);
46
+ }
47
+ return true;
48
+ }
49
+ });
31
50
  }
32
51
 
33
52
  async request<T>(options: RequestOptions, signal?: AbortSignal): Promise<StackExchangeWrapper<T>> {
34
- // Check for backoff from previous requests
35
- if (this.lastBackoff && this.lastBackoff > Date.now()) {
36
- const waitTime = Math.ceil((this.lastBackoff - Date.now()) / 1000);
37
- throw new Error(
38
- `Rate limited. Please wait ${waitTime} seconds before making more requests.`,
39
- );
40
- }
41
-
42
- const url = new URL(`${API_BASE}${options.endpoint}`);
43
- url.search = options.params.toString();
44
-
45
- // Add API key if provided
46
- if (this._apiKey) {
47
- url.searchParams.set('key', this._apiKey);
48
- }
49
-
50
- // Add site parameter if not already present (for methods that need it)
51
- if (!url.searchParams.has('site') && !options.endpoint.startsWith('/sites')) {
52
- url.searchParams.set('site', 'stackoverflow.com');
53
- }
54
-
55
- const controller = new AbortController();
56
- const timeoutId = setTimeout(() => {
57
- controller.abort();
58
- }, this._timeout);
59
-
60
- // Chain the signal if provided
61
- if (signal) {
62
- signal.addEventListener('abort', () => {
63
- clearTimeout(timeoutId);
64
- controller.abort();
65
- });
66
- }
67
-
68
- try {
69
- const response = await fetch(url.toString(), {
70
- method: options.method,
71
- signal: controller.signal,
72
- headers: {
73
- 'Accept': 'application/json',
74
- },
75
- });
76
-
77
- clearTimeout(timeoutId);
78
-
79
- const data = await response.json() as StackExchangeWrapper<T>;
80
-
81
- // Handle API errors
82
- if (data.error_id) {
53
+ const startTime = Date.now();
54
+ return this.circuitBreaker.execute(async () => {
55
+ // Check for backoff from previous requests
56
+ if (this.lastBackoff && this.lastBackoff > Date.now()) {
57
+ const waitTime = Math.ceil((this.lastBackoff - Date.now()) / 1000);
58
+ metrics.increment('stackexchange_backoff_wait_total', 1);
83
59
  throw new Error(
84
- `Stack Exchange API Error (${data.error_id} - ${data.error_name}): ${data.error_message}`,
60
+ `Rate limited. Please wait ${waitTime} seconds before making more requests.`,
85
61
  );
86
62
  }
87
63
 
88
- // Update quota tracking
89
- this.quotaRemaining = data.quota_remaining;
90
- this.quotaMax = data.quota_max;
91
- this.requestCount++;
64
+ const url = new URL(`${API_BASE}${options.endpoint}`);
65
+ url.search = options.params.toString();
92
66
 
93
- // Handle backoff
94
- if (data.backoff) {
95
- this.lastBackoff = Date.now() + (data.backoff * 1000);
96
- logger.warn(`[StackExchange] Backoff required: ${data.backoff} seconds`);
67
+ // Add API key if provided
68
+ if (this._apiKey) {
69
+ url.searchParams.set('key', this._apiKey);
97
70
  }
98
71
 
99
- return data;
100
- } catch (error) {
101
- clearTimeout(timeoutId);
72
+ // Add site parameter if not already present (for methods that need it)
73
+ if (!url.searchParams.has('site') && !options.endpoint.startsWith('/sites')) {
74
+ url.searchParams.set('site', 'stackoverflow.com');
75
+ }
102
76
 
103
- if (error instanceof Error && error.name === 'AbortError') {
104
- throw new Error(`Request timeout after ${this._timeout}ms`, { cause: error });
77
+ const controller = new AbortController();
78
+ const timeoutId = setTimeout(() => {
79
+ controller.abort();
80
+ }, this._timeout);
81
+
82
+ // Chain the signal if provided
83
+ const abortHandler = () => {
84
+ clearTimeout(timeoutId);
85
+ controller.abort();
86
+ };
87
+ if (signal) {
88
+ signal.addEventListener('abort', abortHandler, { once: true });
105
89
  }
106
90
 
107
- throw error;
108
- }
91
+ try {
92
+ const response = await fetch(url.toString(), {
93
+ method: options.method,
94
+ signal: controller.signal,
95
+ headers: {
96
+ 'Accept': 'application/json',
97
+ },
98
+ });
99
+
100
+ // NB: the timeout is cleared in `finally`, not here. Clearing it now —
101
+ // when only the headers have arrived — would leave the body read
102
+ // (response.json() below) with no timeout, so a stalled body could hang
103
+ // for the whole process lifetime.
104
+
105
+ // The SE API returns a structured JSON wrapper (with error_id) even for
106
+ // logical errors, so we still parse on non-2xx. But a maintenance page /
107
+ // CDN interstitial is non-JSON: surface it as an HTTP status error (which
108
+ // the circuit breaker recognizes as transient) instead of an opaque
109
+ // "Unexpected token <" SyntaxError that hides the real cause.
110
+ let data: StackExchangeWrapper<T>;
111
+ try {
112
+ data = await response.json() as StackExchangeWrapper<T>;
113
+ } catch (parseErr) {
114
+ if (!response.ok) throw new Error(`HTTP ${response.status} from Stack Exchange API`, { cause: parseErr });
115
+ throw parseErr;
116
+ }
117
+
118
+ // Handle API errors
119
+ if (data.error_id) {
120
+ const errorName = data.error_name ?? 'unknown';
121
+ metrics.increment('stackexchange_errors_total', 1, {
122
+ endpoint: options.endpoint,
123
+ error_id: data.error_id.toString(),
124
+ error_name: errorName
125
+ });
126
+ throw new Error(
127
+ `Stack Exchange API Error (${data.error_id} - ${data.error_name}): ${data.error_message}`,
128
+ );
129
+ }
130
+
131
+ // Update quota tracking
132
+ this.quotaRemaining = data.quota_remaining;
133
+ this.quotaMax = data.quota_max;
134
+ this.requestCount++;
135
+
136
+ // Track quota metrics
137
+ metrics.setGauge('stackexchange_quota_remaining', this.quotaRemaining);
138
+ metrics.setGauge('stackexchange_quota_max', this.quotaMax);
139
+ metrics.setGauge('stackexchange_quota_used', this.quotaMax - this.quotaRemaining);
140
+
141
+ // Handle backoff
142
+ if (data.backoff) {
143
+ this.lastBackoff = Date.now() + (data.backoff * 1000);
144
+ metrics.increment('stackexchange_backoff_total', 1, { seconds: data.backoff.toString() });
145
+ metrics.observe('stackexchange_backoff_duration_seconds', data.backoff);
146
+ logger.warn(`[StackExchange] Backoff required: ${data.backoff} seconds`);
147
+ }
148
+
149
+ // Track successful requests
150
+ metrics.increment('stackexchange_requests_total', 1, { endpoint: options.endpoint, status: 'success' });
151
+
152
+ return data;
153
+ } catch (error) {
154
+ const duration = Date.now() - startTime;
155
+ metrics.observe('stackexchange_request_duration_ms', duration, { endpoint: options.endpoint, status: 'error' });
156
+ metrics.increment('stackexchange_requests_total', 1, { endpoint: options.endpoint, status: 'error' });
157
+
158
+ if (error instanceof Error && error.name === 'AbortError') {
159
+ metrics.increment('stackexchange_timeouts_total', 1, { endpoint: options.endpoint });
160
+ throw new Error(`Request timeout after ${this._timeout}ms`, { cause: error });
161
+ }
162
+
163
+ throw error;
164
+ } finally {
165
+ // Single clear point: covers both the success and error paths and keeps
166
+ // the timeout armed across the body read above.
167
+ clearTimeout(timeoutId);
168
+ if (signal) {
169
+ signal.removeEventListener('abort', abortHandler);
170
+ }
171
+ }
172
+ });
109
173
  }
110
174
 
111
175
  getQuotaInfo(): { remaining: number; max: number; requestCount: number; lastBackoff: number | null } {
112
- return {
176
+ const info = {
113
177
  remaining: this.quotaRemaining,
114
178
  max: this.quotaMax,
115
179
  requestCount: this.requestCount,
116
180
  lastBackoff: this.lastBackoff,
117
181
  };
182
+
183
+ // Track quota exhaustion events
184
+ if (this.isQuotaExhausted()) {
185
+ metrics.increment('stackexchange_quota_exhausted_total', 1);
186
+ }
187
+
188
+ if (this.isQuotaLow()) {
189
+ metrics.increment('stackexchange_quota_low_total', 1);
190
+ }
191
+
192
+ return info;
118
193
  }
119
194
 
120
195
  isQuotaExhausted(): boolean {
@@ -51,10 +51,6 @@ export interface Question {
51
51
  body_markdown?: string;
52
52
  }
53
53
 
54
- export interface QuestionWithAnswers extends Question {
55
- answers?: Answer[];
56
- }
57
-
58
54
  export interface Answer {
59
55
  owner?: {
60
56
  account_id: number;
@@ -114,16 +110,3 @@ export interface Site {
114
110
  icon_url?: string;
115
111
  }
116
112
 
117
- // Command parameters
118
- export interface CommandParams {
119
- command: 'search' | 'get' | 'user' | 'site';
120
- query?: string;
121
- id?: string | number;
122
- site: string;
123
- limit: number;
124
- format: 'table' | 'json' | 'compact';
125
- tags?: string | null;
126
- full: boolean;
127
- minimal: boolean;
128
- }
129
-