mingdao-harness 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/CONFIG.md +5 -5
- package/docs/PLAN-v0.3.0.md +109 -0
- package/docs/QA-REPORT.md +2 -0
- package/docs/SAVINGS-BENCHMARK.md +42 -0
- package/docs/STRATEGY-0.3.md +121 -0
- package/package.json +3 -3
- package/src/agent.js +54 -2
- package/src/cli.js +15 -0
- package/src/commands/diagnose.js +105 -0
- package/src/commands/repl.js +27 -5
- package/src/memory.js +126 -0
- package/src/prompts.js +9 -3
- package/src/session-index.js +130 -41
- package/src/task-state.js +59 -0
- package/src/tools/fs-tools.js +2 -1
- package/src/web/app.js +81 -87
- package/src/web/attachments.js +3 -4
- package/src/web/constants.js +9 -0
- package/src/web/index.html +22 -8
- package/src/web/routes/api.js +9 -7
- package/src/web/routes/domains/config.js +20 -2
- package/src/web/routes/domains/sessions.js +36 -1
- package/src/web/server.js +43 -4
- package/src/web/util.js +201 -0
package/src/web/server.js
CHANGED
|
@@ -25,9 +25,12 @@ import { createProvider, resolveProviderConfig, helperProvider } from '../provid
|
|
|
25
25
|
import { MODELS, modelPreset, PROVIDERS } from '../models.js';
|
|
26
26
|
import { routeTask, routingConfig } from '../routing.js';
|
|
27
27
|
import { buildUserContent } from './attachments.js';
|
|
28
|
+
import { MAX_CONCURRENT } from './constants.js';
|
|
28
29
|
import { createAgent } from '../agent.js';
|
|
29
30
|
import { createPermission } from '../permissions.js';
|
|
30
31
|
import { buildSystemPrompt } from '../prompts.js';
|
|
32
|
+
import { loadProjectMemory, extractAndAppendProjectMemory } from '../memory.js';
|
|
33
|
+
import { saveTaskState, clearTaskState, loadTaskState, resumePrompt } from '../task-state.js';
|
|
31
34
|
import { createWebIO } from './web-io.js';
|
|
32
35
|
import { startMcpServers } from '../mcp.js';
|
|
33
36
|
import {
|
|
@@ -217,7 +220,6 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
217
220
|
};
|
|
218
221
|
|
|
219
222
|
// 任务注册表:支持多会话并行——每个任务独立的 SSE 流、权限确认、中断控制
|
|
220
|
-
const MAX_CONCURRENT = 8;
|
|
221
223
|
let inflight = 0; // 质检 S2:在途聊天请求计数(与请求生命周期绑定,防 readBody 期间并发超限)
|
|
222
224
|
const tasks = new Map(); // taskId -> { res, send, abortHandler, pendingAsk, session, startedAt, status, message, durationMs }
|
|
223
225
|
let taskSeq = 0;
|
|
@@ -231,6 +233,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
231
233
|
return next;
|
|
232
234
|
}
|
|
233
235
|
const draftTexts = new Map(); // 质检 A5:草稿按会话维度存储(此前单槽多客户端互相覆盖)
|
|
236
|
+
const sessionMemoryCache = new Map(); // v0.3.0 P0-3:项目记忆「会话内快照」——同一会话系统提示恒定(保前缀缓存)
|
|
234
237
|
|
|
235
238
|
// —— 服务端诊断日志(第二问无反应排查 + 长期运维):<mingdao-home>/logs/web-server.log ——
|
|
236
239
|
// 记录每次对话的关键阶段与耗时;2MB 滚动。桌面版与 WebUI 共用同一日志。
|
|
@@ -390,12 +393,30 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
390
393
|
if (!sessionWsDir) {
|
|
391
394
|
setSessionWorkspace(sessionName, taskDir, currentWorkspace(workingDir)?.name || null);
|
|
392
395
|
}
|
|
393
|
-
|
|
396
|
+
// v0.3.0 P0-3:项目记忆会话内快照——本会话全程用同一份快照(前缀稳定),
|
|
397
|
+
// 每轮结束提取的新条目只写文件、不回灌当前会话。
|
|
398
|
+
let projectMemorySnapshot = sessionMemoryCache.get(sessionName);
|
|
399
|
+
if (projectMemorySnapshot === undefined) {
|
|
400
|
+
projectMemorySnapshot = loadProjectMemory(taskDir);
|
|
401
|
+
sessionMemoryCache.set(sessionName, projectMemorySnapshot);
|
|
402
|
+
if (sessionMemoryCache.size > 200) {
|
|
403
|
+
const oldest = sessionMemoryCache.keys().next().value;
|
|
404
|
+
sessionMemoryCache.delete(oldest);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true, projectMemory: projectMemorySnapshot });
|
|
394
408
|
let messages =
|
|
395
409
|
session.messages?.length && session.messages[0]?.role === 'system'
|
|
396
410
|
? session.messages
|
|
397
411
|
: [{ role: 'system', content: systemPrompt }, ...(session.messages || [])];
|
|
398
412
|
messages[0] = { role: 'system', content: systemPrompt }; // 总是刷新 system(记忆/技能/AGENTS.md 最新)
|
|
413
|
+
// v0.3.0 P0-2:续跑——继续一个有未完成检查点的会话时,先注入进度摘要让模型从断点接着做
|
|
414
|
+
if (!isNew) {
|
|
415
|
+
const ts = loadTaskState(sessionName);
|
|
416
|
+
if (ts && (ts.status === 'cap' || ts.status === 'interrupted')) {
|
|
417
|
+
messages.push({ role: 'user', content: resumePrompt(ts) });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
399
420
|
messages.push({ role: 'user', content: built.content });
|
|
400
421
|
// 落盘用文本版(图文数组只发给模型,会话文件保持字符串可渲染)
|
|
401
422
|
await withSessionLock(session.file, () => appendMessages(session.file, [{ role: 'user', content: built.persistText }]));
|
|
@@ -483,6 +504,11 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
483
504
|
await withSessionLock(session.file, () => appendMessages(session.file, messages.slice(persistedBefore)));
|
|
484
505
|
io.printUsageLine({ modelName: runModel, usage: r.usage, durationMs: r.durationMs });
|
|
485
506
|
recordUsage(r.perf?.usedModel || runModel, r.usage, /** @type {any} */ (r.perf));
|
|
507
|
+
// v0.3.0 P0-3:轮末提取项目记忆(有工具工作才提,fire-and-forget)。写文件供「未来会话」用,
|
|
508
|
+
// 当前会话用快照(上文已注入),故不会中途改系统提示、不破坏前缀缓存。
|
|
509
|
+
if (io.stats().toolCount > 0) {
|
|
510
|
+
extractAndAppendProjectMemory({ cfg, provider: providerNow, model: titleModel(cfg, runModel), messages, workingDir: taskDir }).catch(() => {});
|
|
511
|
+
}
|
|
486
512
|
maybeAutoSync().catch(() => {});
|
|
487
513
|
// 新会话自动标题(可配置关闭)
|
|
488
514
|
srvlog('chat 标题生成前 ' + taskId);
|
|
@@ -498,6 +524,19 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
498
524
|
}
|
|
499
525
|
} catch {}
|
|
500
526
|
}
|
|
527
|
+
// v0.3.0 P0-2:任务检查点——跑满步数(capHit)或中断(aborted)时落盘供续跑,正常完成清除
|
|
528
|
+
const finalSessionName = path.basename(session.file);
|
|
529
|
+
if (r.capHit || r.aborted) {
|
|
530
|
+
saveTaskState(finalSessionName, {
|
|
531
|
+
goal: built.persistText,
|
|
532
|
+
progress: r.text || '',
|
|
533
|
+
artifacts: io.stats().deliverables,
|
|
534
|
+
status: r.capHit ? 'cap' : 'interrupted',
|
|
535
|
+
updatedAt: new Date().toISOString(),
|
|
536
|
+
});
|
|
537
|
+
} else {
|
|
538
|
+
clearTaskState(finalSessionName);
|
|
539
|
+
}
|
|
501
540
|
entry.status = r.aborted ? 'aborted' : 'done';
|
|
502
541
|
entry.durationMs = Date.now() - entry.startedAt;
|
|
503
542
|
srvlog('chat 发送 done ' + taskId + ' status=' + entry.status + ' 总耗时=' + entry.durationMs + 'ms');
|
|
@@ -519,7 +558,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
519
558
|
durationMs: r.durationMs,
|
|
520
559
|
truncated: r.truncated,
|
|
521
560
|
aborted: r.aborted,
|
|
522
|
-
note: r.note || (r.text ? '' :
|
|
561
|
+
note: r.note || (r.text ? '' : `(任务已执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},自动收尾总结未能生成——可追问「总结一下刚才的工作」)`),
|
|
523
562
|
stats: io.stats(),
|
|
524
563
|
session: path.basename(session.file),
|
|
525
564
|
});
|
|
@@ -544,7 +583,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
544
583
|
providerCache, getProviderFor, tasks,
|
|
545
584
|
MAX_CONCURRENT, pruneTasks, handleChat,
|
|
546
585
|
mcpFacade, provider, validateRemoteUrl, isPrivateHost,
|
|
547
|
-
authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd,
|
|
586
|
+
authEnabled, tokenMatches, requestToken, trustedHost, INDEX_HTML, draftTexts, startupCwd, sessionMemoryCache,
|
|
548
587
|
state: { get modelName() { return modelName; }, set modelName(v) { modelName = v; },
|
|
549
588
|
get workingDir() { return workingDir; }, set workingDir(v) { workingDir = v; } },
|
|
550
589
|
refs: { get inflight() { return inflight; }, set inflight(v) { inflight = v; } },
|
package/src/web/util.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// 纯前端工具(零状态,仅依赖 DOM/标准 API):选择器、转义、Markdown 渲染、格式化。
|
|
2
|
+
// 从 app.js 拆分(v0.2.8 C2):这些函数被消息渲染/轨迹/设置等多处复用,独立成模块便于维护与测试。
|
|
3
|
+
|
|
4
|
+
export const $ = (s) => document.querySelector(s);
|
|
5
|
+
|
|
6
|
+
export function esc(s) {
|
|
7
|
+
return String(s ?? '')
|
|
8
|
+
.replace(/&/g, '&')
|
|
9
|
+
.replace(/</g, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
11
|
+
.replace(/"/g, '"')
|
|
12
|
+
.replace(/'/g, ''');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function highlight(code, lang) {
|
|
16
|
+
const kws = new Set(
|
|
17
|
+
'const let var function return if else for while import from export class new await async try catch throw null undefined true false def elif raise with as lambda None True False self echo exit if then else fi done local function'.split(
|
|
18
|
+
' '
|
|
19
|
+
)
|
|
20
|
+
);
|
|
21
|
+
const re = /(\/\/[^\n]*|#[^\n]*|"(?:\\.|[^"\\])*"|'[^']*'|`[^`]*`|\b\d[\d._]*\b|[A-Za-z_$][\w$]*)/g;
|
|
22
|
+
return esc(code).replace(re, (tok) => {
|
|
23
|
+
if (tok.startsWith('//') || tok.startsWith('#')) return '<span class="hl-c">' + tok + '</span>';
|
|
24
|
+
if (/^["'`]/.test(tok)) return '<span class="hl-s">' + tok + '</span>';
|
|
25
|
+
if (/^\d/.test(tok)) return '<span class="hl-n">' + tok + '</span>';
|
|
26
|
+
if (kws.has(tok)) return '<span class="hl-kw">' + tok + '</span>';
|
|
27
|
+
return tok;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderMarkdown(text) {
|
|
32
|
+
// 块级解析(美化:加粗标题 / 段落 / 真列表 / 引用 / 分割线,参照 DeepSeek-Harness 会话排版)
|
|
33
|
+
const lines = String(text).split('\n');
|
|
34
|
+
let out = '';
|
|
35
|
+
let code = null;
|
|
36
|
+
let codeLang = '';
|
|
37
|
+
const flushCode = () => {
|
|
38
|
+
if (code !== null) {
|
|
39
|
+
out +=
|
|
40
|
+
'<div class="codeblock"><div class="cb-banner"><span class="cb-lang"><span class="cb-dot"></span>' +
|
|
41
|
+
(esc(codeLang) || 'code') +
|
|
42
|
+
'</span><span class="cb-copy" style="cursor:pointer">复制</span></div><pre><code class="lang-' +
|
|
43
|
+
esc(codeLang) +
|
|
44
|
+
'">' +
|
|
45
|
+
highlight(code.join('\n'), codeLang) +
|
|
46
|
+
'</code></pre></div>';
|
|
47
|
+
code = null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
let para = [];
|
|
51
|
+
let list = null; // {type:'ul'|'ol', items:[]}
|
|
52
|
+
const flushPara = () => {
|
|
53
|
+
if (para.length) {
|
|
54
|
+
out += '<p>' + para.join('<br>') + '</p>';
|
|
55
|
+
para = [];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const flushList = () => {
|
|
59
|
+
if (list) {
|
|
60
|
+
out += '<' + list.type + '>' + list.items.map((i) => '<li>' + i + '</li>').join('') + '</' + list.type + '>';
|
|
61
|
+
list = null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// 行内元素(审计 P1-3:内容与 codeLang 均经 esc 转义防 XSS)
|
|
65
|
+
const inline = (l) => {
|
|
66
|
+
let s = esc(l);
|
|
67
|
+
s = s
|
|
68
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
69
|
+
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
|
70
|
+
.replace(/\*([^*\s][^*]*)\*/g, '<i>$1</i>')
|
|
71
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s'"<>)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
72
|
+
return s;
|
|
73
|
+
};
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const t = line.trim();
|
|
76
|
+
if (t.startsWith('```')) {
|
|
77
|
+
if (code === null) {
|
|
78
|
+
flushList();
|
|
79
|
+
flushPara();
|
|
80
|
+
code = [];
|
|
81
|
+
codeLang = t.slice(3).trim().split(/\s+/)[0] || '';
|
|
82
|
+
} else flushCode();
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (code !== null) {
|
|
86
|
+
code.push(line);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (t === '') {
|
|
90
|
+
flushList();
|
|
91
|
+
flushPara();
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const h = t.match(/^(#{1,6})\s+(.*)$/);
|
|
95
|
+
if (h) {
|
|
96
|
+
flushList();
|
|
97
|
+
flushPara();
|
|
98
|
+
const level = Math.min(h[1].length, 4);
|
|
99
|
+
out += '<h' + level + '>' + inline(h[2]) + '</h' + level + '>';
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const ul = t.match(/^([-*+])\s+(.*)$/);
|
|
103
|
+
if (ul) {
|
|
104
|
+
flushPara();
|
|
105
|
+
if (!list || list.type !== 'ul') {
|
|
106
|
+
flushList();
|
|
107
|
+
list = { type: 'ul', items: [] };
|
|
108
|
+
}
|
|
109
|
+
list.items.push(inline(ul[2]));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const ol = t.match(/^(\d+)[.)]\s+(.*)$/);
|
|
113
|
+
if (ol) {
|
|
114
|
+
flushPara();
|
|
115
|
+
if (!list || list.type !== 'ol') {
|
|
116
|
+
flushList();
|
|
117
|
+
list = { type: 'ol', items: [] };
|
|
118
|
+
}
|
|
119
|
+
list.items.push(inline(ol[2]));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (/^>\s?/.test(t)) {
|
|
123
|
+
flushList();
|
|
124
|
+
flushPara();
|
|
125
|
+
out += '<blockquote>' + inline(t.replace(/^>\s?/, '')) + '</blockquote>';
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (/^(-{3,}|\*{3,})$/.test(t)) {
|
|
129
|
+
flushList();
|
|
130
|
+
flushPara();
|
|
131
|
+
out += '<hr>';
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
flushList();
|
|
135
|
+
para.push(inline(t));
|
|
136
|
+
}
|
|
137
|
+
flushCode();
|
|
138
|
+
flushList();
|
|
139
|
+
flushPara();
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function scrollBottom() {
|
|
144
|
+
const sc = document.querySelector('main');
|
|
145
|
+
if (sc) sc.scrollTop = sc.scrollHeight;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function expandableBody(previewHtml, fullHtml) {
|
|
149
|
+
const b = document.createElement('div');
|
|
150
|
+
b.className = 'body';
|
|
151
|
+
const prev = document.createElement('div');
|
|
152
|
+
const full = document.createElement('div');
|
|
153
|
+
prev.innerHTML = previewHtml;
|
|
154
|
+
full.innerHTML = fullHtml;
|
|
155
|
+
full.style.display = 'none';
|
|
156
|
+
const btn = document.createElement('button');
|
|
157
|
+
btn.textContent = '展开全文';
|
|
158
|
+
btn.style.cssText = 'padding:1px 8px;font-size:11px;margin-top:4px';
|
|
159
|
+
btn.onclick = () => {
|
|
160
|
+
const open = full.style.display !== 'none';
|
|
161
|
+
full.style.display = open ? 'none' : 'block';
|
|
162
|
+
btn.textContent = open ? '展开全文' : '收起';
|
|
163
|
+
scrollBottom();
|
|
164
|
+
};
|
|
165
|
+
b.appendChild(prev);
|
|
166
|
+
b.appendChild(full);
|
|
167
|
+
b.appendChild(btn);
|
|
168
|
+
return b;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function resultText(r) {
|
|
172
|
+
if (r == null || r === undefined) return '';
|
|
173
|
+
if (typeof r === 'string') return r;
|
|
174
|
+
if (r.output) return String(r.output);
|
|
175
|
+
if (r.stdout || r.stderr) return String(r.stdout || '') + String(r.stderr || '');
|
|
176
|
+
return JSON.stringify(r);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function truncText(t, n) {
|
|
180
|
+
t = String(t || '');
|
|
181
|
+
return t.length > n ? t.slice(0, n) + '\n…(截断,共 ' + t.length + ' 字)' : t;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function fmtDur(ms) {
|
|
185
|
+
if (!ms) return '0s';
|
|
186
|
+
const sec = Math.round(ms / 1000);
|
|
187
|
+
const m = Math.floor(sec / 60);
|
|
188
|
+
return m > 0 ? m + 'm' + String(sec % 60).padStart(2, '0') + 's' : sec + 's';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function fmtTok(n) {
|
|
192
|
+
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
|
|
193
|
+
if (n >= 1e6) return (n / 1e6).toFixed(0) + 'M';
|
|
194
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
|
195
|
+
return String(n);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function fmtT(n) {
|
|
199
|
+
n = Number(n || 0);
|
|
200
|
+
return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
|
|
201
|
+
}
|