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/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
- const systemPrompt = buildSystemPrompt({ workingDir: taskDir, withJournal: body.withJournal === true });
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 ? '' : `(本轮共执行 ${io.stats().toolCount} 步工具操作${io.stats().deliverables.length ? `、交付 ${io.stats().deliverables.length} 个文件` : ''},但模型没有输出总结文字——可追问「总结一下刚才的工作」)`),
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; } },
@@ -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, '&amp;')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/"/g, '&quot;')
12
+ .replace(/'/g, '&#39;');
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
+ }