@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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/config/searxng/default-settings.yml +189 -0
- package/config/searxng/limiter.toml +34 -0
- package/config/tooling/eslint.config.js +73 -0
- package/config/tooling/vitest.config.integration.ts +17 -0
- package/config/tooling/vitest.config.ts +40 -0
- package/config/tooling/vitest.config.unit.ts +12 -0
- package/docs/ARCHITECTURE.md +109 -0
- package/docs/assets/README-banner.jpg +0 -0
- package/package.json +92 -0
- package/scripts/setup.js +174 -0
- package/src/config.ts +206 -0
- package/src/constants.ts +148 -0
- package/src/healthcheck/index.ts +309 -0
- package/src/index.ts +71 -0
- package/src/infrastructure/searxng-lifecycle.ts +577 -0
- package/src/infrastructure/searxng-manager.ts +1173 -0
- package/src/infrastructure/state-manager.ts +918 -0
- package/src/logger.ts +246 -0
- package/src/orchestration/deep-research-orchestrator.ts +848 -0
- package/src/orchestration/deep-research-reducer.ts +129 -0
- package/src/orchestration/deep-research-types.ts +54 -0
- package/src/orchestration/id-utils.ts +63 -0
- package/src/orchestration/researcher.ts +104 -0
- package/src/orchestration/session-context.ts +43 -0
- package/src/prompts/research-tool-usage.md +45 -0
- package/src/prompts/researcher.md +166 -0
- package/src/prompts/system-coordinator.md +24 -0
- package/src/prompts/system-lead-evaluator.md +225 -0
- package/src/security/cisa-kev.ts +242 -0
- package/src/security/github-advisories.ts +607 -0
- package/src/security/index.ts +406 -0
- package/src/security/nvd.ts +532 -0
- package/src/security/osv.ts +487 -0
- package/src/security/types.ts +138 -0
- package/src/stackexchange/cache.ts +111 -0
- package/src/stackexchange/index.ts +352 -0
- package/src/stackexchange/output/compact.ts +64 -0
- package/src/stackexchange/output/index.ts +7 -0
- package/src/stackexchange/output/json.ts +21 -0
- package/src/stackexchange/output/table.ts +123 -0
- package/src/stackexchange/queries.ts +168 -0
- package/src/stackexchange/rest-client.ts +127 -0
- package/src/stackexchange/types.ts +129 -0
- package/src/tool.ts +430 -0
- package/src/tools/grep.ts +335 -0
- package/src/tools/index.ts +77 -0
- package/src/tools/scrape.ts +451 -0
- package/src/tools/search.ts +188 -0
- package/src/tools/security.ts +292 -0
- package/src/tools/stackexchange.ts +101 -0
- package/src/tui/research-panel.ts +576 -0
- package/src/tui/searxng-status.ts +80 -0
- package/src/types/extension-context.ts +60 -0
- package/src/types/llm.ts +111 -0
- package/src/utils/inject-date.ts +29 -0
- package/src/utils/input-validation.ts +103 -0
- package/src/utils/json-utils.ts +262 -0
- package/src/utils/make-resource-loader.ts +43 -0
- package/src/utils/research-error.ts +205 -0
- package/src/utils/research-export.ts +199 -0
- package/src/utils/searxng-config.ts +94 -0
- package/src/utils/session-state.ts +295 -0
- package/src/utils/shared-links.ts +123 -0
- package/src/utils/shutdown-manager.ts +54 -0
- package/src/utils/text-utils.ts +83 -0
- package/src/utils/tool-usage-tracker.ts +135 -0
- package/src/web-research/dependencies.ts +20 -0
- package/src/web-research/retry-utils.ts +232 -0
- package/src/web-research/scrapers.ts +935 -0
- package/src/web-research/search.ts +235 -0
- package/src/web-research/types.ts +47 -0
- package/src/web-research/utils.ts +158 -0
- 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,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
|
+
}
|