@jetcrab/pi-desk-subagent 0.0.0-stage → 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.
@@ -0,0 +1,637 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
4
+ import { StringEnum } from '@earendil-works/pi-ai';
5
+ import { Type } from 'typebox';
6
+ const CACHE_VERSION = 1;
7
+ const CACHE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
8
+ const FETCH_TIMEOUT_MS = 30_000;
9
+ const MAX_FETCH_BYTES = 2 * 1024 * 1024;
10
+ const MAX_TOOL_CHARS = 30_000;
11
+ const MAX_CACHE_READ_CHARS = 30_000;
12
+ const ANYSEARCH_API_URL = 'https://api.anysearch.com/v1/search';
13
+ const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14
+ function nowIso(now) {
15
+ return new Date(now()).toISOString();
16
+ }
17
+ function hashText(value) {
18
+ return `sha256:${createHash('sha256').update(value).digest('hex')}`;
19
+ }
20
+ function cacheKey(kind, value) {
21
+ return `${kind}:${createHash('sha256').update(JSON.stringify(value)).digest('hex')}`;
22
+ }
23
+ function emptyIndex(now) {
24
+ return { version: CACHE_VERSION, updatedAt: nowIso(now), entries: [] };
25
+ }
26
+ function asIndex(value, now) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
28
+ return emptyIndex(now);
29
+ }
30
+ const candidate = value;
31
+ if (candidate.version !== CACHE_VERSION || !Array.isArray(candidate.entries)) {
32
+ return emptyIndex(now);
33
+ }
34
+ return {
35
+ version: CACHE_VERSION,
36
+ updatedAt: typeof candidate.updatedAt === 'string' ? candidate.updatedAt : nowIso(now),
37
+ entries: candidate.entries.filter((entry) => Boolean(entry &&
38
+ typeof entry.key === 'string' &&
39
+ typeof entry.kind === 'string' &&
40
+ typeof entry.relativePath === 'string' &&
41
+ typeof entry.source === 'string' &&
42
+ typeof entry.createdAt === 'string' &&
43
+ typeof entry.updatedAt === 'string' &&
44
+ typeof entry.lastUsedAt === 'string' &&
45
+ typeof entry.contentHash === 'string'))
46
+ };
47
+ }
48
+ function isPathInside(root, target) {
49
+ const relativeTarget = relative(root, target);
50
+ return (relativeTarget === '' || (!relativeTarget.startsWith(`..${sep}`) && relativeTarget !== '..'));
51
+ }
52
+ function clampInteger(value, fallback, min, max) {
53
+ if (typeof value !== 'number' || !Number.isFinite(value))
54
+ return fallback;
55
+ return Math.max(min, Math.min(max, Math.floor(value)));
56
+ }
57
+ function truncateText(value, limit = MAX_TOOL_CHARS) {
58
+ if (value.length <= limit)
59
+ return value;
60
+ return `${value.slice(0, limit)}\n\n[输出已截断;请使用 cache_read 分页读取完整缓存。]`;
61
+ }
62
+ function decodeHtml(value) {
63
+ return value
64
+ .replace(/&#x([0-9a-f]+);/gi, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)))
65
+ .replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number.parseInt(code, 10)))
66
+ .replace(/&nbsp;/gi, ' ')
67
+ .replace(/&amp;/gi, '&')
68
+ .replace(/&lt;/gi, '<')
69
+ .replace(/&gt;/gi, '>')
70
+ .replace(/&quot;/gi, '"')
71
+ .replace(/&#39;/gi, "'");
72
+ }
73
+ function htmlTitle(html) {
74
+ const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
75
+ return match ? decodeHtml(match[1].replace(/<[^>]+>/g, '')).trim() : '';
76
+ }
77
+ /**
78
+ * 保留正文中的块级断行,避免把网页压成无法引用的一整段文本。
79
+ */
80
+ export function extractReadableText(html) {
81
+ const withoutHiddenContent = html
82
+ .replace(/<(script|style|noscript|template|svg)[^>]*>[\s\S]*?<\/\1>/gi, ' ')
83
+ .replace(/<(br|hr)\s*\/?\s*>/gi, '\n')
84
+ .replace(/<\/(p|div|section|article|main|header|footer|li|h[1-6]|tr|blockquote)>/gi, '\n')
85
+ .replace(/<[^>]+>/g, ' ');
86
+ return decodeHtml(withoutHiddenContent)
87
+ .replace(/[ \t]+/g, ' ')
88
+ .replace(/ *\n */g, '\n')
89
+ .replace(/\n{3,}/g, '\n\n')
90
+ .trim();
91
+ }
92
+ const operationTails = new Map();
93
+ /**
94
+ * 同一 Pi 进程中,研究缓存与同一路径的 Git 工作树都可能被并行工具调用。
95
+ * 对同一资源完整串行化,而不是只锁最后一次写入,避免索引和证据文件分叉。
96
+ */
97
+ async function withExclusiveOperation(resource, work) {
98
+ const previous = operationTails.get(resource) ?? Promise.resolve();
99
+ let release;
100
+ const turn = new Promise((resolveTurn) => {
101
+ release = resolveTurn;
102
+ });
103
+ const tail = previous.catch(() => undefined).then(() => turn);
104
+ operationTails.set(resource, tail);
105
+ await previous.catch(() => undefined);
106
+ try {
107
+ return await work();
108
+ }
109
+ finally {
110
+ release?.();
111
+ if (operationTails.get(resource) === tail)
112
+ operationTails.delete(resource);
113
+ }
114
+ }
115
+ export class ResearchCache {
116
+ root;
117
+ indexPath;
118
+ now;
119
+ constructor(options) {
120
+ this.root = join(options.cwd, 'temp', 'pi', 'pi-desk-subagent', 'research-cache');
121
+ this.indexPath = join(this.root, 'index.json');
122
+ this.now = options.now ?? Date.now;
123
+ }
124
+ async get(key) {
125
+ return this.withLock(async () => {
126
+ const index = await this.readIndex();
127
+ await this.cleanupIndexUnlocked(index);
128
+ const entry = index.entries.find((candidate) => candidate.key === key);
129
+ let cached;
130
+ if (entry) {
131
+ const path = this.resolveEntryPath(entry.relativePath);
132
+ try {
133
+ const record = JSON.parse(await readFile(path, 'utf8'));
134
+ entry.lastUsedAt = nowIso(this.now);
135
+ cached = { entry, record };
136
+ }
137
+ catch {
138
+ index.entries = index.entries.filter((candidate) => candidate.key !== key);
139
+ }
140
+ }
141
+ await this.writeIndexUnlocked(index);
142
+ return cached;
143
+ });
144
+ }
145
+ async put(params) {
146
+ return this.withLock(async () => {
147
+ const index = await this.readIndex();
148
+ await this.cleanupIndexUnlocked(index);
149
+ const createdAt = nowIso(this.now);
150
+ const serializedData = JSON.stringify(params.data);
151
+ const record = {
152
+ version: CACHE_VERSION,
153
+ key: params.key,
154
+ kind: params.kind,
155
+ source: params.source,
156
+ createdAt,
157
+ contentHash: hashText(serializedData),
158
+ data: params.data
159
+ };
160
+ const relativePath = join('records', `${hashText(params.key).slice(7)}.json`);
161
+ await this.writeJson(this.resolveEntryPath(relativePath), record);
162
+ const entry = {
163
+ key: params.key,
164
+ kind: params.kind,
165
+ relativePath,
166
+ source: params.source,
167
+ createdAt,
168
+ updatedAt: createdAt,
169
+ lastUsedAt: createdAt,
170
+ contentHash: record.contentHash,
171
+ repositoryPath: params.repositoryPath
172
+ };
173
+ index.entries = [...index.entries.filter((candidate) => candidate.key !== entry.key), entry];
174
+ await this.writeIndexUnlocked(index);
175
+ return { entry, record };
176
+ });
177
+ }
178
+ async cleanup() {
179
+ await this.withLock(() => this.cleanupUnlocked());
180
+ }
181
+ async withLock(work) {
182
+ return withExclusiveOperation(this.indexPath, work);
183
+ }
184
+ async cleanupUnlocked() {
185
+ const index = await this.readIndex();
186
+ await this.cleanupIndexUnlocked(index);
187
+ await this.writeIndexUnlocked(index);
188
+ }
189
+ async cleanupIndexUnlocked(index) {
190
+ const threshold = this.now() - CACHE_RETENTION_MS;
191
+ const kept = [];
192
+ for (const entry of index.entries) {
193
+ const lastUsedAt = Date.parse(entry.lastUsedAt);
194
+ if (Number.isFinite(lastUsedAt) && lastUsedAt >= threshold) {
195
+ kept.push(entry);
196
+ continue;
197
+ }
198
+ await rm(this.resolveEntryPath(entry.relativePath), { force: true });
199
+ if (entry.repositoryPath) {
200
+ await rm(this.resolveEntryPath(entry.repositoryPath), {
201
+ recursive: true,
202
+ force: true
203
+ });
204
+ }
205
+ }
206
+ index.entries = kept;
207
+ }
208
+ async readIndex() {
209
+ try {
210
+ return asIndex(JSON.parse(await readFile(this.indexPath, 'utf8')), this.now);
211
+ }
212
+ catch {
213
+ return emptyIndex(this.now);
214
+ }
215
+ }
216
+ async writeIndexUnlocked(index) {
217
+ index.updatedAt = nowIso(this.now);
218
+ await this.writeJson(this.indexPath, index);
219
+ }
220
+ resolveEntryPath(relativePath) {
221
+ const path = resolve(this.root, relativePath);
222
+ if (!isPathInside(resolve(this.root), path)) {
223
+ throw new Error('研究缓存路径越界');
224
+ }
225
+ return path;
226
+ }
227
+ async writeJson(path, value) {
228
+ await mkdir(dirname(path), { recursive: true });
229
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
230
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
231
+ await rename(tempPath, path);
232
+ }
233
+ }
234
+ function cacheReadText(record) {
235
+ if (record.kind === 'page') {
236
+ const page = record.data;
237
+ return page.content;
238
+ }
239
+ return JSON.stringify(record.data, null, 2);
240
+ }
241
+ function formatCacheMetadata(cached, status) {
242
+ return [
243
+ `缓存:${status}`,
244
+ `缓存键:${cached.entry.key}`,
245
+ `来源:${cached.entry.source}`,
246
+ `获取时间:${cached.entry.updatedAt}`,
247
+ `内容 Hash:${cached.entry.contentHash}`
248
+ ].join('\n');
249
+ }
250
+ function filterDomains(results, domains) {
251
+ if (!domains || domains.length === 0)
252
+ return results;
253
+ const allows = domains
254
+ .filter((domain) => !domain.startsWith('-'))
255
+ .map((domain) => domain.toLowerCase());
256
+ const denies = domains
257
+ .filter((domain) => domain.startsWith('-'))
258
+ .map((domain) => domain.slice(1).toLowerCase());
259
+ return results.filter((result) => {
260
+ let hostname;
261
+ try {
262
+ hostname = new URL(result.url).hostname.toLowerCase();
263
+ }
264
+ catch {
265
+ return false;
266
+ }
267
+ const matches = (domain) => hostname === domain || hostname.endsWith(`.${domain}`);
268
+ if (denies.some(matches))
269
+ return false;
270
+ return allows.length === 0 || allows.some(matches);
271
+ });
272
+ }
273
+ async function searchWeb(params) {
274
+ const response = await fetch(ANYSEARCH_API_URL, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ query: params.query, max_results: params.maxResults }),
278
+ signal: params.signal
279
+ ? AbortSignal.any([params.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
280
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS)
281
+ });
282
+ if (!response.ok) {
283
+ throw new Error(`AnySearch 请求失败:HTTP ${response.status}`);
284
+ }
285
+ const body = (await response.json());
286
+ if (body.code !== 0 || !Array.isArray(body.data?.results)) {
287
+ throw new Error('AnySearch 返回了无效结果');
288
+ }
289
+ return body.data.results
290
+ .filter((result) => Boolean(result &&
291
+ typeof result === 'object' &&
292
+ typeof result.title === 'string' &&
293
+ typeof result.url === 'string' &&
294
+ typeof result.snippet === 'string' &&
295
+ typeof result.content === 'string'))
296
+ .slice(0, params.maxResults);
297
+ }
298
+ async function fetchPage(params) {
299
+ const parsed = new URL(params.url);
300
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
301
+ throw new Error('只能抓取 HTTP(S) 网页');
302
+ }
303
+ const response = await fetch(parsed, {
304
+ headers: { 'User-Agent': 'pi-task-subagent-research/1.0' },
305
+ signal: params.signal
306
+ ? AbortSignal.any([params.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
307
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS)
308
+ });
309
+ if (!response.ok)
310
+ throw new Error(`网页抓取失败:HTTP ${response.status}`);
311
+ const body = await response.arrayBuffer();
312
+ if (body.byteLength > MAX_FETCH_BYTES) {
313
+ throw new Error(`网页正文超过 ${MAX_FETCH_BYTES / 1024 / 1024}MB 限制`);
314
+ }
315
+ const rawText = new TextDecoder().decode(body);
316
+ const contentType = response.headers.get('content-type');
317
+ const content = contentType?.includes('html') ? extractReadableText(rawText) : rawText.trim();
318
+ if (!content)
319
+ throw new Error('网页没有可读取的文本内容');
320
+ return {
321
+ requestedUrl: params.url,
322
+ finalUrl: response.url,
323
+ title: contentType?.includes('html') ? htmlTitle(rawText) : basename(parsed.pathname),
324
+ content,
325
+ contentType
326
+ };
327
+ }
328
+ async function runGh(pi, args, cwd) {
329
+ const result = await pi.exec('gh', args, { cwd, timeout: FETCH_TIMEOUT_MS });
330
+ if (result.code !== 0) {
331
+ throw new Error(`GitHub 查询失败:${result.stderr.trim() || result.stdout.trim()}`);
332
+ }
333
+ return result.stdout;
334
+ }
335
+ async function runGit(pi, args, cwd) {
336
+ const result = await pi.exec('git', args, { cwd, timeout: 120_000 });
337
+ if (result.code !== 0) {
338
+ throw new Error(`Git 操作失败:${result.stderr.trim() || result.stdout.trim()}`);
339
+ }
340
+ return result.stdout;
341
+ }
342
+ function repositoryRelativePath(repository, ref) {
343
+ if (!REPOSITORY_PATTERN.test(repository)) {
344
+ throw new Error('repository 必须使用 owner/repo 格式');
345
+ }
346
+ const [owner, name] = repository.split('/');
347
+ // Ref 可包含 / 等路径字符,使用稳定摘要同时保证不同引用拥有独立工作树。
348
+ const refId = hashText(ref ?? 'default').slice(7, 23);
349
+ return join('repos', 'github.com', owner, name, `ref-${refId}`);
350
+ }
351
+ async function repositoryExists(pi, path, cwd) {
352
+ const result = await pi.exec('git', ['-C', path, 'rev-parse', '--is-inside-work-tree'], {
353
+ cwd,
354
+ timeout: 10_000
355
+ });
356
+ return result.code === 0 && result.stdout.trim() === 'true';
357
+ }
358
+ export async function ensureCachedRepository(params) {
359
+ const requestedRef = params.ref?.trim() || null;
360
+ const relativeRepositoryPath = repositoryRelativePath(params.repository, requestedRef);
361
+ const localPath = join(params.cache.root, relativeRepositoryPath);
362
+ const key = cacheKey('repository', {
363
+ repository: params.repository,
364
+ ref: requestedRef
365
+ });
366
+ return withExclusiveOperation(localPath, async () => {
367
+ let cached = params.refresh ? undefined : await params.cache.get(key);
368
+ const exists = await repositoryExists(params.pi, localPath, params.cwd);
369
+ if (cached && exists) {
370
+ const expected = cached.record.data;
371
+ const currentCommit = (await runGit(params.pi, ['-C', localPath, 'rev-parse', 'HEAD'], params.cwd)).trim();
372
+ if (expected.commit !== currentCommit)
373
+ cached = undefined;
374
+ }
375
+ else if (!exists) {
376
+ cached = undefined;
377
+ }
378
+ const cacheStatus = cached ? 'hit' : 'miss';
379
+ if (!cached) {
380
+ params.onProgress?.('正在准备本地 GitHub 仓库…');
381
+ if (!exists) {
382
+ await mkdir(dirname(localPath), { recursive: true });
383
+ await runGh(params.pi, ['repo', 'clone', params.repository, localPath, '--', '--depth=1'], params.cwd);
384
+ }
385
+ // 首次 clone 后也必须切到请求的 ref;已有目录在缓存未命中时同时刷新默认分支。
386
+ if (requestedRef || exists) {
387
+ await runGit(params.pi, ['-C', localPath, 'fetch', '--depth=1', 'origin', requestedRef ?? 'HEAD'], params.cwd);
388
+ await runGit(params.pi, ['-C', localPath, 'checkout', '--detach', 'FETCH_HEAD'], params.cwd);
389
+ }
390
+ const commit = (await runGit(params.pi, ['-C', localPath, 'rev-parse', 'HEAD'], params.cwd)).trim();
391
+ cached = await params.cache.put({
392
+ key,
393
+ kind: 'repository',
394
+ source: `GitHub repository: ${params.repository}`,
395
+ data: {
396
+ repository: params.repository,
397
+ ref: requestedRef,
398
+ localPath,
399
+ commit
400
+ },
401
+ repositoryPath: relativeRepositoryPath
402
+ });
403
+ }
404
+ return { cached, cacheStatus };
405
+ });
406
+ }
407
+ function createResearchToolExtension() {
408
+ return (pi) => {
409
+ pi.registerTool({
410
+ name: 'external_research',
411
+ label: 'External Research',
412
+ description: '在 research 子代理内检索网页、GitHub 和项目级持久缓存。所有操作优先复用缓存;仅在需要最新资料时传 refresh=true。',
413
+ promptSnippet: '检索外部网页、GitHub 项目和持久证据缓存;二次验证优先读取已有 cacheKey。',
414
+ promptGuidelines: [
415
+ 'external_research:先搜索并读取缓存,再作出外部事实结论;任务要求最新信息时才传 refresh=true。',
416
+ 'external_research:GitHub 项目确认相关后使用 github_clone,本地路径中的文件必须用 read、grep、find 或 ls 继续取证。',
417
+ 'external_research:二次验证使用 cache_read 回读原始证据,并在最终报告中注明缓存键、时间、Hash 与网页偏移或 GitHub 行号。'
418
+ ],
419
+ parameters: Type.Object({
420
+ action: StringEnum(['web_search', 'fetch_url', 'github_search', 'github_clone', 'cache_read'], { description: '执行的研究操作' }),
421
+ query: Type.Optional(Type.String({ description: '网页或 GitHub 搜索查询' })),
422
+ url: Type.Optional(Type.String({ description: '要抓取的单个网页 URL' })),
423
+ repository: Type.Optional(Type.String({ description: 'GitHub 仓库,格式 owner/repo' })),
424
+ ref: Type.Optional(Type.String({ description: '要 checkout 的 Git ref;省略则使用缓存或默认分支' })),
425
+ githubKind: Type.Optional(StringEnum(['repositories', 'code'], {
426
+ description: 'GitHub 搜索类型;默认 repositories'
427
+ })),
428
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: '最大结果数,默认 5' })),
429
+ domainFilter: Type.Optional(Type.Array(Type.String(), {
430
+ description: '网页结果域名过滤;-example.com 表示排除'
431
+ })),
432
+ cacheKey: Type.Optional(Type.String({ description: 'cache_read 要读取的缓存键' })),
433
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: 'cache_read 字符偏移' })),
434
+ limit: Type.Optional(Type.Integer({
435
+ minimum: 1,
436
+ maximum: MAX_CACHE_READ_CHARS,
437
+ description: 'cache_read 最大字符数'
438
+ })),
439
+ refresh: Type.Optional(Type.Boolean({ description: '忽略已有缓存并重新获取当前资料' }))
440
+ }),
441
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
442
+ const cache = new ResearchCache({ cwd: ctx.cwd });
443
+ const refresh = params.refresh === true;
444
+ const maxResults = clampInteger(params.maxResults, 5, 1, 20);
445
+ onUpdate?.({ content: [{ type: 'text', text: '正在检查研究缓存…' }], details: {} });
446
+ if (params.action === 'cache_read') {
447
+ const key = params.cacheKey?.trim();
448
+ if (!key)
449
+ throw new Error('cache_read 需要 cacheKey');
450
+ const cached = await cache.get(key);
451
+ if (!cached)
452
+ throw new Error(`未找到研究缓存:${key}`);
453
+ const offset = clampInteger(params.offset, 0, 0, Number.MAX_SAFE_INTEGER);
454
+ const limit = clampInteger(params.limit, MAX_CACHE_READ_CHARS, 1, MAX_CACHE_READ_CHARS);
455
+ const content = cacheReadText(cached.record);
456
+ const slice = content.slice(offset, offset + limit);
457
+ return {
458
+ content: [
459
+ {
460
+ type: 'text',
461
+ text: `${formatCacheMetadata(cached, 'hit')}\n字符范围:${offset}-${offset + slice.length}\n\n${slice}`
462
+ }
463
+ ],
464
+ details: {
465
+ cacheKey: key,
466
+ cacheStatus: 'hit',
467
+ offset,
468
+ limit,
469
+ contentHash: cached.entry.contentHash
470
+ }
471
+ };
472
+ }
473
+ if (params.action === 'web_search') {
474
+ const query = params.query?.trim();
475
+ if (!query)
476
+ throw new Error('web_search 需要 query');
477
+ const key = cacheKey('web-search', {
478
+ query,
479
+ maxResults,
480
+ domainFilter: params.domainFilter ?? []
481
+ });
482
+ let cached = refresh ? undefined : await cache.get(key);
483
+ const cacheStatus = cached ? 'hit' : 'miss';
484
+ if (!cached) {
485
+ onUpdate?.({ content: [{ type: 'text', text: '正在搜索网页…' }], details: {} });
486
+ const results = filterDomains(await searchWeb({ query, maxResults, signal }), params.domainFilter);
487
+ cached = await cache.put({
488
+ key,
489
+ kind: 'web-search',
490
+ source: `AnySearch: ${query}`,
491
+ data: { query, maxResults, results }
492
+ });
493
+ }
494
+ const record = cached.record.data;
495
+ const resultText = record.results
496
+ .map((result, index) => `${index + 1}. ${result.title}\n${result.url}\n${result.snippet}`)
497
+ .join('\n\n');
498
+ return {
499
+ content: [
500
+ {
501
+ type: 'text',
502
+ text: `${formatCacheMetadata(cached, cacheStatus)}\n\n${truncateText(resultText || '未找到结果。')}`
503
+ }
504
+ ],
505
+ details: {
506
+ cacheKey: key,
507
+ cacheStatus,
508
+ resultCount: record.results.length,
509
+ contentHash: cached.entry.contentHash
510
+ }
511
+ };
512
+ }
513
+ if (params.action === 'fetch_url') {
514
+ const url = params.url?.trim();
515
+ if (!url)
516
+ throw new Error('fetch_url 需要 url');
517
+ const key = cacheKey('page', { url });
518
+ let cached = refresh ? undefined : await cache.get(key);
519
+ const cacheStatus = cached ? 'hit' : 'miss';
520
+ if (!cached) {
521
+ onUpdate?.({ content: [{ type: 'text', text: '正在抓取网页正文…' }], details: {} });
522
+ const page = await fetchPage({ url, signal });
523
+ cached = await cache.put({ key, kind: 'page', source: page.finalUrl, data: page });
524
+ }
525
+ const page = cached.record.data;
526
+ const preview = page.content.slice(0, 4_000);
527
+ return {
528
+ content: [
529
+ {
530
+ type: 'text',
531
+ text: `${formatCacheMetadata(cached, cacheStatus)}\n标题:${page.title || '(无标题)'}\n正文总字符:${page.content.length}\n\n${preview}${page.content.length > preview.length ? '\n\n[正文预览已截断;使用 cache_read 继续读取。]' : ''}`
532
+ }
533
+ ],
534
+ details: {
535
+ cacheKey: key,
536
+ cacheStatus,
537
+ url: page.finalUrl,
538
+ contentHash: cached.entry.contentHash,
539
+ contentLength: page.content.length
540
+ }
541
+ };
542
+ }
543
+ if (params.action === 'github_search') {
544
+ const query = params.query?.trim();
545
+ if (!query)
546
+ throw new Error('github_search 需要 query');
547
+ const kind = params.githubKind ?? 'repositories';
548
+ const key = cacheKey('github-search', { kind, query, maxResults });
549
+ let cached = refresh ? undefined : await cache.get(key);
550
+ const cacheStatus = cached ? 'hit' : 'miss';
551
+ if (!cached) {
552
+ onUpdate?.({ content: [{ type: 'text', text: '正在搜索 GitHub…' }], details: {} });
553
+ const fields = kind === 'repositories'
554
+ ? 'nameWithOwner,description,url,stargazersCount,updatedAt,defaultBranch'
555
+ : 'repository,path,url,textMatches';
556
+ const stdout = await runGh(pi, [
557
+ 'search',
558
+ kind === 'repositories' ? 'repos' : 'code',
559
+ query,
560
+ '--limit',
561
+ String(maxResults),
562
+ '--json',
563
+ fields
564
+ ], ctx.cwd);
565
+ let results;
566
+ try {
567
+ results = JSON.parse(stdout);
568
+ }
569
+ catch {
570
+ throw new Error('GitHub 搜索结果不是有效 JSON');
571
+ }
572
+ if (!Array.isArray(results))
573
+ throw new Error('GitHub 搜索结果格式无效');
574
+ cached = await cache.put({
575
+ key,
576
+ kind: 'github-search',
577
+ source: `GitHub ${kind}: ${query}`,
578
+ data: { kind, query, results }
579
+ });
580
+ }
581
+ const record = cached.record.data;
582
+ return {
583
+ content: [
584
+ {
585
+ type: 'text',
586
+ text: `${formatCacheMetadata(cached, cacheStatus)}\n\n${truncateText(JSON.stringify(record.results, null, 2))}`
587
+ }
588
+ ],
589
+ details: {
590
+ cacheKey: key,
591
+ cacheStatus,
592
+ resultCount: record.results.length,
593
+ contentHash: cached.entry.contentHash
594
+ }
595
+ };
596
+ }
597
+ const repository = params.repository?.trim();
598
+ if (!repository)
599
+ throw new Error('github_clone 需要 repository');
600
+ const { cached, cacheStatus } = await ensureCachedRepository({
601
+ pi,
602
+ cache,
603
+ cwd: ctx.cwd,
604
+ repository,
605
+ ref: params.ref,
606
+ refresh,
607
+ onProgress: (text) => onUpdate?.({ content: [{ type: 'text', text }], details: {} })
608
+ });
609
+ const record = cached.record.data;
610
+ const key = cached.entry.key;
611
+ return {
612
+ content: [
613
+ {
614
+ type: 'text',
615
+ text: `${formatCacheMetadata(cached, cacheStatus)}\n仓库:${record.repository}\nRef:${record.ref ?? '默认分支'}\nCommit:${record.commit}\n本地路径:${record.localPath}\n\n请使用 read、grep、find 或 ls 从该本地路径继续取证。`
616
+ }
617
+ ],
618
+ details: {
619
+ cacheKey: key,
620
+ cacheStatus,
621
+ repository: record.repository,
622
+ localPath: record.localPath,
623
+ commit: record.commit,
624
+ contentHash: cached.entry.contentHash
625
+ }
626
+ };
627
+ }
628
+ });
629
+ };
630
+ }
631
+ export function createExternalResearchExtension() {
632
+ return createResearchToolExtension();
633
+ }
634
+ export const __test__ = {
635
+ cacheKey,
636
+ hashText
637
+ };
@@ -0,0 +1,20 @@
1
+ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
2
+ type AgentSettingInput = {
3
+ name: string;
4
+ model: string;
5
+ thinking: ThinkingLevel | null;
6
+ disabled: boolean;
7
+ };
8
+ declare function availableModels(): Promise<Array<{
9
+ provider: string;
10
+ modelId: string;
11
+ name: string;
12
+ }>>;
13
+ export declare function getGlobalSubagentSettings(): Promise<{
14
+ agents: AgentSettingInput[];
15
+ models: Awaited<ReturnType<typeof availableModels>>;
16
+ }>;
17
+ export declare function saveGlobalSubagentSettings(input: unknown): Promise<{
18
+ agents: AgentSettingInput[];
19
+ }>;
20
+ export {};