@lincoln504/pi-research 0.1.13 → 1.0.1
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 +55 -138
- package/dist/cli.mjs +23375 -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 +130 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +211 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +122 -0
- package/docs/THIRD-PARTY-NOTICES.md +66 -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 +1195 -0
- package/src/config.ts +1032 -155
- 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 +258 -11
- 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 -212
- package/src/infrastructure/searxng-lifecycle.ts +0 -644
- 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
|
@@ -18,12 +18,13 @@ import {
|
|
|
18
18
|
formatUsersTable,
|
|
19
19
|
formatSitesTable,
|
|
20
20
|
formatCompactQuestions,
|
|
21
|
-
} from './output/
|
|
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 '@
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
209
|
+
const searchParams = buildSearchQuery(queryParams);
|
|
178
210
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
lines.push(`${index}. ${accepted} by ${
|
|
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
|
-
|
|
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 ? '
|
|
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
|
|
28
|
-
|
|
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 ? '
|
|
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:**
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
`
|
|
60
|
+
`Rate limited. Please wait ${waitTime} seconds before making more requests.`,
|
|
85
61
|
);
|
|
86
62
|
}
|
|
87
63
|
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|