mingdao-harness 0.3.0 → 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/docs/PLAN-v0.3.0.md +1 -1
- package/package.json +1 -1
- package/src/agent.js +39 -9
- package/src/audit.js +3 -4
- package/src/cli.js +3 -3
- package/src/commands/diagnose.js +2 -12
- package/src/commands/repl.js +3 -3
- package/src/memory.js +35 -1
- package/src/redact.js +26 -0
- package/src/task-state.js +18 -0
- package/src/tools/fetch.js +56 -0
- package/src/tools/git.js +31 -0
- package/src/tools/index.js +39 -1
- package/src/web/app.js +56 -61
- package/src/web/index.html +61 -22
- package/src/web/server.js +8 -4
package/docs/PLAN-v0.3.0.md
CHANGED
|
@@ -70,7 +70,7 @@ v0.3.0 落地的三条主线共享同一个地基——**「记忆/上下文」*
|
|
|
70
70
|
|
|
71
71
|
## 三、v0.3.0 之外(顺延序列,防漂移备忘)
|
|
72
72
|
|
|
73
|
-
- **v0.3.1
|
|
73
|
+
- **v0.3.1「检索与武器」**:零依赖语义检索(项目记忆从"全量→相关条目"注入,`retrieveRelevant` 分词 Jaccard);`git` 只读分析(diff/log/status 等,无 shell 防注入)与 HTTP 只读抓取(SSRF 白名单兜底)工具;省钱仪表盘从设置移出、顶部费用徽标点击展开(KPI + 命中率环状仪表 + 14 天趋势面积图 + 模型/工具 Top 条形 + 最近缓存)。**完整基线+增量上下文(P0-4)顺延 v0.3.2**(风险高,语义检索 + 续跑进度摘要已覆盖主场景)。
|
|
74
74
|
- **v0.3.2「看得见的省钱 + 省心」**:省钱归因面板(单任务省多少/花在哪一步/缓存命中贡献,WebUI 内 SVG 折线已有基础);`diagnose` 的自动反馈模板;覆盖率阈值随版本上调。
|
|
75
75
|
- **v0.3.3「纵深能力」**:跨平台沙箱补位(Windows Job Object / macOS seatbelt,平台无关敏感命令兜底);WebUI「完成任务的舒服工作台」深化(过程可回放/结论可复现)。
|
|
76
76
|
- **明确不进任何近期版本**:全模型省钱平台、React/框架化重构、SQLite(Node ≥22.5 破坏 18/20)、SaaS 托管化。
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -10,13 +10,17 @@ import { makeTokenCounter } from './tokenizer.js';
|
|
|
10
10
|
import { createHooks } from './hooks.js';
|
|
11
11
|
import { createIO, style, C } from './ui.js';
|
|
12
12
|
import { subagentModel } from './routing.js';
|
|
13
|
-
import { writeAudit
|
|
13
|
+
import { writeAudit } from './audit.js';
|
|
14
|
+
import { redactSecrets } from './redact.js';
|
|
14
15
|
import { checkCostGuard, costGuardConfig, todayCost } from './cost-guard.js';
|
|
15
16
|
import { estimateCost } from './pricing.js';
|
|
16
17
|
import { resolveProviderConfig } from './providers/index.js';
|
|
17
18
|
|
|
18
19
|
const MAX_STEPS = 24;
|
|
19
|
-
|
|
20
|
+
// 子代理步数上限:审计/精读类只读子任务需要读多个文件 + 交叉引用,12 步易在「读不全」时被截断
|
|
21
|
+
// (v0.3.0 桌面版审计实测:多个只读子代理报「因达到步数上限停止读取」或「子任务无输出」)。
|
|
22
|
+
// 提到与主循环一致(24),只读子任务每步是 read/grep(输入便宜、无输出 token),成本增量可忽略。
|
|
23
|
+
const SUBAGENT_MAX_STEPS = 24;
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* 创建 Agent 循环(调用方只需传 provider/permission/io/modelName/workingDir,其余可选)
|
|
@@ -34,8 +38,8 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
34
38
|
// 会话级共享:调用方传入则复用(/model 切换、子代理均共享,undo 不丢失)
|
|
35
39
|
const undo = undoStore || { backups: new Map() };
|
|
36
40
|
const stepLimit = maxSteps || MAX_STEPS;
|
|
37
|
-
// 只读工具集合(子代理只读模式 +
|
|
38
|
-
const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill']);
|
|
41
|
+
// 只读工具集合(子代理只读模式 + 并行批次共用)。v0.3.1 起含 git/fetch(只读、审计常用)
|
|
42
|
+
const READONLY_TOOLS_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'git', 'fetch']);
|
|
39
43
|
// 精确 token 计数:DeepSeek 词表,其他模型回退启发式
|
|
40
44
|
const count = makeTokenCounter(modelName);
|
|
41
45
|
// MCP 工具集(每次取,服务器晚就绪也能在后续轮次出现)
|
|
@@ -45,7 +49,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
45
49
|
const usedToolNames = new Set();
|
|
46
50
|
// 省钱 B1(按需挂载):回合起始为「只读阶段」时只发只读工具(read/ls/glob/grep/skill/todo)
|
|
47
51
|
// + 已用过的工具;检测到写意图(用户消息或模型明说需要写/改/建)后注入全量工具。
|
|
48
|
-
const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo']);
|
|
52
|
+
const READONLY_TIER_SET = new Set(['read', 'ls', 'glob', 'grep', 'skill', 'todo', 'git', 'fetch']);
|
|
49
53
|
// 中英双语写意图(CodeArts 报告:纯中文正则让英文会话整回合只读死锁)
|
|
50
54
|
const WRITE_INTENT_RE = /写|建|创|改|修|删|装|加|添|增|补|换|移|部署|执行|运行|实现|重构|生成|迁移|安装|更新|升级|发布|调整|优化|修复|提交|推送|打包|编译|测试|implement|fix|create|modify|update|delete|deploy|build|make|generate|install|write|refactor|migrate|test|run|commit|push|remove|add|change|patch/i;
|
|
51
55
|
const hasWriteIntent = (/** @type {any} */ text) => WRITE_INTENT_RE.test(String(text || ''));
|
|
@@ -121,6 +125,10 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
121
125
|
async function runTurn(/** @type {any} */ messages) {
|
|
122
126
|
let steps = 0;
|
|
123
127
|
let finish = null;
|
|
128
|
+
// v0.3.1 自动续跑(长程执行):跑满 stepLimit 步后不再直接中断,而是注入进度摘要再续跑,
|
|
129
|
+
// 最多 maxRounds 轮(默认 3,可用 cfg.maxRounds 调);审计/重构等大任务不再「一步中断」。
|
|
130
|
+
const maxRounds = Math.max(1, Number(cfg.maxRounds) || 3);
|
|
131
|
+
let round = 0;
|
|
124
132
|
const usage = /** @type {{ prompt_tokens: number, completion_tokens: number, prompt_cache_hit_tokens?: number, prompt_cache_miss_tokens?: number }} */ ({ prompt_tokens: 0, completion_tokens: 0 });
|
|
125
133
|
const startedAt = Date.now();
|
|
126
134
|
// 回合性能指标(状态栏:LLM 时长 / 工具时长 / 首 token 延迟 / 步数)
|
|
@@ -130,6 +138,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
130
138
|
// 省钱 B3(费用二级分账):推理 token 估算(按增量累计)与逐工具调用/耗时累加
|
|
131
139
|
let reasoningTokens = 0;
|
|
132
140
|
const toolStats = /** @type {Map<string, {calls: number, ms: number}>} */ (new Map());
|
|
141
|
+
const deliverables = /** @type {string[]} */ ([]); // 本回合 write/edit 成功落盘的文件路径(去重)
|
|
133
142
|
const perf = () => ({
|
|
134
143
|
llmMs: llmMsTotal,
|
|
135
144
|
toolMs: toolMsTotal,
|
|
@@ -138,6 +147,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
138
147
|
reasoningTokens,
|
|
139
148
|
toolStats: [...toolStats.entries()].map(([tool, s]) => ({ tool, calls: s.calls, ms: s.ms })),
|
|
140
149
|
usedModel: activeModel, // 省钱 B4:本回合实际使用模型(降级后归属它)
|
|
150
|
+
deliverables: [...deliverables], // v0.3.1:CLI/REPL 续跑检查点复用(此前 artifacts 恒空)
|
|
141
151
|
});
|
|
142
152
|
let aborted = false;
|
|
143
153
|
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
@@ -174,7 +184,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
174
184
|
}
|
|
175
185
|
};
|
|
176
186
|
try {
|
|
177
|
-
|
|
187
|
+
for (round = 0; round < maxRounds; round++) {
|
|
188
|
+
steps = 0;
|
|
189
|
+
while (steps < stepLimit) {
|
|
178
190
|
steps += 1;
|
|
179
191
|
// 同回合只读工具去重(Hermes C4):相同 name+args 的只读调用只执行一次,结果复用回填
|
|
180
192
|
const turnToolCache = new Map();
|
|
@@ -484,6 +496,11 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
484
496
|
ts.ms += ms;
|
|
485
497
|
toolStats.set(prep.name, ts);
|
|
486
498
|
io.renderTool(prep.name, prep.args, result, ms);
|
|
499
|
+
// v0.3.1 P1-2 修复:write/edit 成功落盘的路径记入交付物(CLI/REPL 续跑检查点用)
|
|
500
|
+
if ((prep.name === 'write' || prep.name === 'edit') && prep.args?.path && result && result.ok !== false) {
|
|
501
|
+
const p = String(prep.args.path);
|
|
502
|
+
if (p && !deliverables.includes(p)) deliverables.push(p);
|
|
503
|
+
}
|
|
487
504
|
if (prep.name === 'todo' && result?.todos) io.renderTodo(result.todos);
|
|
488
505
|
hooks.post(prep.name, prep.args, typeof result === 'string' ? { output: result } : result).catch(() => {});
|
|
489
506
|
// 审计(P3-5):执行结果摘要(含退出码/超时/输出大小)
|
|
@@ -563,9 +580,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
563
580
|
}
|
|
564
581
|
}
|
|
565
582
|
}
|
|
566
|
-
// v0.2.8 步数预留收尾(对齐 DSH
|
|
567
|
-
//
|
|
568
|
-
if (steps === stepLimit - 1) {
|
|
583
|
+
// v0.2.8 步数预留收尾(对齐 DSH):仅「最后一轮」的末步追加收尾指令,
|
|
584
|
+
// 让模型在末轮输出总结;中间轮不注入(交给自动续跑继续干活,而非提前收尾中断)。
|
|
585
|
+
if (steps === stepLimit - 1 && round === maxRounds - 1) {
|
|
569
586
|
messages.push({
|
|
570
587
|
role: 'user',
|
|
571
588
|
content:
|
|
@@ -642,6 +659,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
642
659
|
};
|
|
643
660
|
}
|
|
644
661
|
}
|
|
662
|
+
// v0.3.1 自动续跑(长程执行):还有剩余轮次且未中断 → 注入进度摘要直接续跑,不落收尾总结
|
|
663
|
+
if (round < maxRounds - 1 && !aborted) {
|
|
664
|
+
const art = deliverables.length ? '已交付文件:' + deliverables.join('、') + '。' : '';
|
|
665
|
+
messages.push({
|
|
666
|
+
role: 'user',
|
|
667
|
+
content: `(系统提示)已连续执行 ${stepLimit} 步工具操作,任务尚未完成,请继续完成剩余工作。${art}先核对已完成部分(勿重复),再做未完成的部分。`,
|
|
668
|
+
});
|
|
669
|
+
io.print(style(`♻ 步数上限,自动续跑第 ${round + 2} 轮…`, C.dim));
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
645
672
|
io.endTurn();
|
|
646
673
|
// 步数上限:清掉未执行的 tool_calls,避免下一轮/恢复后 API 400
|
|
647
674
|
stripOrphanCalls();
|
|
@@ -679,6 +706,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
679
706
|
} catch {}
|
|
680
707
|
}
|
|
681
708
|
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
709
|
+
}
|
|
710
|
+
// 理论不可达(for 循环末轮必 return);给 tsc 一个兜底,保证 runTurn 恒有返回值
|
|
711
|
+
return { text: null, reasoning: '', usage, steps, finish, truncated: true, aborted: false, capHit: true, durationMs: Date.now() - startedAt, perf: perf() };
|
|
682
712
|
} finally {
|
|
683
713
|
currentAc = null;
|
|
684
714
|
offSigint();
|
package/src/audit.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
|
+
import { redactSecrets } from './redact.js';
|
|
10
11
|
|
|
11
12
|
const MAX_LINES = 20000;
|
|
12
13
|
const KEEP_LINES = 10000;
|
|
@@ -16,10 +17,8 @@ export function auditFile() {
|
|
|
16
17
|
return path.join(mingdaoHome(), 'audit.jsonl');
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
export
|
|
21
|
-
return String(text ?? '').replace(/(sk-[A-Za-z0-9_-]{6,})/g, 'sk-***');
|
|
22
|
-
}
|
|
20
|
+
// 轻量脱敏(统一单一来源 v0.3.1 P1-1):sk-/ghp_ 等常见前缀掩码,见 src/redact.js
|
|
21
|
+
export { redactSecrets };
|
|
23
22
|
|
|
24
23
|
export function writeAudit(/** @type {any} */ entry) {
|
|
25
24
|
try {
|
package/src/cli.js
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
formatScheduleRow,
|
|
35
35
|
} from './schedule.js';
|
|
36
36
|
import { createAgent } from './agent.js';
|
|
37
|
-
import {
|
|
37
|
+
import { saveTaskStateMerge, clearTaskState } from './task-state.js';
|
|
38
38
|
import { createPermission } from './permissions.js';
|
|
39
39
|
import { buildSystemPrompt } from './prompts.js';
|
|
40
40
|
import { listSkills, tamperedSkillNames } from './skills.js';
|
|
@@ -474,10 +474,10 @@ async function main() {
|
|
|
474
474
|
}
|
|
475
475
|
// v0.3.0 P0-2:单次提问跑满步数/中断落检查点(--continue 可续跑),正常完成清除
|
|
476
476
|
if (res.capHit || res.aborted) {
|
|
477
|
-
|
|
477
|
+
saveTaskStateMerge(path.basename(session.file), {
|
|
478
478
|
goal: question,
|
|
479
479
|
progress: res.text || '',
|
|
480
|
-
artifacts: [],
|
|
480
|
+
artifacts: res.perf?.deliverables || [],
|
|
481
481
|
status: res.capHit ? 'cap' : 'interrupted',
|
|
482
482
|
updatedAt: new Date().toISOString(),
|
|
483
483
|
});
|
package/src/commands/diagnose.js
CHANGED
|
@@ -7,22 +7,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { createIO, style, C } from '../ui.js';
|
|
8
8
|
import { mingdaoHome, ensureHome, loadConfig } from '../config.js';
|
|
9
9
|
import { credentialsPath } from '../credentials.js';
|
|
10
|
-
import { listAudit
|
|
10
|
+
import { listAudit } from '../audit.js';
|
|
11
|
+
import { redactSensitive } from '../redact.js';
|
|
11
12
|
import { projectMemoryFile, loadProjectMemory } from '../memory.js';
|
|
12
13
|
import { listWorkspaces } from '../workspace.js';
|
|
13
14
|
import { detectSandbox } from '../tools/bash.js';
|
|
14
15
|
|
|
15
|
-
// 更严格的脱敏:sk-/ghp_ token、key/token/secret/password=值、私网 IP、家目录路径
|
|
16
|
-
function redactSensitive(/** @type {any} */ text) {
|
|
17
|
-
let s = redactSecrets(text);
|
|
18
|
-
s = s.replace(/(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, 'ghp_***');
|
|
19
|
-
s = s.replace(/((?:api[_-]?key|token|secret|password|passwd|access_token)\s*[=:]\s*["']?)[^\s"',}]+/gi, '$1***');
|
|
20
|
-
s = s.replace(/\b(?:10|127)(?:\.\d{1,3}){3}\b|\b192\.168(?:\.\d{1,3}){2}\b|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b/g, '[私网IP]');
|
|
21
|
-
const home = os.homedir();
|
|
22
|
-
if (home && home.length > 1) s = s.split(home).join('~');
|
|
23
|
-
return s;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
16
|
function tailFile(/** @type {any} */ file, /** @type {number} */ lines = 60) {
|
|
27
17
|
try {
|
|
28
18
|
const raw = fs.readFileSync(file, 'utf8');
|
package/src/commands/repl.js
CHANGED
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
relativeTime,
|
|
55
55
|
searchSessions,
|
|
56
56
|
} from '../session.js';
|
|
57
|
-
import { loadTaskState,
|
|
57
|
+
import { loadTaskState, saveTaskStateMerge, clearTaskState, resumePrompt } from '../task-state.js';
|
|
58
58
|
|
|
59
59
|
const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
60
60
|
|
|
@@ -682,10 +682,10 @@ export async function runRepl(ctx) {
|
|
|
682
682
|
}
|
|
683
683
|
// v0.3.0 P0-2:跑满步数(capHit)或中断(aborted)落检查点,正常完成清除
|
|
684
684
|
if (res.capHit || res.aborted) {
|
|
685
|
-
|
|
685
|
+
saveTaskStateMerge(path.basename(session.file), {
|
|
686
686
|
goal: input,
|
|
687
687
|
progress: res.text || '',
|
|
688
|
-
artifacts: [],
|
|
688
|
+
artifacts: res.perf?.deliverables || [],
|
|
689
689
|
status: res.capHit ? 'cap' : 'interrupted',
|
|
690
690
|
updatedAt: new Date().toISOString(),
|
|
691
691
|
});
|
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');
|
|
@@ -231,6 +232,32 @@ export function loadProjectMemory(/** @type {any} */ workingDir) {
|
|
|
231
232
|
}
|
|
232
233
|
}
|
|
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
|
+
|
|
234
261
|
export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
|
|
235
262
|
const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
|
|
236
263
|
if (!add.length || !workingDir) return 0;
|
|
@@ -340,7 +367,14 @@ export async function extractAndAppendProjectMemory(/** @type {any} */ { cfg, pr
|
|
|
340
367
|
export async function finalizeSession(/** @type {any} */ { cfg, provider, model, home, workingDir, messages, turns, lastText }) {
|
|
341
368
|
const firstUser = messages.find((/** @type {any} */ m) => m.role === 'user')?.content || '';
|
|
342
369
|
try {
|
|
343
|
-
|
|
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
|
+
}
|
|
344
378
|
appendJournal(home, {
|
|
345
379
|
at: Date.now(),
|
|
346
380
|
workspace: wsName,
|
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
|
+
}
|
package/src/task-state.js
CHANGED
|
@@ -44,6 +44,24 @@ export function clearTaskState(sessionName) {
|
|
|
44
44
|
} catch {}
|
|
45
45
|
}
|
|
46
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
|
+
|
|
47
65
|
// 续跑提示:注入到消息历史,让模型先核对现状(已完成文件不重做)、再做未完成部分。
|
|
48
66
|
/** @param {any} ts */
|
|
49
67
|
export function resumePrompt(ts) {
|
|
@@ -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
|
+
}
|
package/src/tools/git.js
ADDED
|
@@ -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
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
}
|
package/src/web/app.js
CHANGED
|
@@ -483,6 +483,7 @@ function syncPanelLayout(){
|
|
|
483
483
|
document.body.classList.toggle('traj-open', open('trajPanel'));
|
|
484
484
|
document.body.classList.toggle('sub-open', open('subPanel'));
|
|
485
485
|
document.body.classList.toggle('tasks-open', open('tasksPanel'));
|
|
486
|
+
document.body.classList.toggle('dash-open', open('dashPanel'));
|
|
486
487
|
}
|
|
487
488
|
$('#tjClose').onclick=()=>{ $('#trajPanel').style.display='none'; $('#trajRailBtn').classList.remove('on'); syncPanelLayout(); };
|
|
488
489
|
$('#trajRailBtn').onclick=()=>{
|
|
@@ -568,17 +569,20 @@ async function updateTasksPanel(){
|
|
|
568
569
|
}
|
|
569
570
|
}
|
|
570
571
|
setInterval(updateTasksPanel, 2000);
|
|
571
|
-
// 费用徽标:今日费用 / 缓存命中率 / 护栏(15s
|
|
572
|
+
// 费用徽标:今日费用 / 缓存命中率 / 护栏(15s 刷新);点击展开省钱仪表盘
|
|
572
573
|
async function refreshCostBadge(){
|
|
573
574
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
574
575
|
const j=await r.json().catch(()=>null); if(!j) return;
|
|
575
576
|
const bd=j.breakdown||{}; const gd=j.guard||null;
|
|
576
|
-
let t='今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
577
|
+
let t='📊 今日 ≈¥'+(bd.today||0).toFixed(4);
|
|
577
578
|
if(bd.rate!=null) t+=' · 命中 '+(bd.rate*100).toFixed(0)+'%';
|
|
578
579
|
if(gd&&gd.limit>0&&gd.cost!=null) t+=' · 护栏 '+(gd.cost/gd.limit*100).toFixed(0)+'%';
|
|
579
580
|
$('#costBadge').textContent=t;
|
|
581
|
+
if($('#dashPanel').style.display==='flex') renderDashboard(j);
|
|
580
582
|
}
|
|
581
583
|
refreshCostBadge(); setInterval(refreshCostBadge, 15000);
|
|
584
|
+
$('#costBadge').onclick=()=>{ const p=$('#dashPanel'); const show=p.style.display==='none'; p.style.display=show?'flex':'none'; if(show){ $('#subPanel').style.display='none'; $('#trajPanel').style.display='none'; $('#tasksPanel').style.display='none'; $('#subRailBtn').classList.remove('on'); $('#trajRailBtn').classList.remove('on'); refreshCostBadge(); } syncPanelLayout(); };
|
|
585
|
+
$('#dashClose').onclick=()=>{ $('#dashPanel').style.display='none'; syncPanelLayout(); };
|
|
582
586
|
// 底部状态栏:轮次/步数/LLM 与工具时长/首 token 平均/吞吐/缓存命中/输入输出 tokens
|
|
583
587
|
async function refreshStatusBar(){
|
|
584
588
|
const r=await fetch('/api/cache-stats',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
@@ -699,7 +703,7 @@ $('#wsSel').addEventListener('change', async e=>{
|
|
|
699
703
|
refreshWsSel(); reloadModels();
|
|
700
704
|
}
|
|
701
705
|
});
|
|
702
|
-
$('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI();
|
|
706
|
+
$('#cfgBtn').addEventListener('click', ()=>{ refreshModelsCfg(); refreshSyncUI(); refreshSyncShares(); refreshSyncConflicts(); refreshSchList(); refreshWorkspaces(); loadMemoryUI(); refreshMcpPresets(); refreshSkillLib(''); });
|
|
703
707
|
$('#schWhen').onchange=e=>{ const v=e.target.value; $('#schAtRow').style.display=v==='at'?'':'none'; $('#schEveryRow').style.display=v==='every'?'':'none'; $('#schAfterRow').style.display=v==='after'?'':'none'; $('#schChainRow').style.display=v==='chain'?'':'none'; };
|
|
704
708
|
async function refreshSchList(){
|
|
705
709
|
const r=await fetch('/api/schedule',{cache:'no-store'}).catch(()=>null); if(!r) return;
|
|
@@ -767,70 +771,61 @@ $('#memDedupe').onclick=async ()=>{
|
|
|
767
771
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
768
772
|
if(j.ok){ memFlash('✓ 去重完成,移除 '+j.removed+' 行', true); loadMemoryUI(); } else memFlash('✖ '+(j.error||'去重失败'), false);
|
|
769
773
|
};
|
|
770
|
-
// ——
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
const bar=document.createElement('div'); bar.style.cssText='height:3px;border-radius:2px;background:var(--bg3);margin:1px 0;overflow:hidden';
|
|
786
|
-
const max=bd.byTool&&bd.byTool[0]?bd.byTool[0].ms:1;
|
|
787
|
-
bar.innerHTML='<div style="height:100%;width:'+Math.max(2,Math.round((t.ms||0)/Math.max(max,1e-9)*100))+'%;background:var(--cyan)"></div>';
|
|
788
|
-
const row=document.createElement('div'); row.style.cssText='display:flex;justify-content:space-between;font-size:11.5px;padding:1px 0';
|
|
789
|
-
row.innerHTML='<span>'+esc(t.tool||'—')+'</span><span>'+t.calls+' 次 · '+fmtDur(t.ms)+'</span>';
|
|
790
|
-
const w=document.createElement('div'); w.appendChild(row); w.appendChild(bar); tools.appendChild(w);
|
|
791
|
-
}
|
|
792
|
-
if(!(bd.byTool||[]).length) tools.innerHTML='<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
|
|
793
|
-
const line=$('#costLine');
|
|
794
|
-
const days=(bd.byDay||[]).slice(-14);
|
|
795
|
-
if(!days.length){ line.innerHTML=''; return; }
|
|
796
|
-
const W=320, H=56, P=4;
|
|
797
|
-
const maxC=Math.max(...days.map(d=>d.cost), 1e-9);
|
|
798
|
-
const pts=days.map((d,i)=>{
|
|
774
|
+
// —— 省钱仪表盘(v0.3.1:从设置移出,顶部费用徽标点击展开,真·仪表盘) ——
|
|
775
|
+
function renderRateGauge(rate){
|
|
776
|
+
const r=48, cx=60, cy=60, circ=2*Math.PI*r;
|
|
777
|
+
const p=Math.max(0,Math.min(1,Number(rate)||0));
|
|
778
|
+
const color = p>=0.8 ? 'var(--accent)' : p>=0.5 ? 'var(--accent2)' : 'var(--warn)';
|
|
779
|
+
$('#rateGauge').innerHTML =
|
|
780
|
+
'<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="var(--bg3)" stroke-width="13"/>'+
|
|
781
|
+
'<circle cx="'+cx+'" cy="'+cy+'" r="'+r+'" fill="none" stroke="'+color+'" stroke-width="13" stroke-linecap="round" stroke-dasharray="'+(circ*p).toFixed(1)+' '+(circ*(1-p)).toFixed(1)+'" transform="rotate(-90 '+cx+' '+cy+')"/>';
|
|
782
|
+
$('#rateGaugeNum').textContent=(p*100).toFixed(1)+'%';
|
|
783
|
+
}
|
|
784
|
+
function trendChart(days){
|
|
785
|
+
if(!days||!days.length) return '<div style="color:var(--faint);font-size:11.5px;padding:6px">暂无费用记录</div>';
|
|
786
|
+
const W=360, H=88, P=8;
|
|
787
|
+
const max=Math.max(...days.map((/** @type {any} */ d)=>Number(d.cost)||0), 1e-9);
|
|
788
|
+
const pts=days.map((/** @type {any} */ d, /** @type {number} */ i)=>{
|
|
799
789
|
const x=P+i*(W-2*P)/Math.max(days.length-1,1);
|
|
800
|
-
const y=H-
|
|
801
|
-
return [x
|
|
790
|
+
const y=H-P-(Number(d.cost)||0)/max*(H-2*P);
|
|
791
|
+
return [x,y];
|
|
802
792
|
});
|
|
803
|
-
const
|
|
793
|
+
const line=pts.map((/** @type {any} */ p)=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' ');
|
|
794
|
+
const area='M'+pts[0][0].toFixed(1)+','+(H-P)+' L'+pts.map((/** @type {any} */ p)=>p[0].toFixed(1)+','+p[1].toFixed(1)).join(' L')+' L'+pts[pts.length-1][0].toFixed(1)+','+(H-P)+' Z';
|
|
804
795
|
const last=pts[pts.length-1];
|
|
805
|
-
|
|
806
|
-
+'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto
|
|
807
|
-
+'<
|
|
808
|
-
+'<
|
|
809
|
-
+'<
|
|
810
|
-
+'<
|
|
796
|
+
return '<div style="font-size:10.5px;color:var(--faint);margin-bottom:4px">最高 ¥'+max.toFixed(4)+' · '+days[0].day+' → '+days[days.length-1].day+'</div>'
|
|
797
|
+
+'<svg viewBox="0 0 '+W+' '+H+'" style="width:100%;height:auto">'
|
|
798
|
+
+'<defs><linearGradient id="dg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" style="stop-color:var(--accent2);stop-opacity:.35"/><stop offset="1" style="stop-color:var(--accent2);stop-opacity:0"/></linearGradient></defs>'
|
|
799
|
+
+'<path d="'+area+'" fill="url(#dg)"/>'
|
|
800
|
+
+'<polyline points="'+line+'" fill="none" stroke="var(--accent2)" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>'
|
|
801
|
+
+'<circle cx="'+last[0].toFixed(1)+'" cy="'+last[1].toFixed(1)+'" r="3" fill="var(--accent)"/>'
|
|
811
802
|
+'</svg>';
|
|
812
803
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
804
|
+
function barList(items, isCost){
|
|
805
|
+
if(!items||!items.length) return '<div style="color:var(--faint);font-size:11.5px;padding:4px">暂无记录</div>';
|
|
806
|
+
const max=Math.max(...items.map((/** @type {any} */ i)=>Number(i.val)||0), 1e-9);
|
|
807
|
+
return items.map((/** @type {any} */ i)=>{
|
|
808
|
+
const pct=Math.max(3,Math.round((Number(i.val)||0)/max*100));
|
|
809
|
+
const v=isCost ? '¥'+(Number(i.val)||0).toFixed(4) : fmtDur(i.val);
|
|
810
|
+
return '<div class="dbar"><div class="t"><span class="n" title="'+esc(i.name)+'">'+esc(String(i.name||'—').slice(0,26))+'</span><span class="v">'+v+(i.sub?' · '+i.sub:'')+'</span></div><div class="track"><div class="fill" style="width:'+pct+'%"></div></div></div>';
|
|
811
|
+
}).join('');
|
|
812
|
+
}
|
|
813
|
+
function renderDashboard(j){
|
|
814
|
+
const s=j.summary||{}; const bd=j.breakdown||{};
|
|
815
|
+
const rate=bd.rate!=null?bd.rate:(s.rate!=null?s.rate:0);
|
|
816
|
+
const saved=Number(s.saved||0);
|
|
817
|
+
$('#kpiToday').textContent='¥'+Number(bd.today||0).toFixed(4);
|
|
818
|
+
$('#kpiRate').textContent=(rate*100).toFixed(1)+'%';
|
|
819
|
+
$('#kpiSaved').textContent='¥'+saved.toFixed(4);
|
|
820
|
+
$('#kpiTurns').textContent=(s.turns||0)+' / '+(s.steps||0);
|
|
821
|
+
renderRateGauge(rate);
|
|
822
|
+
$('#trendChart').innerHTML=trendChart((bd.byDay||[]).slice(-14));
|
|
823
|
+
$('#dashModels').innerHTML=barList((bd.byModel||[]).slice(0,5).map((/** @type {any} */ m)=>({name:m.model, val:m.cost, sub:m.turns+' 轮'})), true);
|
|
824
|
+
$('#dashTools').innerHTML=barList((bd.byTool||[]).slice(0,5).map((/** @type {any} */ t)=>({name:t.tool, val:t.ms, sub:t.calls+' 次'})), false);
|
|
825
|
+
$('#dashRecent').innerHTML=(j.recent||[]).slice(0,6).map((/** @type {any} */ e)=>{
|
|
823
826
|
const hit=e.hit!=null&&e.miss!=null&&(e.hit+e.miss)>0?e.hit/(e.hit+e.miss):null;
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
bar.innerHTML='<div style="height:100%;width:'+(hit==null?0:Math.round(hit*100))+'%;background:var(--accent)"></div>';
|
|
827
|
-
const div=document.createElement('div'); div.style.cssText='display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--border);font-size:11.5px;color:var(--dim)';
|
|
828
|
-
const left=document.createElement('div'); left.style.cssText='flex:1';
|
|
829
|
-
left.innerHTML='<div style="display:flex;justify-content:space-between"><span>'+esc(e.model||'')+'</span><span>'+pct+' · ¥'+Number(e.cost||0).toFixed(5)+'</span></div>';
|
|
830
|
-
left.appendChild(bar);
|
|
831
|
-
const when=document.createElement('span'); when.style.cssText='white-space:nowrap;color:var(--faint)'; when.textContent=new Date(e.at).toLocaleTimeString();
|
|
832
|
-
div.appendChild(left); div.appendChild(when); list.appendChild(div);
|
|
833
|
-
}
|
|
827
|
+
return '<div class="dr"><span class="m">'+esc(e.model||'')+'</span><span class="p">'+(hit==null?'—':(hit*100).toFixed(0)+'%')+'</span><span class="c">¥'+Number(e.cost||0).toFixed(4)+'</span></div>';
|
|
828
|
+
}).join('') || '<div style="color:var(--faint);font-size:11.5px">暂无记录</div>';
|
|
834
829
|
}
|
|
835
830
|
// —— MCP 生态预设 ——
|
|
836
831
|
let mcpPresetData=[];
|
package/src/web/index.html
CHANGED
|
@@ -31,20 +31,23 @@ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.65 -apple-syste
|
|
|
31
31
|
body.traj-open{padding-left:294px}
|
|
32
32
|
body.sub-open{padding-right:320px}
|
|
33
33
|
body.tasks-open{padding-right:280px}
|
|
34
|
+
body.dash-open{padding-right:400px}
|
|
34
35
|
@media (max-width:1200px){
|
|
35
36
|
#trajPanel{width:210px}body.traj-open{padding-left:252px}
|
|
36
37
|
#subPanel{width:270px}body.sub-open{padding-right:270px}
|
|
37
38
|
#tasksPanel{width:230px}body.tasks-open{padding-right:230px}
|
|
39
|
+
#dashPanel{width:360px}body.dash-open{padding-right:360px}
|
|
38
40
|
}
|
|
39
41
|
@media (max-width:860px){
|
|
40
42
|
#trajPanel{width:160px}body.traj-open{padding-left:202px}
|
|
41
43
|
#subPanel{width:190px}body.sub-open{padding-right:190px}
|
|
42
44
|
#tasksPanel{width:190px}body.tasks-open{padding-right:190px}
|
|
45
|
+
#dashPanel{width:min(340px,94vw)}body.dash-open{padding-right:min(340px,94vw)}
|
|
43
46
|
}
|
|
44
47
|
@media (max-width:560px){
|
|
45
|
-
body.traj-open,body.sub-open,body.tasks-open{padding:0}
|
|
48
|
+
body.traj-open,body.sub-open,body.tasks-open,body.dash-open{padding:0}
|
|
46
49
|
#trajPanel{width:calc(100vw - 42px);left:42px}
|
|
47
|
-
#subPanel,#tasksPanel{width:100vw}
|
|
50
|
+
#subPanel,#tasksPanel,#dashPanel{width:100vw}
|
|
48
51
|
}
|
|
49
52
|
header{min-height:52px;display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);flex:none;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto}
|
|
50
53
|
header .logo{font-weight:700;font-size:16px;color:var(--accent);letter-spacing:.5px;flex:none}
|
|
@@ -229,6 +232,36 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
229
232
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
230
233
|
/* 自绘悬浮气泡 tooltip(替换原生 title):暗色悬浮层 + 阴影,跟随锚点居中,越界自动回弹 */
|
|
231
234
|
.mdtip{position:fixed;z-index:1000;max-width:320px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:7px 11px;font-size:12px;line-height:1.6;box-shadow:0 8px 24px rgba(0,0,0,.55);pointer-events:none;white-space:pre-line;word-break:break-word;display:none}
|
|
235
|
+
/* —— 省钱仪表盘(v0.3.1 顶部入口,真·仪表盘) —— */
|
|
236
|
+
#dashPanel{position:fixed;right:0;top:52px;bottom:0;width:400px;max-width:94vw;background:var(--bg2);border-left:1px solid var(--border);z-index:45;display:flex;flex-direction:column;box-shadow:-8px 0 32px rgba(0,0,0,.4)}
|
|
237
|
+
.dash-head{padding:12px 14px;font-size:14px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px}
|
|
238
|
+
.dash-head .dash-sub{font-size:11.5px;color:var(--faint);font-weight:400}
|
|
239
|
+
.dash-head button{margin-left:auto;padding:2px 8px;font-size:12px}
|
|
240
|
+
.dash-body{flex:1;overflow-y:auto;padding:14px}
|
|
241
|
+
.dash-kpi{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px}
|
|
242
|
+
.kpi{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:2px}
|
|
243
|
+
.kpi-l{font-size:11px;color:var(--faint)}
|
|
244
|
+
.kpi-v{font-size:17px;font-weight:700;font-variant-numeric:tabular-nums}
|
|
245
|
+
.dash-gauge-wrap{position:relative;width:120px;margin:4px auto 14px;text-align:center}
|
|
246
|
+
.gauge-label{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0}
|
|
247
|
+
.gauge-label b{font-size:20px;color:var(--accent2)}
|
|
248
|
+
.gauge-label span{font-size:10.5px;color:var(--faint)}
|
|
249
|
+
.dash-sec{font-size:11.5px;color:var(--faint);margin:12px 0 6px;border-top:1px dashed var(--border);padding-top:10px}
|
|
250
|
+
.dash-sec:first-of-type{border-top:none;padding-top:0}
|
|
251
|
+
.dash-chart{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:8px}
|
|
252
|
+
.dash-bars{display:flex;flex-direction:column;gap:6px}
|
|
253
|
+
.dbar{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:6px 10px}
|
|
254
|
+
.dbar .t{display:flex;justify-content:space-between;font-size:11.5px;margin-bottom:4px}
|
|
255
|
+
.dbar .t .n{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
|
|
256
|
+
.dbar .t .v{color:var(--dim);font-variant-numeric:tabular-nums}
|
|
257
|
+
.dbar .track{height:5px;border-radius:3px;background:var(--bg2);overflow:hidden}
|
|
258
|
+
.dbar .fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--accent2),var(--accent))}
|
|
259
|
+
.dash-recent{display:flex;flex-direction:column;gap:5px;font-size:11.5px}
|
|
260
|
+
.dr{display:flex;justify-content:space-between;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:5px 10px}
|
|
261
|
+
.dr .m{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:50%}
|
|
262
|
+
.dr .p{color:var(--accent2);font-variant-numeric:tabular-nums}
|
|
263
|
+
.dr .c{color:var(--faint);font-variant-numeric:tabular-nums}
|
|
264
|
+
.badge-btn:hover{border-color:var(--accent2);color:var(--accent2)}
|
|
232
265
|
</style>
|
|
233
266
|
</head>
|
|
234
267
|
<body>
|
|
@@ -237,7 +270,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
237
270
|
<span id="cfgMsg" class="badge" style="display:none"></span>
|
|
238
271
|
<button id="cfgBtn" title="设置">⚙</button>
|
|
239
272
|
<select id="wsSel" title="工作空间(切换 / 新建,目录自动创建)"></select>
|
|
240
|
-
<span class="badge" id="envBadge"></span><span class="badge" id="costBadge" title="
|
|
273
|
+
<span class="badge" id="envBadge"></span><span class="badge badge-btn" id="costBadge" title="省钱仪表盘(点击展开)" style="cursor:pointer">📊 —</span>
|
|
241
274
|
<div class="spacer"></div>
|
|
242
275
|
<input id="sessionSearch" placeholder="搜索会话…" title="关键词检索历史会话">
|
|
243
276
|
<select id="sessions" title="历史会话"><option value="">历史会话</option></select>
|
|
@@ -260,6 +293,29 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
260
293
|
<div class="tp-head">任务面板 <span id="tpCount"></span><button id="tpClose" style="float:right">✕</button></div>
|
|
261
294
|
<div id="tpList"></div>
|
|
262
295
|
</aside>
|
|
296
|
+
<aside id="dashPanel" style="display:none">
|
|
297
|
+
<div class="dash-head">📊 省钱仪表盘 <span class="dash-sub">费用 · 命中 · 节省</span><button id="dashClose">✕</button></div>
|
|
298
|
+
<div class="dash-body">
|
|
299
|
+
<div class="dash-kpi">
|
|
300
|
+
<div class="kpi"><span class="kpi-l">今日费用</span><span class="kpi-v" id="kpiToday">—</span></div>
|
|
301
|
+
<div class="kpi"><span class="kpi-l">缓存命中</span><span class="kpi-v" id="kpiRate">—</span></div>
|
|
302
|
+
<div class="kpi"><span class="kpi-l">已节省</span><span class="kpi-v" style="color:var(--accent)" id="kpiSaved">—</span></div>
|
|
303
|
+
<div class="kpi"><span class="kpi-l">轮次/步数</span><span class="kpi-v" id="kpiTurns">—</span></div>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="dash-gauge-wrap">
|
|
306
|
+
<svg id="rateGauge" viewBox="0 0 120 120" width="120" height="120"></svg>
|
|
307
|
+
<div class="gauge-label"><b id="rateGaugeNum">—</b><span>命中率</span></div>
|
|
308
|
+
</div>
|
|
309
|
+
<div class="dash-sec">近 14 天费用趋势(¥)</div>
|
|
310
|
+
<div id="trendChart" class="dash-chart"></div>
|
|
311
|
+
<div class="dash-sec">模型费用 Top</div>
|
|
312
|
+
<div id="dashModels" class="dash-bars"></div>
|
|
313
|
+
<div class="dash-sec">工具耗时 Top</div>
|
|
314
|
+
<div id="dashTools" class="dash-bars"></div>
|
|
315
|
+
<div class="dash-sec">最近缓存命中</div>
|
|
316
|
+
<div id="dashRecent" class="dash-recent"></div>
|
|
317
|
+
</div>
|
|
318
|
+
</aside>
|
|
263
319
|
<main><div id="chat"></div></main>
|
|
264
320
|
<footer>
|
|
265
321
|
<div id="workStatus" style="display:none"></div>
|
|
@@ -304,7 +360,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
304
360
|
<button class="cfg-nav-btn" data-panel="schedule">⏰ 调度队列</button>
|
|
305
361
|
<button class="cfg-nav-btn" data-panel="workspace">📁 工作空间</button>
|
|
306
362
|
<button class="cfg-nav-btn" data-panel="memory">🧠 长期记忆</button>
|
|
307
|
-
|
|
363
|
+
|
|
308
364
|
<button class="cfg-nav-btn" data-panel="mcp">🔌 MCP 生态</button>
|
|
309
365
|
<button class="cfg-nav-btn" data-panel="skills">🧩 技能库</button>
|
|
310
366
|
</nav>
|
|
@@ -402,24 +458,7 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
402
458
|
<textarea id="memArea" rows="5" placeholder="例如:所有文档用中文;提交信息用约定式提交…" style="width:100%;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;resize:vertical"></textarea>
|
|
403
459
|
<div class="row" style="justify-content:flex-start"><button id="memSave">保存记忆</button><button id="memDedupe">去重合并</button><span id="memMsg" style="font-size:12px;color:var(--faint)"></span></div>
|
|
404
460
|
</section>
|
|
405
|
-
|
|
406
|
-
<hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
|
|
407
|
-
<h4 style="margin:4px 0;color:var(--accent2)">缓存命中率仪表盘(最近 10 次)</h4>
|
|
408
|
-
<div id="cacheSummary" style="font-size:12.5px;color:var(--dim);padding:2px"></div>
|
|
409
|
-
<div id="cacheRecent" style="max-height:130px;overflow-y:auto"></div>
|
|
410
|
-
<h4 style="margin:10px 0 4px;color:var(--accent2)">费用二级分账(模型 / 工具 Top5 · 近 14 天)</h4>
|
|
411
|
-
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
412
|
-
<div style="flex:1;min-width:170px">
|
|
413
|
-
<div style="font-size:11.5px;color:var(--faint)">模型 Top5(费用)</div>
|
|
414
|
-
<div id="costTopModels" style="font-size:12px;color:var(--dim)"></div>
|
|
415
|
-
</div>
|
|
416
|
-
<div style="flex:1;min-width:170px">
|
|
417
|
-
<div style="font-size:11.5px;color:var(--faint)">工具 Top5(耗时)</div>
|
|
418
|
-
<div id="costTopTools" style="font-size:12px;color:var(--dim)"></div>
|
|
419
|
-
</div>
|
|
420
|
-
</div>
|
|
421
|
-
<div id="costLine" style="margin-top:8px"></div>
|
|
422
|
-
</section>
|
|
461
|
+
|
|
423
462
|
<section class="cfg-panel" data-panel="mcp">
|
|
424
463
|
<hr style="border:none;border-top:1px solid var(--border);margin:14px 0 8px">
|
|
425
464
|
<h4 style="margin:4px 0;color:var(--accent2)">MCP 生态预设(一键接入,重启生效)</h4>
|
package/src/web/server.js
CHANGED
|
@@ -29,8 +29,8 @@ import { MAX_CONCURRENT } from './constants.js';
|
|
|
29
29
|
import { createAgent } from '../agent.js';
|
|
30
30
|
import { createPermission } from '../permissions.js';
|
|
31
31
|
import { buildSystemPrompt } from '../prompts.js';
|
|
32
|
-
import { loadProjectMemory, extractAndAppendProjectMemory } from '../memory.js';
|
|
33
|
-
import {
|
|
32
|
+
import { loadProjectMemory, loadProjectMemoryEntries, retrieveRelevant, extractAndAppendProjectMemory } from '../memory.js';
|
|
33
|
+
import { saveTaskStateMerge, clearTaskState, loadTaskState, resumePrompt } from '../task-state.js';
|
|
34
34
|
import { createWebIO } from './web-io.js';
|
|
35
35
|
import { startMcpServers } from '../mcp.js';
|
|
36
36
|
import {
|
|
@@ -397,7 +397,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
397
397
|
// 每轮结束提取的新条目只写文件、不回灌当前会话。
|
|
398
398
|
let projectMemorySnapshot = sessionMemoryCache.get(sessionName);
|
|
399
399
|
if (projectMemorySnapshot === undefined) {
|
|
400
|
-
|
|
400
|
+
// v0.3.1 语义检索:用本轮任务(新会话即首条消息)取「相关记忆条目」,而非全量截 4K——
|
|
401
|
+
// 换任务只注入相关记忆、省 token;快照保证同一会话前缀稳定。
|
|
402
|
+
const allEntries = loadProjectMemoryEntries(taskDir);
|
|
403
|
+
const query = String(built.persistText || '');
|
|
404
|
+
projectMemorySnapshot = query ? retrieveRelevant(allEntries, query, 6).join('\n') : loadProjectMemory(taskDir);
|
|
401
405
|
sessionMemoryCache.set(sessionName, projectMemorySnapshot);
|
|
402
406
|
if (sessionMemoryCache.size > 200) {
|
|
403
407
|
const oldest = sessionMemoryCache.keys().next().value;
|
|
@@ -527,7 +531,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
527
531
|
// v0.3.0 P0-2:任务检查点——跑满步数(capHit)或中断(aborted)时落盘供续跑,正常完成清除
|
|
528
532
|
const finalSessionName = path.basename(session.file);
|
|
529
533
|
if (r.capHit || r.aborted) {
|
|
530
|
-
|
|
534
|
+
saveTaskStateMerge(finalSessionName, {
|
|
531
535
|
goal: built.persistText,
|
|
532
536
|
progress: r.text || '',
|
|
533
537
|
artifacts: io.stats().deliverables,
|