dsh-mcmp 2.2.0 → 2.2.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/README.md +1 -3
- package/lib/index.js +24 -130
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,8 +19,6 @@ DeepSeek Harness **持久化 Cordis 插件**:面向全国大学生数学建模
|
|
|
19
19
|
- **成果全落盘**:每步迭代完整追加保存(含质疑清单),旧内容绝不覆盖;代码保留全部历史版本;全新运行时旧目录自动归档为 `数学建模流水线_历史归档_*`
|
|
20
20
|
- **实时进度面板**:独立浮动窗口(可拖拽/最小化/关闭),显示百分比进度条、当前轮/步骤/迭代、8 步骤打点、日志、产出文件,支持一键中止、重置(全新开始)
|
|
21
21
|
- **断点续跑与重置**:中断/关闭窗口不丢进度,再次 `/loopbegin` 自动续跑;`/loopreset` 或面板「重置(全新开始)」清空续跑记录,下次从头跑
|
|
22
|
-
- **迭代失败自动重试**:单个子任务因模型限流/过载(429、5xx)等瞬时故障失败时,自动退避重试(默认 15s/45s,最多 2 次),吸收抖动、避免"连续 3 败提前终止";确实持续失败才终止,并提示排查方向
|
|
23
|
-
- **`--from` 文件自动补全**:`--from 文件名` 支持部分匹配,自动查找工作区根目录下的 `.md` 文档(前缀优先、包含次之;唯一命中即采用,歧义列出候选);只写 `--from` 会列出全部 MD 文档供选择——不用再复制粘贴完整文件名
|
|
24
22
|
- **原生工作流卡片**:聊天区实时显示每个子智能体的运行状态(按阶段分组)
|
|
25
23
|
|
|
26
24
|
## 八大步骤(每轮 19 次迭代)
|
|
@@ -44,7 +42,7 @@ DeepSeek Harness **持久化 Cordis 插件**:面向全国大学生数学建模
|
|
|
44
42
|
2. **触发插件**:发送一条以 `/loopbegin` 开头的消息:
|
|
45
43
|
- `/loopbegin` —— 默认 1 轮
|
|
46
44
|
- `/loopbegin --round=3` —— 3 轮外循环(上限 10)
|
|
47
|
-
- `/loopbegin --from 题目.txt` ——
|
|
45
|
+
- `/loopbegin --from 题目.txt` —— 从文件读取赛题(支持含空格/中文的文件名,如 `--from 2024 年高教社杯全国大学生数学建模竞赛题目.md`,也可用 `--from=文件` 形式)
|
|
48
46
|
3. **查看进度**:右下角浮动面板(百分比、步骤打点、日志、文件)+ 聊天区工作流卡片。
|
|
49
47
|
|
|
50
48
|
辅助命令:`/loopstatus`(查看进度)、`/loopabort`(中止,已产出成果保留)、`/loopreset`(清空状态与断点续跑记录,下次 `/loopbegin` 从头全新开始;等价于面板「重置(全新开始)」按钮)。
|
package/lib/index.js
CHANGED
|
@@ -29,38 +29,12 @@ export function apply(ctx) {
|
|
|
29
29
|
const subagents = ctx.get('subagents')
|
|
30
30
|
const systemPrompt = ctx.get('systemPrompt')
|
|
31
31
|
const webServer = ctx.get('webServer')
|
|
32
|
-
|
|
33
|
-
console.log('dsh-mcmp: services available -> commands=' + !!commands + ' fs=' + !!fs + ' agents=' + !!agents + ' subagents=' + !!subagents + ' systemPrompt=' + !!systemPrompt + ' webServer=' + !!webServer + ' timer=' + !!timerSvc)
|
|
32
|
+
console.log('dsh-mcmp: services available -> commands=' + !!commands + ' fs=' + !!fs + ' agents=' + !!agents + ' subagents=' + !!subagents + ' systemPrompt=' + !!systemPrompt + ' webServer=' + !!webServer)
|
|
34
33
|
|
|
35
34
|
const FLOW_NAME = '数学建模论文流水线v2'
|
|
36
35
|
// 全流水线缺陷台账:跨步骤错误登记、裁决与向上修复的唯一权威文件
|
|
37
36
|
const LEDGER = '00_缺陷与裁决台账.md'
|
|
38
37
|
|
|
39
|
-
// 迭代级重试:一次迭代失败(如模型 429/5xx 瞬时抖动)后自动退避重试,
|
|
40
|
-
// 避免"连续 3 个子任务失败"被瞬时故障触发。重试间隔可用 --retry-ms=N 覆盖(测试/调优用)。
|
|
41
|
-
const DEFAULT_RETRY_DELAYS = [15000, 45000]
|
|
42
|
-
const MAX_ITER_RETRY = 2
|
|
43
|
-
|
|
44
|
-
function sleepAbortable(ms, signal) {
|
|
45
|
-
return new Promise((resolve) => {
|
|
46
|
-
let settled = false
|
|
47
|
-
let timerDispose = null
|
|
48
|
-
const finish = () => {
|
|
49
|
-
if (settled) return
|
|
50
|
-
settled = true
|
|
51
|
-
if (timerDispose) { try { timerDispose() } catch (err) { /* ignore */ } }
|
|
52
|
-
resolve()
|
|
53
|
-
}
|
|
54
|
-
if (signal && signal.aborted) { resolve(); return }
|
|
55
|
-
if (signal && typeof signal.addEventListener === 'function') signal.addEventListener('abort', finish, { once: true })
|
|
56
|
-
if (timerSvc && typeof timerSvc.timeout === 'function') {
|
|
57
|
-
try { timerDispose = timerSvc.timeout(finish, ms) } catch (err) { finish() }
|
|
58
|
-
} else {
|
|
59
|
-
finish() // 无 timer 服务时不做等待,直接重试
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
|
|
64
38
|
// ---------- 识图能力探测(宿主侧,仅供参考;以子智能体自检为准) ----------
|
|
65
39
|
function detectVision() {
|
|
66
40
|
// 1) 宿主服务:常见的识图服务键
|
|
@@ -139,7 +113,6 @@ export function apply(ctx) {
|
|
|
139
113
|
let state = freshState()
|
|
140
114
|
let active = null
|
|
141
115
|
let lastSession = null // 最近一次运行所属会话,供「清空状态」追加重置标记
|
|
142
|
-
let starting = false // 启动锁:async startPipeline 中等待目录扫描时防止并发启动
|
|
143
116
|
const recording = new Map()
|
|
144
117
|
|
|
145
118
|
function fmtTime(ts) {
|
|
@@ -316,67 +289,6 @@ export function apply(ctx) {
|
|
|
316
289
|
return filtered.trim()
|
|
317
290
|
}
|
|
318
291
|
|
|
319
|
-
// ---------- --from 的文件补全:列出/匹配工作区根目录的 .md 文档 ----------
|
|
320
|
-
async function listMdFiles(dir) {
|
|
321
|
-
if (!fs || !dir) return []
|
|
322
|
-
try {
|
|
323
|
-
const root = await fs.resolve(dir)
|
|
324
|
-
const entries = await fs.listDir(root)
|
|
325
|
-
return entries
|
|
326
|
-
.filter((e) => e && e.type === 'file' && /\.md$/i.test(e.name || ''))
|
|
327
|
-
.map((e) => e.name)
|
|
328
|
-
.sort()
|
|
329
|
-
} catch (err) { /* 目录不存在/不可读时返回空 */ }
|
|
330
|
-
return []
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function pickMdMatch(names, partial) {
|
|
334
|
-
const p = (partial || '').toLowerCase()
|
|
335
|
-
const exact = names.filter((n) => n.toLowerCase() === p)
|
|
336
|
-
if (exact.length === 1) return { kind: 'match', name: exact[0] }
|
|
337
|
-
const starts = names.filter((n) => n.toLowerCase().startsWith(p))
|
|
338
|
-
if (starts.length === 1) return { kind: 'match', name: starts[0] }
|
|
339
|
-
if (starts.length > 1) return { kind: 'ambiguous', names: starts }
|
|
340
|
-
const contains = names.filter((n) => n.toLowerCase().indexOf(p) >= 0)
|
|
341
|
-
if (contains.length === 1) return { kind: 'match', name: contains[0] }
|
|
342
|
-
if (contains.length > 1) return { kind: 'ambiguous', names: contains }
|
|
343
|
-
return { kind: 'none' }
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
async function resolveFromPath(cwd, raw) {
|
|
347
|
-
// 返回 { kind:'ok', path } | { kind:'error', text }
|
|
348
|
-
let p = (raw || '').trim().replace(/^["']|["']$/g, '')
|
|
349
|
-
const cwdNorm = cwd.replace(/[\\/]+$/, '')
|
|
350
|
-
if (p.length === 0) {
|
|
351
|
-
const names = await listMdFiles(cwd)
|
|
352
|
-
if (names.length === 0) return { kind: 'error', text: '--from 未指定文件,且工作区根目录下没有 .md 文档。' }
|
|
353
|
-
return { kind: 'error', text: '--from 未指定文件。工作区根目录下的 MD 文档:\n' + names.map((n) => ' - ' + n).join('\n') + '\n请用 --from 文件名(支持部分匹配,如 --from 2024)重新指定。' }
|
|
354
|
-
}
|
|
355
|
-
// 绝对路径
|
|
356
|
-
if (/^[A-Za-z]:[\\/]/.test(p) || p.indexOf('/') === 0 || p.indexOf('\\') === 0) {
|
|
357
|
-
return { kind: 'ok', path: p }
|
|
358
|
-
}
|
|
359
|
-
// 含路径分隔符的相对路径:直接按路径解析并验证存在
|
|
360
|
-
if (p.indexOf('/') >= 0 || p.indexOf('\\') >= 0) {
|
|
361
|
-
const full = cwdNorm + '/' + p
|
|
362
|
-
try {
|
|
363
|
-
const t = await fs.resolve(full)
|
|
364
|
-
const st = await fs.stat(t)
|
|
365
|
-
if (st && st.type === 'file') return { kind: 'ok', path: full }
|
|
366
|
-
return { kind: 'error', text: '路径不是文件: ' + p }
|
|
367
|
-
} catch (err) { /* fallthrough 到模糊匹配 */ }
|
|
368
|
-
}
|
|
369
|
-
// 模糊匹配文件名
|
|
370
|
-
const names = await listMdFiles(cwd)
|
|
371
|
-
const pick = pickMdMatch(names, p)
|
|
372
|
-
if (pick.kind === 'match') return { kind: 'ok', path: cwdNorm + '/' + pick.name }
|
|
373
|
-
if (pick.kind === 'ambiguous') {
|
|
374
|
-
return { kind: 'error', text: '--from "' + p + '" 匹配到多个文档,请补充关键字:\n' + pick.names.map((n) => ' - ' + n).join('\n') }
|
|
375
|
-
}
|
|
376
|
-
const hint = names.length > 0 ? '\n工作区根目录现有 MD 文档:\n' + names.map((n) => ' - ' + n).join('\n') : ''
|
|
377
|
-
return { kind: 'error', text: '未找到匹配 "' + p + '" 的 MD 文档。' + hint }
|
|
378
|
-
}
|
|
379
|
-
|
|
380
292
|
function buildPrompt(round, si, ii, s, it, args) {
|
|
381
293
|
const p = []
|
|
382
294
|
p.push('你正在参与「全国大学生数学建模竞赛论文」的多轮自动化撰写流水线,担任本轮「' + s.name + ' · ' + it.name + '」的执行专家。你的目标不是"写完",而是产出**可直接放进竞赛论文、经得起评审质疑**的内容。')
|
|
@@ -564,23 +476,10 @@ export function apply(ctx) {
|
|
|
564
476
|
let started = false
|
|
565
477
|
let outcome = 'failed'
|
|
566
478
|
let note = ''
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
res = await runChild(provider, session, agent, controller, label, buildPrompt(r, f.s, f.i, s, it, args), (child) => {
|
|
572
|
-
if (!started) {
|
|
573
|
-
started = true
|
|
574
|
-
appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
|
|
575
|
-
}
|
|
576
|
-
})
|
|
577
|
-
if (res.outcome !== 'failed' || attempt >= MAX_ITER_RETRY) break
|
|
578
|
-
const delayMs = retryDelays[Math.min(attempt, retryDelays.length - 1)] || 0
|
|
579
|
-
pushLog('迭代失败,将在 ' + Math.round(delayMs / 1000) + 's 后重试(第 ' + (attempt + 1) + '/' + MAX_ITER_RETRY + ' 次): ' + label)
|
|
580
|
-
await sleepAbortable(delayMs, controller.signal)
|
|
581
|
-
if (controller.signal.aborted) { cancelled = true; break }
|
|
582
|
-
}
|
|
583
|
-
if (cancelled) break
|
|
479
|
+
const res = await runChild(provider, session, agent, controller, label, buildPrompt(r, f.s, f.i, s, it, args), (child) => {
|
|
480
|
+
started = true
|
|
481
|
+
appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
|
|
482
|
+
})
|
|
584
483
|
outcome = res.outcome
|
|
585
484
|
note = (res.text || '').trim().slice(0, 300)
|
|
586
485
|
if (res.err) note = '执行异常: ' + String((res.err && res.err.message) || res.err).slice(0, 200)
|
|
@@ -641,13 +540,12 @@ export function apply(ctx) {
|
|
|
641
540
|
if (active && active.runId === runId) active = null
|
|
642
541
|
}
|
|
643
542
|
|
|
644
|
-
// ---------- 共享的流水线启动逻辑(
|
|
645
|
-
|
|
646
|
-
if (state.status === 'running' || starting) {
|
|
647
|
-
return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
|
|
648
|
-
}
|
|
649
|
-
starting = true
|
|
543
|
+
// ---------- 共享的流水线启动逻辑(同步执行,永不阻塞命令通道) ----------
|
|
544
|
+
function startPipeline(agent, rawInput) {
|
|
650
545
|
try {
|
|
546
|
+
if (state.status === 'running') {
|
|
547
|
+
return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
|
|
548
|
+
}
|
|
651
549
|
if (!subagents) return { kind: 'error', text: '子智能体服务不可用(当前部署未提供),无法启动流水线。' }
|
|
652
550
|
const session = agent && agent.session
|
|
653
551
|
const cwd = session && session.header ? session.header.cwd : undefined
|
|
@@ -662,21 +560,20 @@ export function apply(ctx) {
|
|
|
662
560
|
if (!Number.isSafeInteger(rounds) || rounds < 1) rounds = 1
|
|
663
561
|
if (rounds > 10) return { kind: 'error', text: '--round 最大为 10。每轮 19 次迭代、每次迭代启动一个专家子智能体,轮数过大会非常耗时。' }
|
|
664
562
|
const fresh = /(?:^|\s)--fresh\b/i.test(raw)
|
|
665
|
-
//
|
|
666
|
-
const
|
|
667
|
-
const retryDelays = mRetry ? [Math.max(1, parseInt(mRetry[1], 10)), Math.max(1, parseInt(mRetry[1], 10))] : DEFAULT_RETRY_DELAYS
|
|
668
|
-
// --from:允许只写 --from(列出根目录 MD 文档)或部分文件名(自动匹配补全)
|
|
669
|
-
const hasFrom = /(?:^|\s)--from\b/i.test(raw)
|
|
670
|
-
const mFrom = hasFrom ? /(?:^|\s)--from(?:=)?\s*([^\r\n]+?)(?=\s+--|\r?$)/i.exec(raw) : null
|
|
563
|
+
// --from 路径:允许含空格,遇到下一个 -- 标志或行尾即结束,避免吞掉后面的 --round/--fresh
|
|
564
|
+
const mFrom = /(?:^|\s)--from(?:=)?\s*([^\r\n]+?)(?=\s+--|\r?$)/i.exec(raw)
|
|
671
565
|
let problem = null
|
|
672
|
-
if (
|
|
673
|
-
|
|
674
|
-
if (
|
|
675
|
-
|
|
566
|
+
if (mFrom) {
|
|
567
|
+
let p = mFrom[1].trim().replace(/^["']|["']$/g, '')
|
|
568
|
+
if (p.length === 0) return { kind: 'error', text: '--from 需要指定赛题文件路径。' }
|
|
569
|
+
const abs = /^[A-Za-z]:[\\/]/.test(p) || p.indexOf('/') === 0 || p.indexOf('\\') === 0
|
|
570
|
+
? p
|
|
571
|
+
: cwd.replace(/[\\/]+$/, '') + '/' + p
|
|
572
|
+
problem = { kind: 'file', path: abs }
|
|
676
573
|
} else {
|
|
677
574
|
const text = extractProblem(session)
|
|
678
575
|
if (text.trim().length < 40) {
|
|
679
|
-
return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from
|
|
576
|
+
return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from 题目.txt 指定赛题文件。' }
|
|
680
577
|
}
|
|
681
578
|
problem = { kind: 'text', text: text.length > 6000 ? text.slice(0, 6000) + '\n…(原文过长,已截断)' : text }
|
|
682
579
|
}
|
|
@@ -684,7 +581,7 @@ export function apply(ctx) {
|
|
|
684
581
|
const problemPath = problem.kind === 'file' ? problem.path : wsDir + '/00_赛题原文.md'
|
|
685
582
|
const title = problem.kind === 'text' ? problem.text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0)[0] : problem.path
|
|
686
583
|
const vision = detectVision()
|
|
687
|
-
const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题', vision
|
|
584
|
+
const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题', vision }
|
|
688
585
|
// 断点续跑:扫描本会话落盘的 v2 工作流记录,跳过已完成的迭代
|
|
689
586
|
const resumeCount = fresh ? 0 : completedIterations(session)
|
|
690
587
|
args.startIndex = resumeCount
|
|
@@ -730,8 +627,6 @@ export function apply(ctx) {
|
|
|
730
627
|
}
|
|
731
628
|
} catch (err) {
|
|
732
629
|
return { kind: 'error', text: '启动失败: ' + String((err && err.message) || err) }
|
|
733
|
-
} finally {
|
|
734
|
-
starting = false
|
|
735
630
|
}
|
|
736
631
|
}
|
|
737
632
|
|
|
@@ -741,7 +636,7 @@ export function apply(ctx) {
|
|
|
741
636
|
commands.register({
|
|
742
637
|
name: 'loopbegin',
|
|
743
638
|
description: '启动数学建模论文自动化流水线 v2(8 步骤:含图表视觉自检、评审修复闭环、论文定稿;每轮 19 次迭代、多轮外循环,成果全部落盘)',
|
|
744
|
-
input: { hint: '--round=N 外循环轮数(默认 1)
|
|
639
|
+
input: { hint: '--round=N 外循环轮数(默认 1);可选 --from 题目文件 指定赛题文件;--fresh 从头重跑' },
|
|
745
640
|
handler: (invocation) => startPipeline(invocation.agent, invocation.rawInput),
|
|
746
641
|
})
|
|
747
642
|
} catch (err) { console.warn('dsh-mcmp: loopbegin 注册失败(可能被其他实例占用): ' + String((err && err.message) || err)) }
|
|
@@ -799,9 +694,8 @@ export function apply(ctx) {
|
|
|
799
694
|
const agent = agents && agents.get(session.id)
|
|
800
695
|
if (!agent) return
|
|
801
696
|
console.log('dsh-mcmp: 检测到以 /loopbegin 开头的用户消息,自动启动流水线')
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
}).catch(() => { /* 忽略 */ })
|
|
697
|
+
const res = startPipeline(agent, firstLine)
|
|
698
|
+
if (res && res.kind === 'error') pushLog('消息触发启动失败: ' + res.text)
|
|
805
699
|
} catch (err) { /* 触发器自身异常忽略,不影响消息流 */ }
|
|
806
700
|
}, { global: true })
|
|
807
701
|
|
package/package.json
CHANGED