mingdao-harness 0.2.8 → 0.3.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/src/memory.js CHANGED
@@ -8,6 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { mingdaoHome, ensureHome } from './config.js';
10
10
  import { beijingParts } from './pricing.js';
11
+ import { tokenize } from './session-index.js';
11
12
 
12
13
  export function memoryFile() {
13
14
  return path.join(mingdaoHome(), 'AGENTS.md');
@@ -216,11 +217,164 @@ export async function extractMemory(/** @type {any} */ provider, /** @type {any}
216
217
  }
217
218
  }
218
219
 
220
+ // —— 项目级自动记忆(v0.3.0 P0-3)——
221
+ // 与用户记忆(偏好/约定)区分:项目记忆沉淀「关键决定/事实/结构/教训」,随工作空间走,
222
+ // 换会话/换项目不串。存储:<工作空间>/.mingdao/memory.md(git 可忽略,属本机智能体的项目笔记)。
223
+ export function projectMemoryFile(/** @type {any} */ workingDir) {
224
+ return path.join(String(workingDir || ''), '.mingdao', 'memory.md');
225
+ }
226
+
227
+ export function loadProjectMemory(/** @type {any} */ workingDir) {
228
+ try {
229
+ return fs.readFileSync(projectMemoryFile(workingDir), 'utf8');
230
+ } catch {
231
+ return '';
232
+ }
233
+ }
234
+
235
+ // 项目记忆按条目读取(供语义检索)
236
+ export function loadProjectMemoryEntries(/** @type {any} */ workingDir) {
237
+ return loadProjectMemory(workingDir)
238
+ .split('\n')
239
+ .map((/** @type {any} */ l) => l.trim())
240
+ .filter(Boolean);
241
+ }
242
+
243
+ // 零依赖语义检索:与 query 分词(ASCII 词 + 中文 bigram)的 Jaccard 相似度取 TopN 相关条目。
244
+ // 用于「记忆/日志从最近 N 条 → 相关 N 条」(v0.3.1),换会话/换任务只注入相关记忆、省 token。
245
+ export function retrieveRelevant(/** @type {any[]} */ entries, /** @type {any} */ query, /** @type {number} */ topN = 5) {
246
+ const qTerms = new Set([...tokenize(String(query || '')).keys()]);
247
+ if (!qTerms.size) return entries.slice(0, topN);
248
+ const scored = entries.map((/** @type {any} */ e) => {
249
+ const text = typeof e === 'string' ? e : String(e.text || e.outcome || e.firstUser || e.content || '');
250
+ const terms = tokenize(text);
251
+ if (!terms.size) return { e, score: 0 };
252
+ let overlap = 0;
253
+ for (const t of terms.keys()) if (qTerms.has(t)) overlap += 1;
254
+ const union = new Set([...qTerms, ...terms.keys()]).size || 1;
255
+ return { e, score: overlap / union };
256
+ });
257
+ scored.sort((/** @type {any} */ a, /** @type {any} */ b) => b.score - a.score);
258
+ return scored.filter((/** @type {any} */ s) => s.score > 0).slice(0, topN).map((/** @type {any} */ s) => s.e);
259
+ }
260
+
261
+ export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
262
+ const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
263
+ if (!add.length || !workingDir) return 0;
264
+ try {
265
+ fs.mkdirSync(path.dirname(projectMemoryFile(workingDir)), { recursive: true, mode: 0o700 });
266
+ const bp = beijingParts(new Date());
267
+ const date = `${bp.year}-${String(bp.month).padStart(2, '0')}-${String(bp.day).padStart(2, '0')}`;
268
+ fs.appendFileSync(
269
+ projectMemoryFile(workingDir),
270
+ add.map((/** @type {any} */ l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n'
271
+ );
272
+ return add.length;
273
+ } catch {
274
+ return 0;
275
+ }
276
+ }
277
+
278
+ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
279
+ const raw = loadProjectMemory(workingDir);
280
+ if (!raw.trim()) return 0;
281
+ const seen = new Set();
282
+ const kept = [];
283
+ let removed = 0;
284
+ for (const line of raw.split('\n')) {
285
+ const t = line.trim();
286
+ if (!t) continue;
287
+ const norm = t.replace(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*/, '').trim().toLowerCase();
288
+ if (seen.has(norm)) { removed += 1; continue; }
289
+ seen.add(norm);
290
+ kept.push(t);
291
+ }
292
+ if (removed > 0) {
293
+ try { fs.writeFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
294
+ }
295
+ return removed;
296
+ }
297
+
298
+ // 项目记忆提取:关键决定及其原因、文件/目录结构、依赖与约定、踩过的坑与教训、未完成事项
299
+ export async function extractProjectMemory(/** @type {any} */ provider, /** @type {any} */ model, /** @type {any} */ messages, /** @type {any} */ existing) {
300
+ try {
301
+ const convo = messages
302
+ .filter((/** @type {any} */ m) => m.role === 'user' || m.role === 'assistant')
303
+ .slice(-20)
304
+ .map((/** @type {any} */ m) => `${m.role === 'user' ? '用户' : 'MingDao'}:${String(m.content || '').slice(0, 500)}`)
305
+ .join('\n');
306
+ const msgs = [
307
+ {
308
+ role: 'system',
309
+ content:
310
+ '你是 MingDao Harness 的项目记忆提取器。从对话中提取值得长期记住的「项目级事实」:关键决定及其原因、文件/目录结构、依赖与约定、踩过的坑与教训、未完成事项。每条 ≤50 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出)。只输出 JSON:{"items": ["条目1", "条目2"]};没有新增时输出 {"items": []}。\n已有记忆:\n' +
311
+ (existing || '(空)'),
312
+ },
313
+ { role: 'user', content: convo.slice(0, 8000) },
314
+ ];
315
+ try {
316
+ const res = await provider.chat({ model, messages: msgs, tools: [], temperature: 0.2, maxTokens: 250, reasoningEffort: 'low', responseFormat: { type: 'json_object' } });
317
+ const j = JSON.parse(String(res.text || '').trim());
318
+ const items = Array.isArray(j?.items) ? j.items : [];
319
+ const clean = items
320
+ .map((/** @type {any} */ l) => String(l).trim())
321
+ .filter(Boolean)
322
+ .map((/** @type {any} */ l) => (/^[·•]/.test(l) ? '- ' + l.replace(/^[·•]\s*/, '') : l.startsWith('-') ? l : '- ' + l))
323
+ .slice(0, 10);
324
+ if (clean.length || items.length === 0) return clean;
325
+ } catch {}
326
+ const res = await provider.chat({
327
+ model,
328
+ messages: [
329
+ {
330
+ role: 'system',
331
+ content:
332
+ '你是 MingDao Harness 的项目记忆提取器。从对话中提取值得长期记住的项目级事实(关键决定及原因、结构、依赖约定、教训、未完成事项)。每条一行,以 - 开头,≤50 字,只输出新条目(与「已有记忆」重复或未提及的不要输出);没有新增就只输出「无新增」。\n已有记忆:\n' +
333
+ (existing || '(空)'),
334
+ },
335
+ { role: 'user', content: convo.slice(0, 8000) },
336
+ ],
337
+ tools: [],
338
+ temperature: 0.2,
339
+ maxTokens: 300,
340
+ });
341
+ const text = String(res.text || '').trim();
342
+ if (!text || text.includes('无新增')) return [];
343
+ return text
344
+ .split('\n')
345
+ .map((/** @type {any} */ l) => l.trim())
346
+ .filter((/** @type {any} */ l) => l.startsWith('-') || /^[·•]/.test(l))
347
+ .map((/** @type {any} */ l) => l.replace(/^[·•]\s*/, '- '))
348
+ .slice(0, 10);
349
+ } catch {
350
+ return [];
351
+ }
352
+ }
353
+
354
+ // 项目记忆提取 + 追加(CLI finalizeSession 与 WebUI 收尾共用;配置 autoProjectMemory 默认开)
355
+ export async function extractAndAppendProjectMemory(/** @type {any} */ { cfg, provider, model, messages, workingDir }) {
356
+ if (!workingDir || cfg?.autoProjectMemory === false) return;
357
+ try {
358
+ const existing = loadProjectMemory(workingDir);
359
+ const { helperProvider } = await import('./providers/index.js');
360
+ const memProvider = await helperProvider(cfg, model, provider);
361
+ const lines = await extractProjectMemory(memProvider, model, messages, existing);
362
+ if (lines.length) appendProjectMemory(workingDir, lines);
363
+ } catch {}
364
+ }
365
+
219
366
  // 会话收尾:写日志 + 自动记忆(turns 太少的一次性会话只记日志不提取)
220
367
  export async function finalizeSession(/** @type {any} */ { cfg, provider, model, home, workingDir, messages, turns, lastText }) {
221
368
  const firstUser = messages.find((/** @type {any} */ m) => m.role === 'user')?.content || '';
222
369
  try {
223
- const wsName = null; // 由调用方通过 currentWorkspace 提供会更好,这里保持轻量
370
+ // v0.3.1 P1-3 修复:journal 归属信息——优先工作空间名,回退目录 basename(不再恒 null)
371
+ let wsName = null;
372
+ try {
373
+ const { workspaceForDir } = await import('./workspace.js');
374
+ wsName = workspaceForDir(workingDir)?.name || (workingDir ? path.basename(workingDir) : null);
375
+ } catch {
376
+ wsName = workingDir ? path.basename(workingDir) : null;
377
+ }
224
378
  appendJournal(home, {
225
379
  at: Date.now(),
226
380
  workspace: wsName,
@@ -239,4 +393,10 @@ export async function finalizeSession(/** @type {any} */ { cfg, provider, model,
239
393
  if (lines.length) appendMemory(lines);
240
394
  } catch {}
241
395
  }
396
+ // v0.3.0 P0-3:项目级自动记忆(有实际工具工作才提取,避免空话污染项目记忆;
397
+ // 单轮重型任务也会执行工具,故按「是否有 tool 消息」判定而非 turn 数)
398
+ const hadToolWork = messages.some((/** @type {any} */ m) => m.role === 'tool');
399
+ if (workingDir && (hadToolWork || turns >= 2)) {
400
+ await extractAndAppendProjectMemory({ cfg, provider, model, messages, workingDir });
401
+ }
242
402
  }
package/src/prompts.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { skillsRegistryBlock } from './skills.js';
7
7
  import { mingdaoHome, loadConfig } from './config.js';
8
- import { recentJournalBlock } from './memory.js';
8
+ import { recentJournalBlock, loadProjectMemory } from './memory.js';
9
9
 
10
10
  const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI 编程助手。你在用户的电脑上工作:通过工具读写文件、搜索代码、执行命令,帮助用户完成编程、调试与自动化任务。
11
11
 
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
30
30
  }
31
31
  }
32
32
 
33
- /** @param {{ workingDir: any, withJournal?: boolean, [key: string]: any }} opts */
34
- export function buildSystemPrompt({ workingDir, withJournal = false }) {
33
+ /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
34
+ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
35
35
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
36
36
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
37
37
  // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
@@ -44,6 +44,12 @@ export function buildSystemPrompt({ workingDir, withJournal = false }) {
44
44
  const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
45
45
  if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
46
46
 
47
+ // 项目级自动记忆(v0.3.0 P0-3):<工作空间>/.mingdao/memory.md 自动沉淀的决定/事实/教训。
48
+ // 与 AGENTS.md(手动约定)区分;默认截 4K 保持系统提示前缀稳定,超长可 read 工具按需读。
49
+ // projectMemory 传入则用「会话内快照」(WebUI 保证同一会话内前缀稳定),否则读文件(CLI/REPL)。
50
+ const projMem = projectMemory !== undefined ? projectMemory : loadProjectMemory(workingDir);
51
+ if (projMem) prompt += `\n\n<project_memory>\n${projMem.length > 4000 ? projMem.slice(0, 4000) + '\n…[过长已截断]' : projMem}\n</project_memory>`;
52
+
47
53
  // 最近会话日志(跨会话连续性):默认不注入——新会话应当全新开始,避免串到
48
54
  // 上一次会话的上下文(曾出现「新会话却接着给上个会话的游戏升级」的混淆)。
49
55
  // 仅当用户显式开启时注入(WebUI 勾选「带上文」/ CLI --journal)。
package/src/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ // 统一脱敏(v0.3.1 P1-1 修复):审计/日志/会话/诊断/错误消息共用同一套规则,消除「各层自扫门前雪」。
2
+ // - redactSecrets:密钥脱敏(常见前缀 + Bearer + URL 内嵌凭据),保留路径便于排查 → 审计/日志用
3
+ // - redactSensitive:在 redactSecrets 之上再加私网 IP + 家目录路径掩码 → 诊断包/对外输出用
4
+ import os from 'node:os';
5
+
6
+ // 常见密钥前缀(GitHub/OpenAI/AWS/Slack/Google 等);sk- 保留前缀、其余整体掩码
7
+ const KEY_PREFIX = /(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,})/g;
8
+
9
+ export function redactSecrets(/** @type {any} */ text) {
10
+ let s = String(text ?? '');
11
+ s = s.replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***'); // 保留 sk- 前缀(兼容审计标记)
12
+ s = s.replace(KEY_PREFIX, '***');
13
+ s = s.replace(/(Authorization\s*:\s*Bearer\s+)[^\s"',}]+/gi, '$1***');
14
+ s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
15
+ s = s.replace(/([?&](?:key|token|secret|api_key|access_token)=)[^&\s"']+/gi, '$1***');
16
+ return s;
17
+ }
18
+
19
+ export function redactSensitive(/** @type {any} */ text) {
20
+ let s = redactSecrets(text);
21
+ s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b169\.254(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b|\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
22
+ s = s.replace(/(?:fe80:[\da-f:]+|::1|::)/gi, '[链路本地/回环IPv6]');
23
+ const home = os.homedir();
24
+ if (home && home.length > 1) s = s.split(home).join('~');
25
+ return s;
26
+ }
@@ -0,0 +1,77 @@
1
+ // 任务检查点(v0.3.0 P0-2):把「一轮 24 步跑满即止」升级为「可续跑」。
2
+ // 存储:<mingdao-home>/taskstates/<会话名>.json(独立侧车文件 + 原子写,不污染会话 JSONL 消息结构)。
3
+ // 状态:{ goal, progress, artifacts, status('cap'|'interrupted'), updatedAt }
4
+ // - cap:本轮跑满步数上限,未完成,可续跑;
5
+ // - interrupted:用户主动中断(Ctrl+C/停止),可续跑;
6
+ // - 任务正常完成时清除检查点(clearTaskState)。
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { mingdaoHome } from './config.js';
10
+ import { atomicWriteFileSync } from './atomic-write.js';
11
+
12
+ function taskStateDir() {
13
+ return path.join(mingdaoHome(), 'taskstates');
14
+ }
15
+
16
+ /** @param {any} sessionName */
17
+ export function taskStateFile(sessionName) {
18
+ return path.join(taskStateDir(), `${String(sessionName)}.json`);
19
+ }
20
+
21
+ /** @param {any} sessionName */
22
+ export function loadTaskState(sessionName) {
23
+ try {
24
+ const j = JSON.parse(fs.readFileSync(taskStateFile(sessionName), 'utf8'));
25
+ return j && typeof j === 'object' ? j : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** @param {any} sessionName @param {any} ts */
32
+ export function saveTaskState(sessionName, ts) {
33
+ try {
34
+ const dir = taskStateDir();
35
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ atomicWriteFileSync(taskStateFile(sessionName), JSON.stringify(ts) + '\n', { mode: 0o600 });
37
+ } catch {}
38
+ }
39
+
40
+ /** @param {any} sessionName */
41
+ export function clearTaskState(sessionName) {
42
+ try {
43
+ fs.unlinkSync(taskStateFile(sessionName));
44
+ } catch {}
45
+ }
46
+
47
+ // 合并落盘(v0.3.1 P2-3 修复):续跑再中断时保留原始 goal、合并 artifacts,只更新 progress/status。
48
+ // 避免「第二次续跑」时把 goal 覆盖成「继续」、把已交付文件清单清零。
49
+ /** @param {any} sessionName @param {any} ts */
50
+ export function saveTaskStateMerge(sessionName, ts) {
51
+ const prev = loadTaskState(sessionName);
52
+ const prevUnfinished = prev && (prev.status === 'cap' || prev.status === 'interrupted');
53
+ const merged = prevUnfinished
54
+ ? {
55
+ goal: prev.goal || ts.goal,
56
+ artifacts: [...new Set([...(Array.isArray(prev.artifacts) ? prev.artifacts : []), ...(Array.isArray(ts.artifacts) ? ts.artifacts : [])])],
57
+ progress: ts.progress || prev.progress,
58
+ status: ts.status,
59
+ updatedAt: ts.updatedAt,
60
+ }
61
+ : ts;
62
+ saveTaskState(sessionName, merged);
63
+ }
64
+
65
+ // 续跑提示:注入到消息历史,让模型先核对现状(已完成文件不重做)、再做未完成部分。
66
+ /** @param {any} ts */
67
+ export function resumePrompt(ts) {
68
+ const art = (Array.isArray(ts?.artifacts) && ts.artifacts.length ? ts.artifacts : []).join('、') || '(无)';
69
+ const progress = String(ts?.progress || '').slice(0, 2000) || '(无总结)';
70
+ const goal = String(ts?.goal || '').slice(0, 500) || '(未知)';
71
+ return (
72
+ `(系统提示)上一个任务「${goal}」因步数上限中断,尚未完成,现在续跑。\n` +
73
+ `已完成进度摘要:\n${progress}\n` +
74
+ `已交付文件(勿重复重做,先核对现状):${art}\n` +
75
+ `请从断点继续:先确认已完成部分的现状,再完成剩余工作。`
76
+ );
77
+ }
@@ -0,0 +1,56 @@
1
+ // HTTP 只读抓取工具(v0.3.1):GET 任意 http(s) URL,返回文本(512KB 上限,正文截 20K)。
2
+ // SSRF 防护:字面量私网/回环拒绝 + DNS 解析复检(防域名重绑定),口径与 server.js validateRemoteUrl 一致。
3
+ import { lookup } from 'node:dns/promises';
4
+
5
+ function isPrivateHost(/** @type {string} */ hostname) {
6
+ let h = String(hostname || '').toLowerCase();
7
+ if (!h) return true;
8
+ h = h.replace(/^\[|\]$/g, '');
9
+ if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
10
+ if (h.includes(':')) {
11
+ if (/^::ffff:/.test(h)) return isPrivateHost(h.slice(7));
12
+ return /^fe[89ab]/.test(h) || /^f[c d]/.test(h) || h === '::' || h === '::1';
13
+ }
14
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
15
+ if (!m) return false;
16
+ const a = Number(m[1]);
17
+ const b = Number(m[2]);
18
+ return a === 10 || a === 127 || a === 0 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
19
+ }
20
+
21
+ export async function runFetch(/** @type {any} */ args, /** @type {any} */ _ctx) {
22
+ const raw = String(args.url ?? '').trim();
23
+ if (!raw) return { ok: false, error: '缺少 url 参数。' };
24
+ let u;
25
+ try {
26
+ u = new URL(raw);
27
+ } catch {
28
+ return { ok: false, error: 'url 必须是合法的 http(s) URL。' };
29
+ }
30
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, error: '仅支持 http/https 地址。' };
31
+ const host = String(u.hostname || '').toLowerCase();
32
+ let blocked = isPrivateHost(host);
33
+ if (!blocked && host && host !== 'localhost' && !/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
34
+ try {
35
+ const addrs = await lookup(host, { all: true, verbatim: true });
36
+ blocked = addrs.some((/** @type {any} */ a) => isPrivateHost(a.address));
37
+ } catch {
38
+ // DNS 解析失败:放行,连接阶段会报错
39
+ }
40
+ }
41
+ if (blocked) return { ok: false, error: `拒绝访问内网/本机地址(${host})——SSRF 防护。` };
42
+ const ac = new AbortController();
43
+ const timer = setTimeout(() => ac.abort(), 15000);
44
+ try {
45
+ const res = await fetch(u, { signal: ac.signal, redirect: 'follow' });
46
+ const buf = await res.arrayBuffer();
47
+ clearTimeout(timer);
48
+ if (buf.byteLength > 512 * 1024) return { ok: false, error: `响应超过 512KB 上限(实际 ${buf.byteLength} 字节)。` };
49
+ const text = new TextDecoder('utf-8').decode(buf);
50
+ const truncated = text.length > 20000;
51
+ return { ok: true, status: res.status, contentType: res.headers.get('content-type') || '', output: (truncated ? text.slice(0, 20000) + '\n…[正文过长已截断,共 ' + text.length + ' 字符]' : text) || '(空响应)' };
52
+ } catch (/** @type {any} */ err) {
53
+ clearTimeout(timer);
54
+ return { ok: false, error: '抓取失败:' + (err?.name === 'AbortError' ? '超时(15s)' : String(err?.message || err)) };
55
+ }
56
+ }
@@ -0,0 +1,31 @@
1
+ // git 只读工具(v0.3.1):只允许只读子命令(status/log/diff/show/blame/rev-parse/branch/tag/ls-files/shortlog),
2
+ // 经 execFile 无 shell 执行(防注入),运行于 ctx.workingDir,输出与退出码结构化返回。
3
+ import { execFile } from 'node:child_process';
4
+
5
+ const GIT_READONLY = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'branch', 'tag', 'ls-files', 'shortlog']);
6
+
7
+ export async function runGit(/** @type {any} */ args, /** @type {any} */ ctx) {
8
+ const command = String(args.command ?? '').trim();
9
+ if (!command) {
10
+ return { ok: false, error: `缺少 command 参数。只读子命令:${[...GIT_READONLY].join(' / ')}` };
11
+ }
12
+ const argv = command.split(/\s+/).filter(Boolean);
13
+ const sub = argv[0];
14
+ if (!GIT_READONLY.has(sub)) {
15
+ return { ok: false, error: `git ${sub} 不是只读子命令(仅支持 ${[...GIT_READONLY].join(' / ')})。写操作请用 bash 并注意授权。` };
16
+ }
17
+ // 追加默认防超大输出:log/diff 限量(除非模型显式给了 -n/--max-count)
18
+ const cwd = ctx.workingDir || process.cwd();
19
+ try {
20
+ const { stdout, stderr } = await execFile('git', argv, {
21
+ cwd,
22
+ timeout: 15000,
23
+ maxBuffer: 4 * 1024 * 1024,
24
+ });
25
+ const out = String(stdout || '') + String(stderr || '');
26
+ return { ok: true, exitCode: 0, output: out.trim() || '(无输出)' };
27
+ } catch (/** @type {any} */ err) {
28
+ const e = /** @type {any} */ (err);
29
+ return { ok: false, error: String(e.stderr || e.message || err).trim(), exitCode: e.code };
30
+ }
31
+ }
@@ -3,10 +3,12 @@
3
3
 
4
4
  import { read, write, edit, ls, glob, grep, undo } from './fs-tools.js';
5
5
  import { runBash } from './bash.js';
6
+ import { runGit } from './git.js';
7
+ import { runFetch } from './fetch.js';
6
8
  import { listSkills, loadSkill } from '../skills.js';
7
9
 
8
10
  // 只读工具集合的单一来源:permissions.js 引用此导出,新增只读工具时只需改这里
9
- export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill']);
11
+ export const READONLY_TOOLS = new Set(['read', 'glob', 'grep', 'ls', 'skill', 'git', 'fetch']);
10
12
 
11
13
  const READ_SCHEMA = {
12
14
  type: 'object',
@@ -116,6 +118,22 @@ const UNDO_SCHEMA = {
116
118
  },
117
119
  };
118
120
 
121
+ const GIT_SCHEMA = {
122
+ type: 'object',
123
+ properties: {
124
+ command: { type: 'string', description: '只读 git 子命令与参数,如 log --oneline -10、diff HEAD、status、show。' },
125
+ },
126
+ required: ['command'],
127
+ };
128
+
129
+ const FETCH_SCHEMA = {
130
+ type: 'object',
131
+ properties: {
132
+ url: { type: 'string', description: '公网 http(s) 地址。' },
133
+ },
134
+ required: ['url'],
135
+ };
136
+
119
137
  const TOOLS = [
120
138
  {
121
139
  type: 'function',
@@ -205,6 +223,22 @@ const TOOLS = [
205
223
  parameters: UNDO_SCHEMA,
206
224
  },
207
225
  },
226
+ {
227
+ type: 'function',
228
+ function: {
229
+ name: 'git',
230
+ description: '只读 git 查询(status/log/diff/show/blame 等)。写操作请用 bash。',
231
+ parameters: GIT_SCHEMA,
232
+ },
233
+ },
234
+ {
235
+ type: 'function',
236
+ function: {
237
+ name: 'fetch',
238
+ description: '抓取公网 URL 文本(≤512KB,SSRF 防护拒绝内网)。',
239
+ parameters: FETCH_SCHEMA,
240
+ },
241
+ },
208
242
  ];
209
243
 
210
244
  export function toolSchemas() {
@@ -322,6 +356,10 @@ export async function dispatch(/** @type {any} */ name, /** @type {any} */ args,
322
356
  return runTodo(args, ctx);
323
357
  case 'undo':
324
358
  return undo(args, ctx);
359
+ case 'git':
360
+ return runGit(args, ctx);
361
+ case 'fetch':
362
+ return runFetch(args, ctx);
325
363
  default:
326
364
  return { ok: false, error: `未知工具:${name}` };
327
365
  }