@lincoln504/pi-research 0.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/config/searxng/default-settings.yml +189 -0
  4. package/config/searxng/limiter.toml +34 -0
  5. package/config/tooling/eslint.config.js +73 -0
  6. package/config/tooling/vitest.config.integration.ts +17 -0
  7. package/config/tooling/vitest.config.ts +40 -0
  8. package/config/tooling/vitest.config.unit.ts +12 -0
  9. package/docs/ARCHITECTURE.md +109 -0
  10. package/docs/assets/README-banner.jpg +0 -0
  11. package/package.json +92 -0
  12. package/scripts/setup.js +174 -0
  13. package/src/config.ts +206 -0
  14. package/src/constants.ts +148 -0
  15. package/src/healthcheck/index.ts +309 -0
  16. package/src/index.ts +71 -0
  17. package/src/infrastructure/searxng-lifecycle.ts +577 -0
  18. package/src/infrastructure/searxng-manager.ts +1173 -0
  19. package/src/infrastructure/state-manager.ts +918 -0
  20. package/src/logger.ts +246 -0
  21. package/src/orchestration/deep-research-orchestrator.ts +848 -0
  22. package/src/orchestration/deep-research-reducer.ts +129 -0
  23. package/src/orchestration/deep-research-types.ts +54 -0
  24. package/src/orchestration/id-utils.ts +63 -0
  25. package/src/orchestration/researcher.ts +104 -0
  26. package/src/orchestration/session-context.ts +43 -0
  27. package/src/prompts/research-tool-usage.md +45 -0
  28. package/src/prompts/researcher.md +166 -0
  29. package/src/prompts/system-coordinator.md +24 -0
  30. package/src/prompts/system-lead-evaluator.md +225 -0
  31. package/src/security/cisa-kev.ts +242 -0
  32. package/src/security/github-advisories.ts +607 -0
  33. package/src/security/index.ts +406 -0
  34. package/src/security/nvd.ts +532 -0
  35. package/src/security/osv.ts +487 -0
  36. package/src/security/types.ts +138 -0
  37. package/src/stackexchange/cache.ts +111 -0
  38. package/src/stackexchange/index.ts +352 -0
  39. package/src/stackexchange/output/compact.ts +64 -0
  40. package/src/stackexchange/output/index.ts +7 -0
  41. package/src/stackexchange/output/json.ts +21 -0
  42. package/src/stackexchange/output/table.ts +123 -0
  43. package/src/stackexchange/queries.ts +168 -0
  44. package/src/stackexchange/rest-client.ts +127 -0
  45. package/src/stackexchange/types.ts +129 -0
  46. package/src/tool.ts +430 -0
  47. package/src/tools/grep.ts +335 -0
  48. package/src/tools/index.ts +77 -0
  49. package/src/tools/scrape.ts +451 -0
  50. package/src/tools/search.ts +188 -0
  51. package/src/tools/security.ts +292 -0
  52. package/src/tools/stackexchange.ts +101 -0
  53. package/src/tui/research-panel.ts +576 -0
  54. package/src/tui/searxng-status.ts +80 -0
  55. package/src/types/extension-context.ts +60 -0
  56. package/src/types/llm.ts +111 -0
  57. package/src/utils/inject-date.ts +29 -0
  58. package/src/utils/input-validation.ts +103 -0
  59. package/src/utils/json-utils.ts +262 -0
  60. package/src/utils/make-resource-loader.ts +43 -0
  61. package/src/utils/research-error.ts +205 -0
  62. package/src/utils/research-export.ts +199 -0
  63. package/src/utils/searxng-config.ts +94 -0
  64. package/src/utils/session-state.ts +295 -0
  65. package/src/utils/shared-links.ts +123 -0
  66. package/src/utils/shutdown-manager.ts +54 -0
  67. package/src/utils/text-utils.ts +83 -0
  68. package/src/utils/tool-usage-tracker.ts +135 -0
  69. package/src/web-research/dependencies.ts +20 -0
  70. package/src/web-research/retry-utils.ts +232 -0
  71. package/src/web-research/scrapers.ts +935 -0
  72. package/src/web-research/search.ts +235 -0
  73. package/src/web-research/types.ts +47 -0
  74. package/src/web-research/utils.ts +158 -0
  75. package/tsconfig.json +40 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Simple file-based cache for Stack Exchange API responses
3
+ */
4
+
5
+ import * as fs from 'node:fs';
6
+ import { logger } from '../logger.ts';
7
+ import * as path from 'node:path';
8
+ import * as os from 'node:os';
9
+
10
+ const CACHE_DIR = path.join(os.homedir(), '.pi', 'cache', 'stackexchange');
11
+
12
+ // Lazy-initialize cache directory on first use (prevents race conditions)
13
+ let cacheInitialized = false;
14
+
15
+ function ensureCacheDir(): void {
16
+ if (cacheInitialized) {
17
+ return;
18
+ }
19
+ try {
20
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
21
+ cacheInitialized = true;
22
+ } catch {
23
+ // Directory might already exist
24
+ cacheInitialized = true;
25
+ }
26
+ }
27
+
28
+ export interface CacheEntry<T> {
29
+ data: T;
30
+ timestamp: number;
31
+ ttl: number;
32
+ }
33
+
34
+ export function cacheGet<T>(key: string): T | null {
35
+ ensureCacheDir();
36
+ try {
37
+ const cachePath = path.join(CACHE_DIR, `${key}.json`);
38
+ const content = fs.readFileSync(cachePath, 'utf-8');
39
+ const entry: CacheEntry<T> = JSON.parse(content) as CacheEntry<T>;
40
+
41
+ // Check if cache entry is still valid
42
+ const now = Date.now();
43
+ const age = (now - entry.timestamp) / 1000; // Age in seconds
44
+
45
+ if (age > entry.ttl) {
46
+ // Cache expired
47
+ try {
48
+ fs.unlinkSync(cachePath);
49
+ } catch {
50
+ // Ignore errors when deleting expired cache
51
+ }
52
+ return null;
53
+ }
54
+
55
+ return entry.data;
56
+ } catch {
57
+ // Cache file doesn't exist or is invalid
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function cacheSet<T>(key: string, data: T, ttl: number): void {
63
+ ensureCacheDir();
64
+ try {
65
+ const cachePath = path.join(CACHE_DIR, `${key}.json`);
66
+ const entry: CacheEntry<T> = {
67
+ data,
68
+ timestamp: Date.now(),
69
+ ttl,
70
+ };
71
+ fs.writeFileSync(cachePath, JSON.stringify(entry));
72
+ } catch (error) {
73
+ logger.warn('[StackExchange] Failed to write cache:', error);
74
+ }
75
+ }
76
+
77
+ export function cacheClear(): void {
78
+ try {
79
+ const files = fs.readdirSync(CACHE_DIR);
80
+ for (const file of files) {
81
+ fs.unlinkSync(path.join(CACHE_DIR, file));
82
+ }
83
+ } catch (error) {
84
+ logger.warn('[StackExchange] Failed to clear cache:', error);
85
+ }
86
+ }
87
+
88
+ export function cacheGetStats(): { count: number; size: number } {
89
+ try {
90
+ const files = fs.readdirSync(CACHE_DIR);
91
+ let totalSize = 0;
92
+ for (const file of files) {
93
+ const filePath = path.join(CACHE_DIR, file);
94
+ const stats = fs.statSync(filePath);
95
+ totalSize += stats.size;
96
+ }
97
+ return {
98
+ count: files.length,
99
+ size: totalSize,
100
+ };
101
+ } catch {
102
+ return { count: 0, size: 0 };
103
+ }
104
+ }
105
+
106
+ // Sanitize cache keys (replace special characters)
107
+ export function sanitizeCacheKey(key: string): string {
108
+ return key
109
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
110
+ .substring(0, 100); // Limit length
111
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Stack Exchange Integration for pi-research
3
+ * Main command handler
4
+ */
5
+
6
+ import type { Question, Answer, User, Site, StackExchangeConfig } from './types.ts';
7
+ import { StackExchangeClient } from './rest-client.ts';
8
+ import {
9
+ buildSearchQuery,
10
+ buildQuestionsQuery,
11
+ buildUserQuery,
12
+ buildSitesQuery,
13
+ Filters,
14
+ } from './queries.ts';
15
+ import {
16
+ formatQuestionsTable,
17
+ formatAnswersTable,
18
+ formatUsersTable,
19
+ formatSitesTable,
20
+ formatCompactQuestions,
21
+ } from './output/index.ts';
22
+ import {
23
+ formatUsersCompact,
24
+ formatSitesCompact,
25
+ } from './output/compact.ts';
26
+ import type { ExtensionContext, AgentToolResult } from '@mariozechner/pi-coding-agent';
27
+
28
+ function notify(
29
+ ctx: ExtensionContext,
30
+ message: string,
31
+ type: 'info' | 'warning' | 'error',
32
+ ): void {
33
+ ctx.ui?.notify?.(message, type);
34
+ }
35
+
36
+ /**
37
+ * Load configuration from environment variables or defaults
38
+ */
39
+ function loadConfig(): StackExchangeConfig {
40
+ return {
41
+ defaultSite: 'stackoverflow.com',
42
+ apiKey: process.env['STACKEXCHANGE_API_KEY'] ?? null,
43
+ requestTimeout: 30000, // 30 seconds
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Main command handler for Stack Exchange operations
49
+ */
50
+ export async function stackexchangeCommand(options: {
51
+ command: string;
52
+ params: Record<string, unknown>;
53
+ ctx: ExtensionContext;
54
+ signal?: AbortSignal;
55
+ onUpdate?: (_update: unknown) => void;
56
+ }): Promise<AgentToolResult<unknown>> {
57
+ const { command, params, ctx, signal, onUpdate: _onUpdate } = options;
58
+
59
+ // Load config
60
+ const config = loadConfig();
61
+
62
+ // Initialize client
63
+ const client = new StackExchangeClient(
64
+ config.apiKey,
65
+ config.requestTimeout,
66
+ );
67
+
68
+ try {
69
+ // Check quota before making requests
70
+ if (client.isQuotaExhausted()) {
71
+ const quota = client.getQuotaInfo();
72
+ notify(
73
+ ctx,
74
+ `Stack Exchange API quota exhausted (${quota.remaining}/${quota.max} remaining)`,
75
+ 'error',
76
+ );
77
+ return {
78
+ content: [{ type: 'text', text: `❌ Stack Exchange API quota exhausted (${quota.remaining}/${quota.max} remaining). Please wait until the quota resets.` }],
79
+ details: {
80
+ quota,
81
+ command,
82
+ },
83
+ };
84
+ }
85
+
86
+ // Warn if quota is low
87
+ if (client.isQuotaLow()) {
88
+ const quota = client.getQuotaInfo();
89
+ notify(
90
+ ctx,
91
+ `Stack Exchange API quota low: ${quota.remaining}/${quota.max} remaining`,
92
+ 'warning',
93
+ );
94
+ }
95
+
96
+ // Execute command
97
+ let result: unknown;
98
+ switch (command) {
99
+ case 'search':
100
+ result = await executeSearch(params, client, config, signal);
101
+ break;
102
+ case 'get':
103
+ result = await executeGet(params, client, config, signal);
104
+ break;
105
+ case 'user':
106
+ result = await executeUser(params, client, config, signal);
107
+ break;
108
+ case 'site':
109
+ result = await executeSite(params, client, config, signal);
110
+ break;
111
+ default:
112
+ throw new Error(`Unknown command: ${command}`);
113
+ }
114
+
115
+ // Format output
116
+ const format = (params['format'] as string | undefined) ?? 'table';
117
+ let output: string;
118
+
119
+ if (format === 'json') {
120
+ output = JSON.stringify(result, null, 2);
121
+ } else if (format === 'compact') {
122
+ output = formatCompact(result);
123
+ } else {
124
+ output = formatTable(result);
125
+ }
126
+
127
+ // Add quota info
128
+ const quota = client.getQuotaInfo();
129
+ output += `\n---\n**API Quota:** ${quota.remaining}/${quota.max} remaining\n`;
130
+
131
+ return {
132
+ content: [{ type: 'text', text: output }],
133
+ details: {
134
+ quota,
135
+ command,
136
+ },
137
+ };
138
+ } catch (error) {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ notify(ctx, `Stack Exchange error: ${message}`, 'error');
141
+ return {
142
+ content: [{ type: 'text', text: `❌ Stack Exchange error: ${message}` }],
143
+ details: {
144
+ quota: client.getQuotaInfo(),
145
+ command,
146
+ },
147
+ };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Execute search command
153
+ */
154
+ async function executeSearch(
155
+ params: Record<string, unknown>,
156
+ client: StackExchangeClient,
157
+ config: StackExchangeConfig,
158
+ signal?: AbortSignal,
159
+ ): Promise<Question[]> {
160
+ const query = params['query'] as string | undefined;
161
+ const site = (params['site'] as string | undefined) ?? config.defaultSite;
162
+ const limit = Math.min((params['limit'] as number | undefined) ?? 10, 100);
163
+ const tagsInput = params['tags'] as string | null;
164
+ // Convert tags: "tag1,tag2" is converted to semicolon-separated for API
165
+ const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0).join(';') : undefined;
166
+
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
+ };
176
+
177
+ const searchParams = buildSearchQuery(queryParams);
178
+
179
+ // Make request
180
+ const response = await client.request<Question>(
181
+ { method: 'GET', endpoint: '/search/advanced', params: searchParams },
182
+ signal,
183
+ );
184
+
185
+ return response.items;
186
+ }
187
+
188
+ /**
189
+ * Execute get command (get question by ID)
190
+ */
191
+ async function executeGet(
192
+ params: Record<string, unknown>,
193
+ client: StackExchangeClient,
194
+ config: StackExchangeConfig,
195
+ signal?: AbortSignal,
196
+ ): Promise<{ question: Question; answers: Answer[] }> {
197
+ const id = params['id'] as string | number | undefined;
198
+ const site = (params['site'] as string | undefined) ?? config.defaultSite;
199
+
200
+ if (id === undefined) {
201
+ throw new Error('ID parameter is required for get command');
202
+ }
203
+
204
+ // Get question with full body
205
+ const questionParams = buildQuestionsQuery({
206
+ ids: id,
207
+ site,
208
+ filter: Filters.WITH_BODY,
209
+ });
210
+
211
+ const response = await client.request<Question>(
212
+ { method: 'GET', endpoint: `/questions/${id}`, params: questionParams },
213
+ signal,
214
+ );
215
+
216
+ if (response.items.length === 0) {
217
+ throw new Error(`Question not found: ${id}`);
218
+ }
219
+
220
+ const question = response.items[0];
221
+ if (question === undefined) {
222
+ throw new Error('Question not found');
223
+ }
224
+
225
+ // Always get answers
226
+ const answersParams = buildQuestionsQuery({
227
+ ids: id,
228
+ site,
229
+ filter: Filters.ANSWERS_WITH_BODY,
230
+ });
231
+
232
+ const answersResponse = await client.request<Answer>(
233
+ { method: 'GET', endpoint: `/questions/${id}/answers`, params: answersParams },
234
+ signal,
235
+ );
236
+
237
+ return { question, answers: answersResponse.items };
238
+ }
239
+
240
+ /**
241
+ * Execute user command
242
+ */
243
+ async function executeUser(
244
+ params: Record<string, unknown>,
245
+ client: StackExchangeClient,
246
+ config: StackExchangeConfig,
247
+ signal?: AbortSignal,
248
+ ): Promise<User[]> {
249
+ const id = params['id'] as string | number | undefined;
250
+ const site = (params['site'] as string | undefined) ?? config.defaultSite;
251
+ const limit = Math.min((params['limit'] as number | undefined) ?? 1, 100);
252
+
253
+ if (id === undefined) {
254
+ throw new Error('ID parameter is required for user command');
255
+ }
256
+
257
+ const userParams = buildUserQuery({
258
+ ids: id,
259
+ site,
260
+ pagesize: limit,
261
+ });
262
+
263
+ const response = await client.request<User>(
264
+ { method: 'GET', endpoint: `/users/${id}`, params: userParams },
265
+ signal,
266
+ );
267
+
268
+ if (response.items.length === 0) {
269
+ throw new Error(`User not found: ${id}`);
270
+ }
271
+
272
+ return response.items;
273
+ }
274
+
275
+ /**
276
+ * Execute site command
277
+ */
278
+ async function executeSite(
279
+ _params: Record<string, unknown>,
280
+ client: StackExchangeClient,
281
+ _config: StackExchangeConfig,
282
+ signal?: AbortSignal,
283
+ ): Promise<Site[]> {
284
+ const sitesParams = buildSitesQuery({ pagesize: 100 });
285
+
286
+ const response = await client.request<Site>(
287
+ { method: 'GET', endpoint: '/sites', params: sitesParams },
288
+ signal,
289
+ );
290
+
291
+ return response.items;
292
+ }
293
+
294
+ /**
295
+ * Format results as table
296
+ */
297
+ function formatTable(result: unknown): string {
298
+ if (Array.isArray(result)) {
299
+ const first = result[0] as Record<string, unknown> | undefined;
300
+ if (first && typeof first === 'object') {
301
+ if ('badge_counts' in first) {
302
+ // Users
303
+ return formatUsersTable(result as User[]);
304
+ }
305
+ if ('api_site_parameter' in first) {
306
+ // Sites
307
+ return formatSitesTable(result as Site[]);
308
+ }
309
+ if ('question_id' in first || 'answer_id' in first) {
310
+ // Questions or Answers
311
+ if ('answer_id' in first) {
312
+ return formatAnswersTable(result as Answer[]);
313
+ }
314
+ return formatQuestionsTable(result as Question[]);
315
+ }
316
+ }
317
+ } else if (typeof result === 'object' && result !== null) {
318
+ if ('question' in result && 'answers' in result) {
319
+ // Question with answers
320
+ const r = result as { question: Question; answers: Answer[] };
321
+ let output = formatQuestionsTable([r.question]);
322
+ if (r.answers.length > 0) {
323
+ output += `\n${ formatAnswersTable(r.answers)}`;
324
+ }
325
+ return output;
326
+ }
327
+ }
328
+
329
+ // Fallback to JSON
330
+ return JSON.stringify(result, null, 2);
331
+ }
332
+
333
+ /**
334
+ * Format results as compact
335
+ */
336
+ function formatCompact(result: unknown): string {
337
+ if (Array.isArray(result)) {
338
+ const first = result[0] as Record<string, unknown> | undefined;
339
+ if (first && typeof first === 'object') {
340
+ if ('badge_counts' in first) {
341
+ return formatUsersCompact(result as User[]);
342
+ }
343
+ if ('api_site_parameter' in first) {
344
+ return formatSitesCompact(result as Site[]);
345
+ }
346
+ if ('question_id' in first) {
347
+ return formatCompactQuestions(result as Question[]);
348
+ }
349
+ }
350
+ }
351
+ return JSON.stringify(result);
352
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Compact output formatter for Stack Exchange results
3
+ */
4
+
5
+ import type { Question, Answer, User, Site } from '../types';
6
+
7
+ export function formatQuestionsCompact(questions: Question[]): string {
8
+ if (questions.length === 0) {
9
+ return 'No questions found.';
10
+ }
11
+
12
+ const lines: string[] = [];
13
+ let index = 1;
14
+ for (const q of questions) {
15
+ const accepted = q.accepted_answer_id ? '✓' : ' ';
16
+ lines.push(`${index}. ${accepted} [${q.title}](${q.link}) (${q.score} pts, ${q.answer_count} ans, ${q.view_count} views)`);
17
+ index++;
18
+ }
19
+ return lines.join('\n');
20
+ }
21
+
22
+ export function formatAnswersCompact(answers: Answer[]): string {
23
+ if (answers.length === 0) {
24
+ return 'No answers found.';
25
+ }
26
+
27
+ const lines: string[] = [];
28
+ let index = 1;
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)`);
33
+ index++;
34
+ }
35
+ return lines.join('\n');
36
+ }
37
+
38
+ export function formatUsersCompact(users: User[]): string {
39
+ if (users.length === 0) {
40
+ return 'No users found.';
41
+ }
42
+
43
+ const lines: string[] = [];
44
+ let index = 1;
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})`);
47
+ index++;
48
+ }
49
+ return lines.join('\n');
50
+ }
51
+
52
+ export function formatSitesCompact(sites: Site[]): string {
53
+ if (sites.length === 0) {
54
+ return 'No sites found.';
55
+ }
56
+
57
+ const lines: string[] = [];
58
+ let index = 1;
59
+ for (const s of sites) {
60
+ lines.push(`${index}. ${s.name} (\`${s.api_site_parameter}\`) - ${s.audience || 'N/A'}`);
61
+ index++;
62
+ }
63
+ return lines.join('\n');
64
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Output formatters for Stack Exchange results
3
+ */
4
+
5
+ export * from './table.ts';
6
+ export * from './json.ts';
7
+ export * from './compact.ts';
@@ -0,0 +1,21 @@
1
+ /**
2
+ * JSON output formatter for Stack Exchange results
3
+ */
4
+
5
+ import type { Question, Answer, User, Site } from '../types';
6
+
7
+ export function formatQuestionsJSON(questions: Question[]): string {
8
+ return JSON.stringify(questions, null, 2);
9
+ }
10
+
11
+ export function formatAnswersJSON(answers: Answer[]): string {
12
+ return JSON.stringify(answers, null, 2);
13
+ }
14
+
15
+ export function formatUsersJSON(users: User[]): string {
16
+ return JSON.stringify(users, null, 2);
17
+ }
18
+
19
+ export function formatSitesJSON(sites: Site[]): string {
20
+ return JSON.stringify(sites, null, 2);
21
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Table output formatter for Stack Exchange results
3
+ */
4
+
5
+ import type { Question, Answer, User, Site } from '../types';
6
+
7
+ export function formatQuestionsTable(questions: Question[]): string {
8
+ if (questions.length === 0) {
9
+ return 'No questions found.\n';
10
+ }
11
+
12
+ let output = '# Stack Exchange Questions\n\n';
13
+ output += `Found: ${questions.length} question${questions.length !== 1 ? 's' : ''}\n\n`;
14
+
15
+ for (const q of questions) {
16
+ output += `## ${q.title}\n\n`;
17
+ output += `- **Score:** ${q.score}\n`;
18
+ output += `- **Views:** ${q.view_count}\n`;
19
+ output += `- **Answers:** ${q.answer_count}${q.accepted_answer_id ? ' ✓' : ''}\n`;
20
+ output += `- **Tags:** ${q.tags.join(', ')}\n`;
21
+ if (q.owner) {
22
+ output += `- **Author:** ${q.owner.display_name} (rep: ${q.owner.reputation})\n`;
23
+ }
24
+ output += `- **Link:** ${q.link}\n`;
25
+ output += `- **Created:** ${new Date(q.creation_date * 1000).toLocaleString()}\n`;
26
+
27
+ if (q.body && q.body.length < 1000) {
28
+ output += `- **Body:** ${q.body.substring(0, 500)}...\n`;
29
+ }
30
+
31
+ output += '\n---\n\n';
32
+ }
33
+
34
+ return output;
35
+ }
36
+
37
+ export function formatAnswersTable(answers: Answer[]): string {
38
+ if (answers.length === 0) {
39
+ return 'No answers found.\n';
40
+ }
41
+
42
+ let output = '# Stack Exchange Answers\n\n';
43
+ output += `Found: ${answers.length} answer${answers.length !== 1 ? 's' : ''}\n\n`;
44
+
45
+ for (const a of answers) {
46
+ const authorName = a.owner?.display_name ?? 'Unknown';
47
+ output += `## Answer by ${authorName}\n\n`;
48
+ output += `- **Score:** ${a.score}${a.is_accepted ? ' ✓ (Accepted)' : ''}\n`;
49
+ if (a.owner) {
50
+ output += `- **Author:** ${a.owner.display_name} (rep: ${a.owner.reputation})\n`;
51
+ }
52
+ output += `- **Question ID:** ${a.question_id}\n`;
53
+ output += `- **Created:** ${new Date(a.creation_date * 1000).toLocaleString()}\n`;
54
+
55
+ if (a.body && a.body.length < 2000) {
56
+ output += `\n### Answer Body\n\n${a.body}\n`;
57
+ }
58
+
59
+ output += '\n---\n\n';
60
+ }
61
+
62
+ return output;
63
+ }
64
+
65
+ export function formatUsersTable(users: User[]): string {
66
+ if (users.length === 0) {
67
+ return 'No users found.\n';
68
+ }
69
+
70
+ let output = '# Stack Exchange Users\n\n';
71
+ output += `Found: ${users.length} user${users.length !== 1 ? 's' : ''}\n\n`;
72
+
73
+ for (const u of users) {
74
+ output += `## ${u.display_name}\n\n`;
75
+ output += `- **Reputation:** ${u.reputation}\n`;
76
+ output += `- **Badges:** 🥇 ${u.badge_counts.gold} 🥈 ${u.badge_counts.silver} 🥉 ${u.badge_counts.bronze}\n`;
77
+ output += `- **User ID:** ${u.user_id}\n`;
78
+ output += `- **Member since:** ${new Date(u.creation_date * 1000).toLocaleDateString()}\n`;
79
+ if (u.location) {
80
+ output += `- **Location:** ${u.location}\n`;
81
+ }
82
+ if (u.website_url) {
83
+ output += `- **Website:** ${u.website_url}\n`;
84
+ }
85
+ output += `- **Profile:** ${u.link}\n`;
86
+ output += '\n---\n\n';
87
+ }
88
+
89
+ return output;
90
+ }
91
+
92
+ export function formatSitesTable(sites: Site[]): string {
93
+ if (sites.length === 0) {
94
+ return 'No sites found.\n';
95
+ }
96
+
97
+ let output = '# Stack Exchange Sites\n\n';
98
+ output += `Found: ${sites.length} site${sites.length !== 1 ? 's' : ''}\n\n`;
99
+
100
+ output += '| Site | API Parameter | Audience |\n';
101
+ output += '|------|---------------|----------|\n';
102
+
103
+ for (const s of sites) {
104
+ output += `| ${s.name} | \`${s.api_site_parameter}\` | ${s.audience || 'N/A'} |\n`;
105
+ }
106
+
107
+ return `${output}\n`;
108
+ }
109
+
110
+ export function formatCompactQuestions(questions: Question[]): string {
111
+ if (questions.length === 0) {
112
+ return 'No questions found.\n';
113
+ }
114
+
115
+ let output = '';
116
+ let index = 1;
117
+ for (const q of questions) {
118
+ const accepted = q.accepted_answer_id ? '✓' : ' ';
119
+ output += `${index}. [${q.title}](${q.link}) ${accepted} (score: ${q.score}, answers: ${q.answer_count})\n`;
120
+ index++;
121
+ }
122
+ return output;
123
+ }