dsh-vibe-math 2.5.0 → 2.7.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/README.en.md +175 -369
- package/README.md +175 -370
- package/cordis.patch.yml +78 -22
- package/docs/AUDIT-CHECKLIST.md +3 -3
- package/docs/final-paper.md +172 -0
- package/docs/formal-verification.md +184 -29
- package/docs/math-computation.md +121 -0
- package/docs/release-notes/RELEASE-NOTES-2.6.0.md +268 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
- package/docs/test-timing.md +25 -9
- package/installer.js +7 -4
- package/package.json +21 -3
- package/prompt-corpus-persona/persona-corpus.json +16 -16
- package/prompt-corpus-persona/persona-corpus.md +164 -52
- package/prompt-corpus-v2/formal-verify-v2.json +128 -92
- package/prompt-corpus-v2/formal-verify-v2.md +1574 -108
- package/prompt-corpus-v3/formal-verify-v3.json +137 -53
- package/prompt-corpus-v3/formal-verify-v3.md +1406 -100
- package/prompt-corpus-v4/formal-verify-v4.json +14 -14
- package/prompt-corpus-v4/formal-verify-v4.md +247 -14
- package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
- package/prompt-corpus-v5/prompt-corpus-v5.md +570 -3
- package/tests/audit-math-computation-contract.mjs +191 -0
- package/tests/audit-math-computation-parity.mjs +169 -0
- package/tests/audit-math-computation-sensitivity.mjs +231 -0
- package/tests/audit-persona-sensitivity.mjs +64 -11
- package/tests/audit-persona-surface.test.mjs +53 -16
- package/tests/audit-v5-integrity.mjs +14 -0
- package/tests/e2e-v5-round2.test.mjs +923 -3
- package/tests/formal-verify-v2.test.mjs +295 -6
- package/tests/formal-verify-v3.test.mjs +275 -4
- package/tests/formal-verify-v4.test.mjs +295 -6
- package/tests/formal-verify-v5.test.mjs +9 -1
- package/tests/helpers/math-computation-fake-seam.mjs +135 -0
- package/tests/math-computation-shared.test.mjs +504 -0
- package/tests/prompt-v5-integrity.test.mjs +14 -8
- package/tests/selfdrive-v5.mjs +55 -4
- package/vibe-math-v2/agent.cordis.yml +36 -14
- package/vibe-math-v2/math-computation.js +936 -0
- package/vibe-math-v2/math-engines.js +156 -0
- package/vibe-math-v2/vibe-math-v2.js +1653 -48
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v3/agent.cordis.yml +30 -12
- package/vibe-math-v3/math-computation.js +936 -0
- package/vibe-math-v3/math-engines.js +156 -0
- package/vibe-math-v3/vibe-math-v3.js +1623 -53
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v4/agent.cordis.yml +44 -8
- package/vibe-math-v4/math-computation.js +936 -0
- package/vibe-math-v4/math-engines.js +156 -0
- package/vibe-math-v4/vibe-math-v4.js +1592 -37
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v5/agent.cordis.yml +46 -10
- package/vibe-math-v5/math-computation.js +936 -0
- package/vibe-math-v5/math-engines.js +156 -0
- package/vibe-math-v5/vibe-math-v5.js +2233 -42
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +153 -4
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
|
@@ -18,6 +18,24 @@
|
|
|
18
18
|
// VibeMath_State/ — scheduler private state (checkpoint/resume)
|
|
19
19
|
export const name = 'vibe-math-v2'
|
|
20
20
|
export const inject = ['subagents', 'agents', 'fs', 'tools', 'commands']
|
|
21
|
+
// Lean 增量/异步(规格:docs/formal-verification.md §1):内容哈希用于归档去重与作业幂等。
|
|
22
|
+
// `node:crypto` 与 v4/v5 同一写法(只读的内置模块,不执行任何动态代码)。
|
|
23
|
+
import { createHash } from 'node:crypto'
|
|
24
|
+
// 数学计算工具(共享模块,四套字节一致;由 integration owner 维护,本文件只做接线)。
|
|
25
|
+
// 模块内部自己 import './math-engines.js',接线方不直接引用引擎表。
|
|
26
|
+
import {
|
|
27
|
+
registerMathComputation,
|
|
28
|
+
MATH_PARAM_NAMES,
|
|
29
|
+
MATH_PARAM_DEFAULTS,
|
|
30
|
+
MATH_TOOL_DESCRIPTION,
|
|
31
|
+
MATH_TOOL_SCHEMA,
|
|
32
|
+
MATH_ARCHIVE_WORKFLOW_LINE,
|
|
33
|
+
MATH_PERSONA_TOOL_LINE,
|
|
34
|
+
MATH_RULE_LINES,
|
|
35
|
+
normalizeMathParams,
|
|
36
|
+
mathAvailabilityLine,
|
|
37
|
+
probeMathEngines,
|
|
38
|
+
} from './math-computation.js'
|
|
21
39
|
|
|
22
40
|
// Standing mount: DSH mounts each agent preset ONCE per preset and joins every
|
|
23
41
|
// session that names it to that SAME plugin instance (see @deepseek-ai/dsh-agent-presets).
|
|
@@ -164,6 +182,24 @@ export function apply(ctx) {
|
|
|
164
182
|
leanCommand: 'lean', // 要执行的 Lean 可执行文件(例:'lake')
|
|
165
183
|
leanArgs: [], // 插在文件名之前的附加参数(例:['env','lean'] 配合 leanCommand='lake')
|
|
166
184
|
leanTimeoutMs: 120000, // 单次 Lean 运行的超时上限(毫秒)
|
|
185
|
+
leanAsync: true, // true = Lean 编译走后台队列(入队即返回)| false = 同步 await(今天的语义)
|
|
186
|
+
leanJobsMaxParallel: 1, // 后台编译并发上限(默认 1 = 串行;可调大以并行编译)
|
|
187
|
+
leanInitiative: 'normal', // 日常流程中的形式化主动性:off | normal | eager(与 formalVerify 的"验证要求强度"是两件事)
|
|
188
|
+
leanSearchPaths: [], // 额外搜索路径(默认空 = 只用自动注入的 VibeMath 根);非空时先注入它们、再注入自动根
|
|
189
|
+
// ---- 最终论文(规格:docs/final-paper.md;v2/v3 为单作者变体)----
|
|
190
|
+
finalPaper: true, // 收口时是否自动撰写最终论文(false 只关自动触发;/vibe paper 仍可用)
|
|
191
|
+
paperFormat: 'both', // both = md + tex | md = 只写 markdown | tex = 只写 latex
|
|
192
|
+
paperLanguage: 'zh', // zh = 中文(ctexart/xelatex 优先)| en = 英文(article/pdflatex 优先)
|
|
193
|
+
paperCompilePdf: true, // 检测到 LaTeX 时是否编译 paper.pdf
|
|
194
|
+
paperLatexCommand: '', // 指定 LaTeX 引擎(空 = 按语言探测 xelatex/latexmk/pdflatex/lualatex/tectonic)
|
|
195
|
+
// ---- 数学计算 math_computation(规格:docs/math-computation.md;六参数已冻结,拼写不得改)----
|
|
196
|
+
// 值取共享模块的 MATH_PARAM_DEFAULTS(四套逐字一致);数组必须**拷贝**,否则四套会共享同一个默认数组。
|
|
197
|
+
mathComputation: MATH_PARAM_DEFAULTS.mathComputation, // off | auto | on(auto = 探测到任一允许引擎才工作)
|
|
198
|
+
mathMode: MATH_PARAM_DEFAULTS.mathMode, // typed | typed+shell(后者才允许提示词里的 shell 兜底段)
|
|
199
|
+
mathEngines: MATH_PARAM_DEFAULTS.mathEngines.slice(), // 允许的引擎(含 cli;cli 默认开启,受 mathMode/mathEngines 双闸)
|
|
200
|
+
mathTimeoutMs: MATH_PARAM_DEFAULTS.mathTimeoutMs, // 单次计算超时上限(毫秒,下界 1000)
|
|
201
|
+
mathPackages: MATH_PARAM_DEFAULTS.mathPackages.slice(), // 需要预检的包(缺包只报告 + 给安装计划)
|
|
202
|
+
mathInstallScope: MATH_PARAM_DEFAULTS.mathInstallScope, // user | system(system 只对当次显式调用生效,永不记忆)
|
|
167
203
|
}
|
|
168
204
|
let params = Object.assign({}, DEFAULT_PARAMS)
|
|
169
205
|
let scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null } // activeCount 由 activeCount() 从 agentRegistry 推导,不再作为字段
|
|
@@ -283,7 +319,23 @@ export function apply(ctx) {
|
|
|
283
319
|
{ name: 'formalVerify', type: 'enum', options: ['off', 'encourage', 'require'], description: 'Lean 形式化验证档位:off=不额外要求(默认,提示词里不出现 Lean);encourage=鼓励按实现难度自行形式化,一旦 Lean 通过则验证重点转为「忠实性审查」;require=同 encourage 且加门禁——对象的 formal.status 未达到 passed/blocked 前,真/假裁定记为未定论(原因 formal-required)并写入 Formal/TODO.md', suggestion: 'off' },
|
|
284
320
|
{ name: 'leanCommand', type: 'string', description: '要执行的 Lean 可执行文件(默认 lean;用 lake 时配合 leanArgs=["env","lean"])', suggestion: 'lean' },
|
|
285
321
|
{ name: 'leanArgs', type: 'string[]', description: '插在 .lean 文件名之前的附加命令行参数(默认空)', suggestion: [] },
|
|
286
|
-
{ name: 'leanTimeoutMs', type: 'integer', description: '单次 Lean 运行的超时上限(毫秒,默认 120000,最小 1000)', suggestion: 120000 },
|
|
322
|
+
{ name: 'leanTimeoutMs', type: 'integer', description: '单次 Lean 运行的超时上限(毫秒,默认 120000,最小 1000);异步档下它同时是**每个后台编译作业**的预算(到时主动 terminate,作业记 timeout、对象留在 attempted)', suggestion: 120000 },
|
|
323
|
+
{ name: 'leanAsync', type: 'boolean', description: 'Lean 编译模式:true(默认)= 后台队列,vibe_math_lean_run / vibe_math_lean_archive{run:true} 立即返回 async.jobId 入队,成员不阻塞,结果由下一轮提示的【形式化结果】行与 vibe_math_lean_lib / vibe_math_lean_job 公告(**只有作业落地为 ok 才会置 passed 并写归档证明**);false = 完全同步 await(与旧行为逐字一致)', suggestion: true },
|
|
324
|
+
{ name: 'leanJobsMaxParallel', type: 'integer', description: '后台 Lean 编译的并发上限(默认 1 = 串行,保持可预测的资源占用;调大可并行编译多个作业)', suggestion: 1 },
|
|
325
|
+
{ name: 'leanInitiative', type: 'string', enum: LEAN_INITIATIVE_MODES.slice(), description: '日常流程中的形式化主动性:off(不主动,只在验证提示词按 formalVerify 的要求做)| normal(默认:顺手把有价值且可能复用的东西形式化)| eager(更主动:日常就主动把有价值的小引理/命题/定义形式化)。注意它与 formalVerify(验证时的要求强度:off|encourage|require)是**两件事**', suggestion: 'normal' },
|
|
326
|
+
{ name: 'leanSearchPaths', type: 'array', items: { type: 'string' }, description: '额外 Lean 搜索路径(默认空数组 = 只用框架自动注入的 VibeMath 根)。非空时按顺序先注入这里给的路径、再注入自动根(去重);若 leanArgs 里已显式给了 --search-path/-R/--root,则完全尊重用户配置、不注入任何东西', suggestion: [] },
|
|
327
|
+
{ name: 'finalPaper', type: 'boolean', description: '收口(严格终止)时自动撰写最终论文:派遣一名专职「论文撰写」子代理,把已检验通过的命题/解法/成果整理成 Paper/<项目>/{paper.md,paper.tex,paper.meta.json,paper.log.md}。false = 只关自动触发,/vibe paper 手动命令仍可用', suggestion: true },
|
|
328
|
+
{ name: 'paperFormat', type: 'enum', options: ['both', 'md', 'tex'], description: '论文产出格式:both = markdown + latex;md = 只写 paper.md;tex = 只写 paper.tex(tex 才会尝试编译 pdf)', suggestion: 'both' },
|
|
329
|
+
{ name: 'paperLanguage', type: 'enum', options: ['zh', 'en'], description: '论文语言:zh = 中文(LaTeX 用 ctexart,引擎优先 xelatex);en = 英文(article,引擎优先 pdflatex/latexmk)', suggestion: 'zh' },
|
|
330
|
+
{ name: 'paperCompilePdf', type: 'boolean', description: '检测到 LaTeX 时是否编译 paper.pdf(-interaction=nonstopmode 跑两遍;失败先尝试修复:换引擎/去不支持宏包/最小模板)。false 或无 LaTeX 时只保留 tex+md 并记日志', suggestion: true },
|
|
331
|
+
{ name: 'paperLatexCommand', type: 'string', description: '指定 LaTeX 引擎可执行文件(空 = 按语言探测:中文 xelatex > latexmk > pdflatex > lualatex > tectonic;英文 pdflatex 优先)。该命令解析不到时按"未检测到"降级(只留 tex+md)', suggestion: '' },
|
|
332
|
+
// ---- 数学计算 math_computation(六参数冻结;描述与 prompts.md §1/§6 口径一致)----
|
|
333
|
+
{ name: 'mathComputation', type: 'enum', options: ['off', 'auto', 'on'], description: '数学计算总开关:off = 真无操作(提示词零提及);auto = 探测到 mathEngines 里任一允许引擎才工作;on = 同上(探测失败时工具仍返回可执行的安装指引,而不是假装可用)', suggestion: 'auto' },
|
|
334
|
+
{ name: 'mathMode', type: 'enum', options: ['typed', 'typed+shell'], description: '计算策略:typed+shell(默认)= 工具不可用时允许宿主 shell 兜底,但结论必须标注"未经工具归档(shell 路径)";typed = 只用工具路径(提示词里不出现 shell 兜底段,engine:"cli" 返回 REFUSED{reason:policy})', suggestion: 'typed+shell' },
|
|
335
|
+
{ name: 'mathEngines', type: 'string[]', description: '允许的引擎列表(默认 python|r|octave|julia|matlab|maple|wolfram|cli)。cli 默认开启且走同一套超时/输出上限/回执;从列表里移除某引擎即禁用(商业引擎只探测+许可,永不安装)', suggestion: MATH_PARAM_DEFAULTS.mathEngines.slice() },
|
|
336
|
+
{ name: 'mathTimeoutMs', type: 'integer', description: '单次数学计算的超时上限(毫秒,默认 60000,最小 1000);到时主动 terminate 并把回执标为 MATH_TIMEOUT', suggestion: 60000 },
|
|
337
|
+
{ name: 'mathPackages', type: 'string[]', description: '需要预检的包(默认空)。缺包只报告 + 给"用户自装指引"或"代理代装计划",不会执行脚本,也永不自动安装', suggestion: [] },
|
|
338
|
+
{ name: 'mathInstallScope', type: 'string', enum: ['user', 'system'], description: '安装作用域:user(默认,用户级目录);system 只对当次显式调用生效、永不记忆(不会写进状态文件)', suggestion: 'user' },
|
|
287
339
|
]
|
|
288
340
|
|
|
289
341
|
// ================= fs =================
|
|
@@ -348,6 +400,19 @@ export function apply(ctx) {
|
|
|
348
400
|
if (isWindows()) return 'Remove-Item -Force -LiteralPath ' + psQuote(path) + ' -ErrorAction SilentlyContinue'
|
|
349
401
|
return 'rm -f ' + shQuote(path)
|
|
350
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* `subprocess.spawn` 的契约返回**句柄**(带 `done`),但桩宿主/测试替身常常写成 `async spawn(...)`
|
|
405
|
+
* ——那样返回的是 Promise,`handle.done` 是 undefined,`await handle.done` 得到 undefined,随后
|
|
406
|
+
* `outcome.exitCode` 抛 "Cannot read properties of undefined (reading 'exitCode')",被 catch 成一条
|
|
407
|
+
* 误导性的 "mkdir 失败"(实测:第二个会话建项目目录时刷出这条)。
|
|
408
|
+
* 这里统一兼容一次:Promise 就先 await,句柄缺 `done` 才如实报错。
|
|
409
|
+
*/
|
|
410
|
+
async function spawnHandle(subprocess, spec) {
|
|
411
|
+
const raw = subprocess.spawn(spec)
|
|
412
|
+
const handle = (raw && typeof raw.then === 'function') ? await raw : raw
|
|
413
|
+
if (!handle || handle.done === undefined) throw new Error('subprocess.spawn returned no handle.done')
|
|
414
|
+
return handle
|
|
415
|
+
}
|
|
351
416
|
async function runShell(script, cwd) {
|
|
352
417
|
const subprocess = subprocessOf()
|
|
353
418
|
if (subprocess === undefined) return { ok: false, error: 'no-subprocess' }
|
|
@@ -355,9 +420,9 @@ export function apply(ctx) {
|
|
|
355
420
|
const argv = isWindows()
|
|
356
421
|
? ['powershell', '-NoProfile', '-NonInteractive', '-Command', script]
|
|
357
422
|
: ['/bin/sh', '-c', script]
|
|
358
|
-
const handle = subprocess
|
|
423
|
+
const handle = await spawnHandle(subprocess, { argv: argv, cwd: cwd || workspaceRoot(), stdio: { stdin: 'ignore', stdout: 'inherit', stderr: 'inherit' }, graceMs: 20000 })
|
|
359
424
|
const outcome = await handle.done
|
|
360
|
-
return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode }
|
|
425
|
+
return { ok: !!outcome && outcome.exitCode === 0, exitCode: (outcome && outcome.exitCode !== undefined) ? outcome.exitCode : null }
|
|
361
426
|
} catch (e) { return { ok: false, error: String((e && e.message) || e) } }
|
|
362
427
|
}
|
|
363
428
|
/**
|
|
@@ -374,12 +439,13 @@ export function apply(ctx) {
|
|
|
374
439
|
shellWarned[key] = true
|
|
375
440
|
console.error('vibe-math-v2: ' + where + ' failed (' + why + ')')
|
|
376
441
|
}
|
|
377
|
-
// 目录布局(docs/formal-verification.md §3
|
|
378
|
-
// 项目内:Formal/(对象形式化工作文件 + Index.md + TODO.md)、Verified/Lean
|
|
442
|
+
// 目录布局(docs/formal-verification.md §3 / docs/math-computation.md §路径):
|
|
443
|
+
// 项目内:Formal/(对象形式化工作文件 + Index.md + TODO.md)、Verified/Lean/(归档证明)、
|
|
444
|
+
// Computation/(math_computation 的可复核回执:Computation/<runId>/ 与 Computation/installs/)
|
|
379
445
|
// 全局(**不在项目内**,跨项目复用):<VibeMath 根>/Formal/Lib/(可复用定义)、Formal/Proved/(已证引理)
|
|
380
446
|
async function ensureDirs() {
|
|
381
447
|
const base = frameworkRoot()
|
|
382
|
-
const dirs = ['qs', 'Propos', 'Reliable', 'Verified', 'Verified/Lean', 'Verification_logs', 'Progress_Logs', 'VibeMath_State', 'Formal']
|
|
448
|
+
const dirs = ['qs', 'Propos', 'Reliable', 'Verified', 'Verified/Lean', 'Verification_logs', 'Progress_Logs', 'VibeMath_State', 'Formal', 'Formal/Jobs', 'Computation']
|
|
383
449
|
const paths = [vibeRoot() + '/Projects', vibeRoot() + '/Formal/Lib', vibeRoot() + '/Formal/Proved'].concat(dirs.map(function (d) { return base + '/' + d }))
|
|
384
450
|
const r = await runShell(mkdirCmd(paths))
|
|
385
451
|
if (!r || !r.ok) warnShellOnce('ensureDirs (mkdir ' + base + ')', r)
|
|
@@ -392,6 +458,121 @@ export function apply(ctx) {
|
|
|
392
458
|
return r
|
|
393
459
|
}
|
|
394
460
|
|
|
461
|
+
// ================= 数学计算 math_computation:会话侧接线(共享模块,FREEZE §4/§5) =================
|
|
462
|
+
// 探测量(是否装了 python/r/…)是异步的,而提示词是同步构造的 ⇒ 这里维护一个会话级缓存,
|
|
463
|
+
// 在 init / 参数变化 / TTL 到期时刷新,提示词侧只读缓存现算(与 formalVerify 的动态纪律一致)。
|
|
464
|
+
const MATH_PROBE_TTL_MS = 15 * 60 * 1000
|
|
465
|
+
let mathProbe = null
|
|
466
|
+
let mathProbeAt = 0
|
|
467
|
+
/**
|
|
468
|
+
* `host.spawn` 适配:模块只给 `{argv, cwd, timeoutMs, stdoutCap, stderrCap}`,超时/终止由**接线方**负责
|
|
469
|
+
* (契约:超时必须 handle.terminate())。返回**完整** stdout/stderr——模块自己落盘完整版,只在返回体裁 64KB,
|
|
470
|
+
* 所以 stdio 上限要宽(默认 4MB),否则"超大输出"永远测不出完整落盘。
|
|
471
|
+
*/
|
|
472
|
+
async function mathSpawn(spec) {
|
|
473
|
+
const sub = subprocessOf()
|
|
474
|
+
if (sub === undefined || typeof sub.spawn !== 'function') return null // 模块映射为 MATH_NO_SUBPROCESS
|
|
475
|
+
const s = spec || {}
|
|
476
|
+
const cap = Math.max(1000, Number(s.timeoutMs) || Number(params.mathTimeoutMs) || 60000)
|
|
477
|
+
const outCap = Math.max(4096, Number(s.stdoutCap) || 4 * 1024 * 1024)
|
|
478
|
+
const errCap = Math.max(4096, Number(s.stderrCap) || 4 * 1024 * 1024)
|
|
479
|
+
const started = now()
|
|
480
|
+
let handle
|
|
481
|
+
try {
|
|
482
|
+
handle = await spawnHandle(sub, {
|
|
483
|
+
argv: s.argv,
|
|
484
|
+
cwd: s.cwd || frameworkRoot(),
|
|
485
|
+
stdio: { stdin: 'ignore', stdout: { maxBytes: outCap }, stderr: { maxBytes: errCap } },
|
|
486
|
+
graceMs: cap,
|
|
487
|
+
})
|
|
488
|
+
} catch (e) { return null }
|
|
489
|
+
let timedOut = false, killed = false, timer = null, outcome
|
|
490
|
+
try {
|
|
491
|
+
outcome = await Promise.race([
|
|
492
|
+
handle.done,
|
|
493
|
+
new Promise(function (resolve) {
|
|
494
|
+
timer = setTimeout(function () {
|
|
495
|
+
timedOut = true
|
|
496
|
+
killed = true
|
|
497
|
+
try { if (handle && typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* best effort */ }
|
|
498
|
+
resolve({ exitCode: null })
|
|
499
|
+
}, cap)
|
|
500
|
+
}),
|
|
501
|
+
])
|
|
502
|
+
} catch (e) { if (timer !== null) clearTimeout(timer); return null }
|
|
503
|
+
if (timer !== null) clearTimeout(timer)
|
|
504
|
+
let out = '', err = ''
|
|
505
|
+
try { if (handle.collected && handle.collected.stdout) out = handle.collected.stdout.readFrom(0).text } catch (e) { /* best effort */ }
|
|
506
|
+
try { if (handle.collected && handle.collected.stderr) err = handle.collected.stderr.readFrom(0).text } catch (e) { /* best effort */ }
|
|
507
|
+
return {
|
|
508
|
+
exit: (outcome && outcome.exitCode !== undefined) ? outcome.exitCode : null,
|
|
509
|
+
timedOut: timedOut, killed: killed, ms: now() - started,
|
|
510
|
+
stdout: String(out == null ? '' : out), stderr: String(err == null ? '' : err),
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** host.exists:项目根相对路径是否存在。 */
|
|
514
|
+
async function mathExists(rel) {
|
|
515
|
+
try { const t = await fsTarget(rel); const s = await fs.stat(t); return s !== undefined } catch (e) { return false }
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* host.listDir(**可选**,FREEZE §4):模块用它数 `Computation/` 下的 run 目录数(>200 只告警、永不删除)。
|
|
519
|
+
* 返回 DSH `fs.listDir` 的原样条目({name,type})——模块按 `type === 'directory'` 计数,
|
|
520
|
+
* 所以**不要**在这里把条目降级成字符串数组。宿主 fs 没有该能力时整个回调都不声明(见下面的条件字段),
|
|
521
|
+
* 否则模块每次运行都会去问一个不存在的服务。
|
|
522
|
+
*/
|
|
523
|
+
async function mathListDir(rel) {
|
|
524
|
+
try { const t = await fsTarget(rel); const ents = await fs.listDir(t); return Array.isArray(ents) ? ents : [] } catch (e) { return [] }
|
|
525
|
+
}
|
|
526
|
+
/** 会话级 host:参数是**活引用**(函数),回执里写 designator='vibe-math-v2'。 */
|
|
527
|
+
const mathHost = {
|
|
528
|
+
// 模块注册时只**交回** handler/description/parameters;真正的挂载是 mathTool 之后的**字面量**两行。
|
|
529
|
+
// (会话层写 handlers 表、apply 层挂宿主工具面;两条路径名字/描述/schema 完全一致。)
|
|
530
|
+
register: function (name, description, parameters, handler) { /* 挂载见 mathTool 之后的字面量两行 */ },
|
|
531
|
+
params: function () { return params },
|
|
532
|
+
projectRoot: function () { return frameworkRoot() },
|
|
533
|
+
designator: 'vibe-math-v2',
|
|
534
|
+
// 宿主明知没有 subprocess 服务时直接说 false ⇒ 模块立即返回 MATH_NO_SUBPROCESS(不再逐个探引擎)。
|
|
535
|
+
hasSubprocess: function () { const sub = subprocessOf(); return !!(sub && typeof sub.spawn === 'function') },
|
|
536
|
+
// **可选能力,按宿主实际情况声明**:宿主 fs 没有 listDir 就不给这个回调(模块据此跳过
|
|
537
|
+
// "每项目 run 目录数 >200" 的保留检查,而不是每次运行都白问一次)。
|
|
538
|
+
listDir: (typeof fs.listDir === 'function') ? mathListDir : undefined,
|
|
539
|
+
writeText: async function (rel, text) { return await writeText(rel, text) },
|
|
540
|
+
readText: async function (rel) { return await readText(rel) },
|
|
541
|
+
exists: mathExists,
|
|
542
|
+
resolveExecutable: async function (cmd) {
|
|
543
|
+
const sub = subprocessOf()
|
|
544
|
+
if (sub === undefined || typeof sub.resolveExecutable !== 'function') throw new Error('no subprocess service')
|
|
545
|
+
const p = await sub.resolveExecutable(String(cmd))
|
|
546
|
+
if (typeof p !== 'string' || !p) throw new Error('not found: ' + String(cmd))
|
|
547
|
+
return p
|
|
548
|
+
},
|
|
549
|
+
spawn: mathSpawn,
|
|
550
|
+
log: function (kind, msg) { logActivity('math', String(kind) + ': ' + String(msg)) },
|
|
551
|
+
}
|
|
552
|
+
// 模块只调用一次 host.register(...)(FREEZE §4)⇒ 上面那个回调就是会话层注册。
|
|
553
|
+
// 注意:真实注册必须等到 `const handlers = {}` 与 `function registerTool(...)` 就绪之后
|
|
554
|
+
// (`handlers` 是 const,提前调用会撞 TDZ)⇒ 这里只留槽位,注册放在会话工具表旁。
|
|
555
|
+
let mathTool = null
|
|
556
|
+
/** 刷新探测缓存。`mathComputation:'off'` ⇒ 不探测、不缓存、不注入(真 no-op)。 */
|
|
557
|
+
async function refreshMathProbe(force) {
|
|
558
|
+
if (params.mathComputation === 'off') { mathProbe = null; mathProbeAt = 0; return null }
|
|
559
|
+
if (!mathTool) return null
|
|
560
|
+
try { mathProbe = await mathTool.probe(force ? { refresh: true } : undefined); mathProbeAt = now() } catch (e) { mathProbe = null }
|
|
561
|
+
return mathProbe
|
|
562
|
+
}
|
|
563
|
+
/** 心跳里的 TTL 刷新(每拍一次布尔判断,成本可忽略)。 */
|
|
564
|
+
function mathProbeDue() { return params.mathComputation !== 'off' && (!mathProbe || (now() - mathProbeAt) >= MATH_PROBE_TTL_MS) }
|
|
565
|
+
/**
|
|
566
|
+
* 每轮可用性行(中文)。由模块的 `mathAvailabilityLine(probe,'zh',mathMode)` 按档位拼装:
|
|
567
|
+
* 只有 `typed+shell` 才含 shell 兜底句(`mathMode:'typed'` 天然不含,守卫据此断言)。
|
|
568
|
+
* `off` 档返回空串——提示词零提及(对齐 formalVerify:'off' 的真 no-op 纪律)。
|
|
569
|
+
*/
|
|
570
|
+
function mathWorkLine() {
|
|
571
|
+
if (params.mathComputation === 'off') return ''
|
|
572
|
+
if (!mathProbe) return '' // 还没探测过 ⇒ 不注入(不撒谎;init 会先探一次)
|
|
573
|
+
return '\n' + mathAvailabilityLine(mathProbe, 'zh', params.mathMode) + '\n'
|
|
574
|
+
}
|
|
575
|
+
|
|
395
576
|
// ================= settings =================
|
|
396
577
|
function sanitizeParams(obj) {
|
|
397
578
|
const out = {}
|
|
@@ -406,9 +587,14 @@ export function apply(ctx) {
|
|
|
406
587
|
maxParallelThreshold: 1, solverMaxRounds: 1, verifierCount: 2, debateMaxRounds: 1,
|
|
407
588
|
directionsPerSolver: 1, tickIntervalMs: 200, reportIntervalMs: 1000, maxExplorerRetries: 1,
|
|
408
589
|
}
|
|
590
|
+
// 数学计算六参数的显式强制(FREEZE §5.3):交给共享模块的 normalizeMathParams,它按
|
|
591
|
+
// enum/array/integer 逐键判型——`'false'`/字符串/错误枚举一律回退默认值,**绝不**落到
|
|
592
|
+
// 下面 `else { out[k] = v }` 的尾巴(那会把任意字符串当合法值放行)。
|
|
593
|
+
const mathNorm = normalizeMathParams(obj)
|
|
409
594
|
for (const k of Object.keys(DEFAULT_PARAMS)) {
|
|
410
595
|
if (!(k in obj)) continue
|
|
411
596
|
const v = obj[k]
|
|
597
|
+
if (MATH_PARAM_NAMES.indexOf(k) !== -1) { out[k] = Object.prototype.hasOwnProperty.call(mathNorm, k) ? mathNorm[k] : DEFAULT_PARAMS[k]; continue }
|
|
412
598
|
if (intFields.indexOf(k) !== -1) {
|
|
413
599
|
const n = Number(v)
|
|
414
600
|
if (!Number.isFinite(n)) { out[k] = DEFAULT_PARAMS[k]; continue }
|
|
@@ -425,12 +611,30 @@ export function apply(ctx) {
|
|
|
425
611
|
else if (k === 'priorityAdjust') { out[k] = (v === 'none' || v === 'deadend-deprioritize' || v === 'survival-map') ? v : DEFAULT_PARAMS[k] }
|
|
426
612
|
else if (k === 'proposPriorityAdjust') { out[k] = (v === 'none' || v === 'progress-graded') ? v : DEFAULT_PARAMS[k] }
|
|
427
613
|
else if (k === 'solverAllowNetwork' || k === 'verifierAllowNetwork' || k === 'solverAllowScripts' || k === 'verifierAllowScripts') { out[k] = (v === true || v === false || v === '') ? v : DEFAULT_PARAMS[k] }
|
|
614
|
+
else if (k === 'finalPaper' || k === 'paperCompilePdf') { out[k] = (v === true || v === false) ? v : DEFAULT_PARAMS[k] }
|
|
615
|
+
// leanAsync 必须**显式**归一化:`else { out[k] = v }` 会把字符串 'false' 当真理放行(spec §1.1)。
|
|
616
|
+
else if (k === 'leanAsync') { out[k] = (v === true || v === false) ? v : DEFAULT_PARAMS[k] }
|
|
617
|
+
else if (k === 'leanInitiative') { out[k] = LEAN_INITIATIVE_MODES.indexOf(v) !== -1 ? v : DEFAULT_PARAMS[k] }
|
|
618
|
+
else if (k === 'leanJobsMaxParallel') {
|
|
619
|
+
const n = Number(v)
|
|
620
|
+
out[k] = Number.isFinite(n) ? Math.max(1, Math.min(8, Math.floor(n))) : DEFAULT_PARAMS[k]
|
|
621
|
+
}
|
|
622
|
+
// leanSearchPaths:显式数组分支(字符串/非数组一律回退默认空数组),并去掉空串与重复项。
|
|
623
|
+
else if (k === 'leanSearchPaths') {
|
|
624
|
+
if (!Array.isArray(v)) { out[k] = DEFAULT_PARAMS[k]; continue }
|
|
625
|
+
const seen = {}
|
|
626
|
+
out[k] = v.map(function (x) { return String(x == null ? '' : x).trim() }).filter(function (x) { if (!x || seen[x]) return false; seen[x] = true; return true })
|
|
627
|
+
}
|
|
628
|
+
else if (k === 'paperFormat') { out[k] = (v === 'both' || v === 'md' || v === 'tex') ? v : DEFAULT_PARAMS[k] }
|
|
629
|
+
else if (k === 'paperLanguage') { out[k] = (v === 'zh' || v === 'en') ? v : DEFAULT_PARAMS[k] }
|
|
428
630
|
else { out[k] = v }
|
|
429
631
|
}
|
|
430
632
|
// 非正的 leanTimeoutMs 会让每次 Lean 运行立即"超时",而超时被记成"未通过"——静默失效。
|
|
431
633
|
// 删除该键(回退默认),而不是抬到最小值,与 v5/v4 的既有处理一致。
|
|
432
634
|
if (out.leanTimeoutMs !== undefined && !(out.leanTimeoutMs > 0)) delete out.leanTimeoutMs
|
|
433
635
|
if (out.leanTimeoutMs !== undefined) out.leanTimeoutMs = Math.max(1000, out.leanTimeoutMs)
|
|
636
|
+
// 空白的 paperLatexCommand 会让 resolveExecutable('') 直接失败;回退默认(自动探测)。
|
|
637
|
+
if (out.paperLatexCommand !== undefined && !String(out.paperLatexCommand).trim()) out.paperLatexCommand = DEFAULT_PARAMS.paperLatexCommand
|
|
434
638
|
// 未知档位一律回退 'off'(无操作),绝不回退到更强的档位:一个拼写错误若静默启用强制形式化,
|
|
435
639
|
// 会让所有结论被门禁拦下。
|
|
436
640
|
if (out.formalVerify !== undefined && ['off', 'encourage', 'require'].indexOf(String(out.formalVerify)) === -1) out.formalVerify = DEFAULT_PARAMS.formalVerify
|
|
@@ -633,7 +837,6 @@ export function apply(ctx) {
|
|
|
633
837
|
* (无服务 / 无可执行文件 / spawn 失败 / 超时 / 非零退出)都变成可读结果。
|
|
634
838
|
*/
|
|
635
839
|
async function leanRunFile(relPath, timeoutMs) {
|
|
636
|
-
const started = now()
|
|
637
840
|
const rel = String(relPath || '').trim()
|
|
638
841
|
if (!rel) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'file is required' }
|
|
639
842
|
// 路径守卫:只允许执行 VibeMath 树内的文件,构造出来的路径不能让我们跑工作区之外的东西。
|
|
@@ -641,21 +844,35 @@ export function apply(ctx) {
|
|
|
641
844
|
if (abs === null) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'Lean 文件必须位于 ' + vibeRoot() + '/ 之内(收到 ' + rel + ')' }
|
|
642
845
|
if (!/\.lean$/.test(abs)) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: '只有 .lean 文件可以执行' }
|
|
643
846
|
if (await readTextAbs(abs) === undefined) return { ok: false, code: 'V2_NOT_FOUND', message: 'no such file: ' + rel }
|
|
847
|
+
return await leanExecFile(rel, abs, timeoutMs)
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* 已解析路径的**实际执行**(同步档与后台作业共用同一段:argv 注入、超时 terminate、输出截断)。
|
|
851
|
+
* 搜索路径注入(spec §3):`[exe, ...用户 leanArgs, --search-path <VibeMath 根>, <file>]`;
|
|
852
|
+
* 用户已显式给过 `--search-path`/`-R`/`--root` 就不再注入(显式覆盖优先)。
|
|
853
|
+
*/
|
|
854
|
+
async function leanExecFile(rel, abs, timeoutMs, plan, onHandle) {
|
|
855
|
+
const started = now()
|
|
644
856
|
const sub = subprocessOf()
|
|
645
857
|
if (sub === undefined || typeof sub.spawn !== 'function') {
|
|
646
858
|
return { ok: false, code: 'NO_SUBPROCESS', message: 'the host exposes no subprocess service; Lean cannot be executed here', file: rel, ms: 0 }
|
|
647
859
|
}
|
|
648
860
|
const cap = Math.max(1000, Number(timeoutMs) || Number(params.leanTimeoutMs) || 120000)
|
|
861
|
+
// 构建计划:异步作业用**入队时冻结**的计划(修订 §4:结果只对那个上下文有效);同步档现算。
|
|
862
|
+
const p = plan || leanBuildPlan()
|
|
649
863
|
let exe
|
|
650
864
|
try {
|
|
651
|
-
exe = await sub.resolveExecutable(String(
|
|
865
|
+
exe = await sub.resolveExecutable(String(p.engine || 'lean'))
|
|
652
866
|
} catch (e) {
|
|
653
|
-
return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(
|
|
867
|
+
return { ok: false, code: 'LEAN_NOT_FOUND', message: 'cannot resolve "' + String(p.engine || 'lean') + '": ' + String((e && e.message) || e) + ' —— 仍可把形式化代码写下来归档,但无法在此宿主上执行', file: rel, ms: now() - started }
|
|
654
868
|
}
|
|
655
|
-
const
|
|
869
|
+
const inject = Array.isArray(p.inject) ? p.inject : leanSearchPathPlan(p.args, p.searchPaths, vibeRoot()).inject
|
|
870
|
+
const argv = [exe].concat((Array.isArray(p.args) ? p.args : []).map(String)).concat(inject).concat([abs])
|
|
871
|
+
const searchPath = (Array.isArray(p.searchPaths) && p.searchPaths.length) ? p.searchPaths.join(',') : vibeRoot()
|
|
872
|
+
logActivity('formal', 'Lean 运行 ' + rel + '(search-path=' + searchPath + ')')
|
|
656
873
|
let handle
|
|
657
874
|
try {
|
|
658
|
-
handle = sub
|
|
875
|
+
handle = await spawnHandle(sub, {
|
|
659
876
|
argv: argv,
|
|
660
877
|
cwd: frameworkRoot(),
|
|
661
878
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } },
|
|
@@ -664,6 +881,8 @@ export function apply(ctx) {
|
|
|
664
881
|
} catch (e) {
|
|
665
882
|
return { ok: false, code: 'LEAN_SPAWN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
|
|
666
883
|
}
|
|
884
|
+
leanSyncHandle = handle
|
|
885
|
+
if (typeof onHandle === 'function') { try { onHandle(handle) } catch (e) { /* best effort */ } }
|
|
667
886
|
// 超时必须有**主动**兜底:`graceMs` 只是宿主侧的宽限,契约 §7 明确要求"对超时调用
|
|
668
887
|
// handle.terminate()"。此前 v2 只依赖 graceMs,从不终止进程:一个卡住的 Lean 会继续占着
|
|
669
888
|
// 资源,而框架已经报了超时——它与自己的契约不一致。这里与 `handle.done` 竞速:计时器到点
|
|
@@ -687,6 +906,8 @@ export function apply(ctx) {
|
|
|
687
906
|
return { ok: false, code: 'LEAN_RUN_FAILED', message: String((e && e.message) || e), file: rel, ms: now() - started }
|
|
688
907
|
}
|
|
689
908
|
if (timer !== null) clearTimeout(timer)
|
|
909
|
+
leanSyncHandle = null
|
|
910
|
+
if (typeof onHandle === 'function') { try { onHandle(null) } catch (e) { /* best effort */ } }
|
|
690
911
|
let out = '', err = ''
|
|
691
912
|
try { if (handle.collected && handle.collected.stdout) out = handle.collected.stdout.readFrom(0).text } catch (e) { /* best effort */ }
|
|
692
913
|
try { if (handle.collected && handle.collected.stderr) err = handle.collected.stderr.readFrom(0).text } catch (e) { /* best effort */ }
|
|
@@ -699,14 +920,15 @@ export function apply(ctx) {
|
|
|
699
920
|
// 输出截断到 ~4KB 再入库(避免把巨大的编译器输出写进状态)。
|
|
700
921
|
return {
|
|
701
922
|
ok: ok, exitCode: exitCode, signal: (outcome && outcome.signal) || null, ms: ms,
|
|
702
|
-
command: argv.join(' '), file: rel,
|
|
923
|
+
command: argv.join(' '), file: rel, searchPath: searchPath,
|
|
703
924
|
stdout: tailText(out, 4000), stderr: tailText(err, 4000),
|
|
704
925
|
timedOut: isTimeout,
|
|
705
926
|
code: ok ? undefined : (isTimeout ? 'LEAN_TIMEOUT' : 'LEAN_FAILED'),
|
|
706
927
|
}
|
|
707
928
|
}
|
|
708
929
|
// 把一次运行结果写进对象的形式化记录(不提升 status,状态迁移见契约 §4)。
|
|
709
|
-
|
|
930
|
+
// `asyncInfo`(可选):异步档把作业 id/state/attempts/落地时刻贴在记录上,供人对照(§1.4)。
|
|
931
|
+
async function formalSetRun(target, run, asyncInfo) {
|
|
710
932
|
const t = safeId(String(target || ''))
|
|
711
933
|
if (!t) return
|
|
712
934
|
const prev = formalOf(t)
|
|
@@ -714,6 +936,7 @@ export function apply(ctx) {
|
|
|
714
936
|
status: prev.status === 'passed' ? 'passed' : (prev.status === 'blocked' ? 'blocked' : 'attempted'),
|
|
715
937
|
file: run.file || prev.file || '',
|
|
716
938
|
run: { at: now(), ok: !!run.ok, exitCode: run.exitCode === undefined ? null : run.exitCode, ms: run.ms || 0, stdoutTail: tailText(run.stdout, 800), stderrTail: tailText(run.stderr, 800) },
|
|
939
|
+
async: asyncInfo || prev.async || null,
|
|
717
940
|
updatedAt: now(),
|
|
718
941
|
}))
|
|
719
942
|
}
|
|
@@ -882,7 +1105,7 @@ export function apply(ctx) {
|
|
|
882
1105
|
L.push(' 请复核这个判断是否成立;若你认为其实可以形式化,请指出来并动手做。')
|
|
883
1106
|
} else {
|
|
884
1107
|
L.push(' · 请先判断该对象的**实现难度**:若能在可接受的工作量内形式化,优先写 Lean 代码并执行。')
|
|
885
|
-
L.push(' · 工具:vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib
|
|
1108
|
+
L.push(' · 工具:vibe_math_lean_run(执行)· vibe_math_lean_archive(归档)· vibe_math_lean_lib(查已有可复用库/jobs)· vibe_math_lean_read(取回归档原文)')
|
|
886
1109
|
L.push(' · 工作目录:Formal/(相对项目根);可复用定义放 ' + vroot + '/Formal/Lib/,已证引理放 '
|
|
887
1110
|
+ vroot + '/Formal/Proved/;写之前先 vibe_math_lean_lib 查重。')
|
|
888
1111
|
L.push(' · **一旦 Lean 通过,你唯一需要确认的就是忠实性**:定义/对象/条件/假设/结论是否与命题原文逐条一致。'
|
|
@@ -895,19 +1118,42 @@ export function apply(ctx) {
|
|
|
895
1118
|
L.push(' · 若你判断不值得或无法形式化,可以不做,但请在回执的 formal 字段写明难度判断(decision=\'blocked\' 时必须写明 note)。')
|
|
896
1119
|
}
|
|
897
1120
|
L.push(' · 归档可复用定义/引理前先跑通(vibe_math_lean_archive run=true 或先 vibe_math_lean_run);跑不通不要入库。')
|
|
1121
|
+
// —— §5 B(逐字):三条筛选判据 + 先查再写 + 异步"落地前不得当已通过" ——
|
|
1122
|
+
L.push(' · 三条筛选判据:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。')
|
|
1123
|
+
L.push(' · 写新定义/证明前**先 vibe_math_lean_lib 查已有库**(vibe_math_lean_read 可取回归档原文逐字复用),查不到再写;'
|
|
1124
|
+
+ '复用已归档内容用 `import Formal.Lib.<name>` / `import Formal.Proved.<name>`。')
|
|
1125
|
+
L.push(' · **没把握就记 blocked** 并写清难点,别用形式化掩盖不确定。')
|
|
1126
|
+
L.push(' · 该对象若已有后台编译在队列中(leanAsync 默认开启),**不得**在作业落地为通过之前声称已通过或转忠实性审查;'
|
|
1127
|
+
+ '等 vibe_math_lean_lib 的 jobs 显示 settled 再审。')
|
|
898
1128
|
L.push(' · 宿主没有 Lean 工具链(LEAN_NOT_FOUND)或宿主不提供 subprocess 服务(NO_SUBPROCESS)时:把代码写下来归档,并在回执的 note 里写明'
|
|
899
1129
|
+ '"宿主无 Lean 工具链"——这算显式阻塞原因,定论门禁可以据此放行。')
|
|
900
1130
|
}
|
|
901
|
-
|
|
1131
|
+
// 后台作业的一次性公告(spec §2.4):注入到本轮提示里,取走即清空。
|
|
1132
|
+
// 数学计算可用性行(prompts.md §2):验证轮同样告知"先 probe 再 run / 回执即引用"。
|
|
1133
|
+
return L.join('\n') + leanNoticeSection() + mathWorkLine()
|
|
902
1134
|
}
|
|
903
1135
|
function formalWorkLine() {
|
|
904
|
-
|
|
905
|
-
// `
|
|
906
|
-
//
|
|
907
|
-
|
|
1136
|
+
// 修订 §1 + v5 统一口径:**日常主动性(leanInitiative)与验证时的要求强度(formalVerify)是两根
|
|
1137
|
+
// 独立的轴**。所以这里不能用 `!formalOn()` 一票否决——那会让 `leanInitiative:'eager'` 在
|
|
1138
|
+
// `formalVerify:'off'` 下静默失效("定论不额外要求,但日常鼓励形式化"组合必须真的成立)。
|
|
1139
|
+
// `formalVerify:'off'` 只关掉**验证阶段**的 Lean 文本:那里仍是 `formalPromptBlock` 的
|
|
1140
|
+
// `!formalOn() ⇒ ''`。`leanInitiative:'off'` 则连日线也不注入(真无操作)。
|
|
1141
|
+
const initiative = LEAN_INITIATIVE_MODES.indexOf(params.leanInitiative) !== -1 ? params.leanInitiative : 'normal'
|
|
1142
|
+
if (initiative === 'off' || !(formalOn() || initiative === 'eager')) return ''
|
|
1143
|
+
return '【顺手形式化(' + (formalOn() ? (formalMode() === 'require' ? '强制' : '鼓励') : '仅主动性') + '·主动性 ' + initiative + ')】把你工作中常用或可能复用的对象、假设、'
|
|
908
1144
|
+ '新定义用 Lean 形式化定义并归档到全局可复用库(vibe_math_lean_archive kind=\'def\'),已成立的引理归到 '
|
|
909
1145
|
+ vibeRoot().replace(/\\/g, '/') + '/Formal/Proved/(kind=\'lemma\');写之前先 vibe_math_lean_lib 查重,避免重复定义。'
|
|
910
1146
|
+ '归档前先跑通(vibe_math_lean_run 或 run=true);跑不通的定义不要进可复用库。'
|
|
1147
|
+
+ (initiative === 'eager'
|
|
1148
|
+
? '\n · **主动档(leanInitiative=eager)**:日常就主动把有价值的小引理/命题/定义形式化——每轮工作结束时审视一次"这轮有什么值得进库",值得就顺手归档。'
|
|
1149
|
+
: '\n · 主动性 normal:顺手把明显有价值且可能复用的东西形式化;不必刻意扩大范围。')
|
|
1150
|
+
// —— §5 A(逐字):三条筛选判据 + 先查再写 + 「没把握就记 blocked」 + 异步"落地前不得当已通过" ——
|
|
1151
|
+
+ '\n · 三条筛选判据:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)。'
|
|
1152
|
+
+ '\n · **先 vibe_math_lean_lib 查再决定是否重写**:vibe_math_lean_lib 列出现成定义/引理,vibe_math_lean_read 可取回归档原文逐字复用;'
|
|
1153
|
+
+ '复用已归档内容用 `import Formal.Lib.<name>` / `import Formal.Proved.<name>`(模块根 = ' + vibeRoot().replace(/\\/g, '/') + ',框架已把它加进编译搜索路径)。'
|
|
1154
|
+
+ '\n · **没把握就记 blocked**(vibe_math_lean_archive kind=\'blocked\' note=…,或回执 formal 的 blocked):把难点写清楚,别用形式化掩盖不确定。'
|
|
1155
|
+
+ '\n · 编译默认走后台队列(leanAsync=true):入队后你可以继续工作;用 vibe_math_lean_job(可 waitMs 等结果)或下一轮提示里的'
|
|
1156
|
+
+ '【形式化结果】行看结果。**在作业落地为通过之前,不得把该对象当成已通过。**'
|
|
911
1157
|
}
|
|
912
1158
|
/**
|
|
913
1159
|
* 回执契约里的 `formal` 字段(契约 §6.3)。**必须真的出现在回执契约里**:契约写了字段而框架
|
|
@@ -1079,6 +1325,298 @@ export function apply(ctx) {
|
|
|
1079
1325
|
return await absorbFormalReply(f, memberId)
|
|
1080
1326
|
}
|
|
1081
1327
|
|
|
1328
|
+
// ================= Lean 增量 + 异步:作业队列(spec §2) =================
|
|
1329
|
+
// 并发上限 = `leanJobsMaxParallel`(默认 1 = 串行)。队列是**内存**态,作业记录镜像到
|
|
1330
|
+
// <项目>/Formal/Jobs/<jobId>.json —— 崩溃恢复、lean_lib.jobs 与 lean_job 都读它。
|
|
1331
|
+
const LEAN_QUEUE_CONCURRENCY_MAX = 8
|
|
1332
|
+
const leanQueue = [] // FIFO:待跑作业
|
|
1333
|
+
const leanJobs = new Map() // jobId -> job(内存视图,含 state)
|
|
1334
|
+
const leanRunningHandles = new Map() // jobId -> 子进程 handle(dispose 时 terminate)
|
|
1335
|
+
let leanSyncHandle = null // 同步档在跑的 handle(同样能被 terminate)
|
|
1336
|
+
const leanPendingNotices = [] // 一次性公告行(注入下一次 round prompt,spec §2.4)
|
|
1337
|
+
/** 当前会话的构建计划(修订 §2/§4):冻结 engine/args/搜索路径,作业与指纹都用它。 */
|
|
1338
|
+
function leanBuildPlan() {
|
|
1339
|
+
const userArgs = (Array.isArray(params.leanArgs) ? params.leanArgs : []).map(String)
|
|
1340
|
+
const plan = leanSearchPathPlan(userArgs, params.leanSearchPaths, vibeRoot())
|
|
1341
|
+
return { engine: String(params.leanCommand || 'lean'), args: userArgs, searchPaths: plan.paths, inject: plan.inject, explicit: plan.explicit }
|
|
1342
|
+
}
|
|
1343
|
+
function leanPlanContext(plan) { return leanBuildContext(plan.engine, plan.args, plan.searchPaths) }
|
|
1344
|
+
/** 归档作业当前应绑定的构建计划(def/lemma 与 proof 都是本项目计划)。 */
|
|
1345
|
+
function leanContextNow() { return leanPlanContext(leanBuildPlan()) }
|
|
1346
|
+
function leanJobsMaxParallel() { return Math.max(1, Math.min(LEAN_QUEUE_CONCURRENCY_MAX, Number(params.leanJobsMaxParallel) || 1)) }
|
|
1347
|
+
function leanActiveCount() { let n = 0; for (const j of leanJobs.values()) if (j.state === 'running') n++ ; return n }
|
|
1348
|
+
function leanSleep(ms) { return new Promise(function (r) { setTimeout(r, Math.max(0, Number(ms) || 0)) }) }
|
|
1349
|
+
function leanJobAbs(job) {
|
|
1350
|
+
const rel = String((job && job.rel) || '')
|
|
1351
|
+
if (!rel) return null
|
|
1352
|
+
return job.scope === 'root' ? normalizeAbsPath(vibeAbs(rel)) : normalizeAbsPath(frameworkRoot() + '/' + rel)
|
|
1353
|
+
}
|
|
1354
|
+
/** 作业的回执路径与归档路径(lean_job 返回它们,修订 §3)。 */
|
|
1355
|
+
function leanJobPaths(job) {
|
|
1356
|
+
const rec = { receipt: 'Formal/Jobs/' + job.jobId + '.json' }
|
|
1357
|
+
if (job.kind === 'archive' && job.archive && job.archive.kind === 'proof' && job.target) {
|
|
1358
|
+
rec.work = job.rel
|
|
1359
|
+
rec.archive = job.state === 'settled' ? ('Verified/Lean/' + job.target + '.lean') : ''
|
|
1360
|
+
} else if (job.kind === 'archive') rec.archive = job.rel
|
|
1361
|
+
else rec.file = job.rel
|
|
1362
|
+
return rec
|
|
1363
|
+
}
|
|
1364
|
+
function leanJobRecord(job) {
|
|
1365
|
+
return {
|
|
1366
|
+
jobId: job.jobId, kind: job.kind, state: job.state, attempts: Number(job.attempts || 1),
|
|
1367
|
+
target: job.target || '', name: job.name || '', rel: job.rel || '', scope: job.scope || 'project',
|
|
1368
|
+
sha256: job.sha || '', buildSha256: job.buildSha || '', buildContext: job.buildCtx || '',
|
|
1369
|
+
engine: (job.plan && job.plan.engine) || '', argv: (job.plan && job.plan.args) || [], searchPaths: (job.plan && job.plan.searchPaths) || [],
|
|
1370
|
+
archiveKind: (job.archive && job.archive.kind) || '',
|
|
1371
|
+
enqueuedAt: job.enqueuedAt || 0, startedAt: job.startedAt || 0, settledAt: job.settledAt || 0,
|
|
1372
|
+
exitCode: job.exitCode === undefined ? null : job.exitCode,
|
|
1373
|
+
timedOut: !!job.timedOut, interrupted: !!job.interrupted, note: job.note || '',
|
|
1374
|
+
project: currentProject,
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async function writeLeanJobFile(job) { await writeJson('Formal/Jobs/' + job.jobId + '.json', leanJobRecord(job)) }
|
|
1378
|
+
function hydrateLeanJob(rec) {
|
|
1379
|
+
return {
|
|
1380
|
+
jobId: String(rec.jobId), kind: rec.kind || 'run', target: rec.target || '', name: rec.name || '',
|
|
1381
|
+
rel: rec.rel || '', scope: rec.scope || 'project', sha: rec.sha256 || '',
|
|
1382
|
+
buildSha: rec.buildSha256 || '', buildCtx: rec.buildContext || '',
|
|
1383
|
+
plan: rec.engine !== undefined ? { engine: rec.engine || '', args: rec.argv || [], searchPaths: rec.searchPaths || [] } : null,
|
|
1384
|
+
archive: rec.archiveKind ? { kind: rec.archiveKind, target: rec.target || '', name: rec.name || '' } : null,
|
|
1385
|
+
state: rec.state || 'queued', attempts: Number(rec.attempts || 1), enqueuedAt: Number(rec.enqueuedAt || 0),
|
|
1386
|
+
startedAt: Number(rec.startedAt || 0), settledAt: Number(rec.settledAt || 0),
|
|
1387
|
+
exitCode: rec.exitCode === undefined ? null : rec.exitCode, timedOut: !!rec.timedOut,
|
|
1388
|
+
interrupted: !!rec.interrupted, run: rec.run || null, note: rec.note || '',
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
/** 入队:同 jobId 已在队列/在跑 ⇒ 复用(幂等);返回作业对象。 */
|
|
1392
|
+
async function enqueueLeanJob(spec) {
|
|
1393
|
+
const existing = leanJobs.get(spec.jobId)
|
|
1394
|
+
if (existing && (existing.state === 'queued' || existing.state === 'running')) return existing
|
|
1395
|
+
const job = Object.assign({}, spec, {
|
|
1396
|
+
state: 'queued', attempts: Number(spec.attempts || ((existing && existing.attempts) || 0) + 1),
|
|
1397
|
+
enqueuedAt: spec.enqueuedAt || now(), startedAt: 0, settledAt: 0, exitCode: null,
|
|
1398
|
+
timedOut: false, interrupted: false, run: null, note: spec.note || '',
|
|
1399
|
+
})
|
|
1400
|
+
leanJobs.set(job.jobId, job)
|
|
1401
|
+
leanQueue.push(job)
|
|
1402
|
+
await writeLeanJobFile(job)
|
|
1403
|
+
logActivity('formal', 'Lean 作业入队 ' + job.jobId + '(' + job.kind + (job.target ? '|对象 ' + job.target : (job.name ? '|' + job.name : '')) + ',state=queued)')
|
|
1404
|
+
return job
|
|
1405
|
+
}
|
|
1406
|
+
function pushLeanNotice(line) { if (leanPendingNotices.length >= 20) leanPendingNotices.shift(); leanPendingNotices.push(String(line)) }
|
|
1407
|
+
/** 一次性公告(spec §2.4):取走即清空,保证同一条结果只注入一次。 */
|
|
1408
|
+
function leanNoticeSection() {
|
|
1409
|
+
if (leanPendingNotices.length === 0) return ''
|
|
1410
|
+
const lines = leanPendingNotices.splice(0, leanPendingNotices.length)
|
|
1411
|
+
return '\n' + lines.join('\n') + '\n'
|
|
1412
|
+
}
|
|
1413
|
+
function leanJobPublic(job) { return { jobId: job.jobId, state: job.state, rel: job.rel || '', target: job.target || '', attempts: Number(job.attempts || 1), settledAt: Number(job.settledAt || 0) } }
|
|
1414
|
+
/**
|
|
1415
|
+
* 心跳里推进队列:**不 await 编译本身**(调用方不 await 本函数)。
|
|
1416
|
+
* 并发上限 = `leanJobsMaxParallel`(默认 1,串行);串行时本函数等待该作业跑完再返回。
|
|
1417
|
+
*/
|
|
1418
|
+
async function runLeanQueue() {
|
|
1419
|
+
const max = leanJobsMaxParallel()
|
|
1420
|
+
let started = 0
|
|
1421
|
+
while (leanQueue.length > 0 && leanActiveCount() + started < max) {
|
|
1422
|
+
const job = leanQueue.shift()
|
|
1423
|
+
if (!job || job.state !== 'queued') continue
|
|
1424
|
+
started++
|
|
1425
|
+
if (max <= 1) { await runLeanJob(job); return }
|
|
1426
|
+
runLeanJob(job).catch(function (e) { console.error('vibe-math-v2: lean job failed: ' + String((e && e.message) || e)) })
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
async function runLeanJob(job) {
|
|
1430
|
+
if (job.state !== 'queued') return
|
|
1431
|
+
job.state = 'running'; job.startedAt = now(); job.interrupted = false
|
|
1432
|
+
await writeLeanJobFile(job)
|
|
1433
|
+
let run
|
|
1434
|
+
try { run = await leanExecFile(job.rel, leanJobAbs(job), job.timeoutMs, job.plan, function (h) { if (h) leanRunningHandles.set(job.jobId, h); else leanRunningHandles.delete(job.jobId) }) }
|
|
1435
|
+
catch (e) { run = { ok: false, exitCode: null, code: 'LEAN_RUN_FAILED', message: String((e && e.message) || e), ms: 0 } }
|
|
1436
|
+
try { await settleLeanJob(job, run) } catch (e) { console.error('vibe-math-v2: lean job settle failed: ' + String((e && e.stack) || e)) }
|
|
1437
|
+
}
|
|
1438
|
+
/** 结果公告行(spec §2.4 的四种结局)。 */
|
|
1439
|
+
function leanSettleNotice(job, settledOk, capMs) {
|
|
1440
|
+
const ms = (job.run && job.run.ms) || 0
|
|
1441
|
+
if (job.interrupted) return '【形式化结果】' + job.jobId + ':中断(会话卸载或崩溃,已标记 attempted,可重跑)'
|
|
1442
|
+
if (job.timedOut) return '【形式化结果】' + job.jobId + ':超时(leanTimeoutMs=' + capMs + ' 已 terminate;对象留在 attempted)'
|
|
1443
|
+
if (settledOk) return '【形式化结果】' + job.jobId + ':通过(exit 0,' + (ms / 1000).toFixed(1) + 's' + ((job.kind === 'archive' && job.archive && job.archive.kind === 'proof') ? (',已归档 Verified/Lean/' + job.target + '.lean') : '') + ')'
|
|
1444
|
+
if (job.hashMismatch) return '【形式化结果】' + job.jobId + ':失败(编译期间文件内容已变,本次结果已丢弃;请重跑)'
|
|
1445
|
+
if (job.contextMismatch) return '【形式化结果】' + job.jobId + ':失败(编译期间构建上下文已变:引擎/参数/搜索路径,本次结果已丢弃;请在新上下文下重跑)'
|
|
1446
|
+
return '【形式化结果】' + job.jobId + ':失败(exit ' + String(job.exitCode) + ',见 stderr 尾部)'
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* 落地一个作业(**唯一**的 passed 入口,spec §2.1):只有 exit 0 **且** 内容哈希仍匹配才算 ok;
|
|
1450
|
+
* 其余一律 attempted;中断/超时都不是 passed;内容变了只标记不贴结果。
|
|
1451
|
+
*/
|
|
1452
|
+
async function settleLeanJob(job, run) {
|
|
1453
|
+
const capMs = Math.max(1000, Number(job.timeoutMs) || Number(params.leanTimeoutMs) || 120000)
|
|
1454
|
+
let currentSha = null
|
|
1455
|
+
try { const t = await readTextAbs(leanJobAbs(job)); if (t !== undefined) currentSha = leanContentSha(t) } catch (e) { /* 读不到 ⇒ 视为不匹配 */ }
|
|
1456
|
+
const hashOk = currentSha !== null && currentSha === job.sha
|
|
1457
|
+
// 修订 §4:结果只对**产生它的那个构建上下文**有效。参数在编译途中被改(引擎/参数/搜索路径)
|
|
1458
|
+
// ⇒ 这次 ok 不能贴到"现在的配置"上,宁可判为未通过并明确公告。
|
|
1459
|
+
const ctxNow = leanContextNow()
|
|
1460
|
+
const ctxOk = !job.buildCtx || job.buildCtx === ctxNow
|
|
1461
|
+
job.hashMismatch = !hashOk
|
|
1462
|
+
job.contextMismatch = !ctxOk
|
|
1463
|
+
job.exitCode = run.exitCode === undefined ? null : run.exitCode
|
|
1464
|
+
job.timedOut = !!run.timedOut
|
|
1465
|
+
job.settledAt = now()
|
|
1466
|
+
const settledOk = !job.interrupted && !job.timedOut && run.exitCode === 0 && hashOk && ctxOk
|
|
1467
|
+
job.state = job.interrupted ? 'interrupted' : (job.timedOut ? 'timeout' : (settledOk ? 'settled' : 'failed'))
|
|
1468
|
+
job.run = { at: now(), ok: settledOk, exitCode: job.exitCode, ms: run.ms || 0, timedOut: !!job.timedOut, interrupted: !!job.interrupted, stdoutTail: tailText(run.stdout, 800), stderrTail: tailText(run.stderr, 800), searchPath: run.searchPath || vibeRoot(), buildContext: job.buildCtx || '' }
|
|
1469
|
+
if (job.hashMismatch && !job.interrupted && !job.timedOut) job.note = 'file changed while compiling — result discarded (hash mismatch)'
|
|
1470
|
+
else if (job.contextMismatch && !job.interrupted && !job.timedOut) job.note = 'build context changed while compiling (engine/args/search paths) — result discarded; re-run under the new context'
|
|
1471
|
+
const asyncInfo = { jobId: job.jobId, state: job.state, attempts: job.attempts, enqueuedAt: job.enqueuedAt, startedAt: job.startedAt, settledAt: job.settledAt, exitCode: job.exitCode, buildSha256: job.buildSha || '' }
|
|
1472
|
+
if (job.kind === 'archive') await settleLeanArchiveJob(job, settledOk, asyncInfo)
|
|
1473
|
+
else if (job.target) await formalSetRun(job.target, Object.assign({}, run, { ok: settledOk, file: job.rel }), asyncInfo)
|
|
1474
|
+
if (!job.interrupted) await writeFormalIndex()
|
|
1475
|
+
await writeLeanJobFile(job)
|
|
1476
|
+
pushLeanNotice(leanSettleNotice(job, settledOk, capMs))
|
|
1477
|
+
logActivity('formal', 'Lean 作业 ' + job.jobId + ' ' + job.state + '(' + job.kind + ')' + (job.target ? '|对象 ' + job.target : ''))
|
|
1478
|
+
reportDirty = true
|
|
1479
|
+
}
|
|
1480
|
+
/** 归档类作业的落地:**只有 ok 才落库/置 passed**(def/lemma 的文件在入队前已写好)。 */
|
|
1481
|
+
async function settleLeanArchiveJob(job, ok, asyncInfo) {
|
|
1482
|
+
const a = job.archive || {}
|
|
1483
|
+
if (a.kind !== 'proof') {
|
|
1484
|
+
await rebuildLeanLibIndexes()
|
|
1485
|
+
logActivity('formal', '归档作业 ' + job.jobId + '(' + a.kind + ' ' + job.rel + ')' + (ok ? '运行通过' : '运行未通过(见 stderr 尾部;该文件不应被当作可复用定义)'))
|
|
1486
|
+
return
|
|
1487
|
+
}
|
|
1488
|
+
const target = job.target
|
|
1489
|
+
const prev = formalOf(target)
|
|
1490
|
+
const passed = !!ok
|
|
1491
|
+
const stalePrev = passed ? '' : String(prev.proof || ('Verified/Lean/' + target + '.lean'))
|
|
1492
|
+
const rec = Object.assign({}, prev, {
|
|
1493
|
+
status: passed ? 'passed' : 'attempted',
|
|
1494
|
+
file: job.rel,
|
|
1495
|
+
proof: passed ? 'Verified/Lean/' + target + '.lean' : '',
|
|
1496
|
+
decision: 'used',
|
|
1497
|
+
note: String(prev.note || ''),
|
|
1498
|
+
run: job.run,
|
|
1499
|
+
async: asyncInfo,
|
|
1500
|
+
updatedAt: now(),
|
|
1501
|
+
})
|
|
1502
|
+
let withdrawn = null
|
|
1503
|
+
if (passed) {
|
|
1504
|
+
const body = await readTextAbs(leanJobAbs(job))
|
|
1505
|
+
if (body !== undefined) await writeText('Verified/Lean/' + target + '.lean', body)
|
|
1506
|
+
} else if (stalePrev) { try { withdrawn = await withdrawArchivedProof(stalePrev) } catch (e) { withdrawn = { rel: stalePrev, outcome: 'failed' } } }
|
|
1507
|
+
await putFormal(target, rec)
|
|
1508
|
+
await syncVerificationTarget(target, rec.status, rec)
|
|
1509
|
+
await rebuildLeanLibIndexes()
|
|
1510
|
+
logActivity('formal', '归档作业 ' + job.jobId + ' 为 ' + target + ' ' + (passed ? ('通过,已归档 ' + rec.proof + ',验证转为忠实性审查')
|
|
1511
|
+
: ('未通过:' + tailText(job.run && job.run.stderrTail, 160) + ';已撤回上一份已通过状态与归档证明' + (withdrawn && withdrawn.outcome !== 'failed' ? '(' + withdrawn.outcome + ')' : '(⚠ 撤回失败,请不要把 ' + stalePrev + ' 当作该对象的证明)'))))
|
|
1512
|
+
}
|
|
1513
|
+
/** 去重(spec §4.3):同内容且已成功过 ⇒ 跳过写盘与重编译。 */
|
|
1514
|
+
async function leanDedupeLookup(jobId, abs, sha, proofTarget) {
|
|
1515
|
+
if (proofTarget) {
|
|
1516
|
+
const rec = formalOf(proofTarget)
|
|
1517
|
+
if (rec && rec.status === 'passed') {
|
|
1518
|
+
const cur = await readTextAbs(vibeAbs('Verified/Lean/' + proofTarget + '.lean'))
|
|
1519
|
+
if (cur !== undefined && leanContentSha(cur) === sha) return { deduped: true, file: 'Verified/Lean/' + proofTarget + '.lean' }
|
|
1520
|
+
}
|
|
1521
|
+
return null
|
|
1522
|
+
}
|
|
1523
|
+
const prev = await readJson('Formal/Jobs/' + jobId + '.json')
|
|
1524
|
+
if (leanJobSettledOk(prev)) {
|
|
1525
|
+
const cur = await readTextAbs(abs)
|
|
1526
|
+
if (cur !== undefined && leanContentSha(cur) === sha) return { deduped: true, file: String(prev.rel || '') }
|
|
1527
|
+
}
|
|
1528
|
+
return null
|
|
1529
|
+
}
|
|
1530
|
+
/** 后台作业的公共返回形状(§1.2)。 */
|
|
1531
|
+
function leanAsyncReturn(job, extra) {
|
|
1532
|
+
return Object.assign({
|
|
1533
|
+
ok: true, async: { jobId: job.jobId, state: job.state }, jobId: job.jobId,
|
|
1534
|
+
message: '已入队后台编译(并发上限 1);你可以继续工作。结果会写入 Formal/ 与索引,并在下一轮提示里公告;也可用 vibe_math_lean_lib 的 jobs 字段随时查看。**在该作业落地为通过之前,不得把相关对象当成已通过。**',
|
|
1535
|
+
}, extra || {})
|
|
1536
|
+
}
|
|
1537
|
+
/** 崩溃恢复(spec §2.6):queued 重入队;running 哈希匹配 ⇒ 标记 interrupted + 重入队(attempts+1),不匹配 ⇒ 只标记;恢复**绝不**置 passed。 */
|
|
1538
|
+
async function recoverLeanJobs() {
|
|
1539
|
+
let files = []
|
|
1540
|
+
try { files = (await listFiles('Formal/Jobs')) || [] } catch (e) { return { recovered: 0 } }
|
|
1541
|
+
let recovered = 0
|
|
1542
|
+
for (let i = 0; i < files.length; i++) {
|
|
1543
|
+
const f = String(files[i])
|
|
1544
|
+
if (!/\.json$/.test(f)) continue
|
|
1545
|
+
const rec = await readJson('Formal/Jobs/' + f)
|
|
1546
|
+
if (!rec || !rec.jobId) continue
|
|
1547
|
+
// 本进程内存里已知的作业(queued/running 或已落地)不再恢复:同一进程内切换项目时,
|
|
1548
|
+
// 内存队列还在正常跑,按文件状态"恢复"会把一个在跑的作业误判成崩溃(重复入队/误置 interrupted)。
|
|
1549
|
+
if (leanJobs.has(String(rec.jobId))) continue
|
|
1550
|
+
const job = hydrateLeanJob(rec)
|
|
1551
|
+
leanJobs.set(job.jobId, job)
|
|
1552
|
+
const abs = leanJobAbs(job)
|
|
1553
|
+
const txt = abs ? await readTextAbs(abs) : undefined
|
|
1554
|
+
const curSha = txt === undefined ? null : leanContentSha(txt)
|
|
1555
|
+
const hashMatch = curSha !== null && curSha === job.sha
|
|
1556
|
+
if (job.state === 'queued') {
|
|
1557
|
+
await enqueueLeanJob(job)
|
|
1558
|
+
pushLeanNotice('【形式化结果】' + job.jobId + ':上次会话未执行的排队作业已重新入队')
|
|
1559
|
+
recovered++
|
|
1560
|
+
} else if (job.state === 'running') {
|
|
1561
|
+
if (hashMatch) {
|
|
1562
|
+
if (job.target) await putFormal(job.target, Object.assign({}, formalOf(job.target), { status: 'attempted', async: { jobId: job.jobId, state: 'interrupted', attempts: job.attempts }, updatedAt: now() }))
|
|
1563
|
+
job.interrupted = true
|
|
1564
|
+
// 先落到 interrupted,再重入队:否则 enqueueLeanJob 的幂等守卫会把这条 "running" 记录
|
|
1565
|
+
// 当成"仍在跑"而早退,attempts 也不会 +1(恢复就静默失效了)。
|
|
1566
|
+
job.state = 'interrupted'
|
|
1567
|
+
await enqueueLeanJob(Object.assign({}, job, { attempts: job.attempts + 1 }))
|
|
1568
|
+
pushLeanNotice('【形式化结果】' + job.jobId + ':中断(会话崩溃,已标记 attempted,已用新 attempts 重新入队)')
|
|
1569
|
+
} else {
|
|
1570
|
+
if (job.target) await putFormal(job.target, Object.assign({}, formalOf(job.target), { status: 'attempted', async: { jobId: job.jobId, state: 'interrupted', attempts: job.attempts, note: 'file changed' }, updatedAt: now() }))
|
|
1571
|
+
job.state = 'interrupted'; job.interrupted = true
|
|
1572
|
+
job.note = 'file changed while the previous session was compiling — not auto-re-driven'
|
|
1573
|
+
await writeLeanJobFile(job)
|
|
1574
|
+
pushLeanNotice('【形式化结果】' + job.jobId + ':中断(文件已变,未自动重驱;请手动重跑)')
|
|
1575
|
+
}
|
|
1576
|
+
recovered++
|
|
1577
|
+
} else if (job.state === 'settled') {
|
|
1578
|
+
// 修订 §4:只有**同一构建上下文**下的 settled(ok) 才允许恢复成 passed。
|
|
1579
|
+
const ctxNow = leanContextNow()
|
|
1580
|
+
const ctxOk = !job.buildCtx || job.buildCtx === ctxNow
|
|
1581
|
+
if (leanJobSettledOk(rec) && hashMatch && ctxOk) {
|
|
1582
|
+
job.run = { at: now(), ok: true, exitCode: 0, ms: 0, timedOut: false, interrupted: false, stdoutTail: '', stderrTail: '', searchPath: vibeRoot(), buildContext: job.buildCtx || '' }
|
|
1583
|
+
if (job.kind === 'archive') await settleLeanArchiveJob(job, true, { jobId: job.jobId, state: 'settled', attempts: job.attempts, settledAt: job.settledAt, exitCode: 0, buildSha256: job.buildSha || '' })
|
|
1584
|
+
else if (job.target) await formalSetRun(job.target, job.run, { jobId: job.jobId, state: 'settled', buildSha256: job.buildSha || '' })
|
|
1585
|
+
pushLeanNotice('【形式化结果】' + job.jobId + ':已按作业记录补写归档与 passed(恢复时校验内容哈希与构建上下文一致)')
|
|
1586
|
+
} else if (job.target) {
|
|
1587
|
+
await putFormal(job.target, Object.assign({}, formalOf(job.target), { status: 'attempted', async: { jobId: job.jobId, state: 'interrupted', attempts: job.attempts, note: ctxOk ? 'recovered-incomplete' : 'build context changed' }, updatedAt: now() }))
|
|
1588
|
+
pushLeanNotice('【形式化结果】' + job.jobId + ':按作业记录标记为未通过/已变(恢复绝不置 passed)')
|
|
1589
|
+
}
|
|
1590
|
+
recovered++
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
if (recovered > 0) logActivity('formal', 'Lean 作业恢复:处理了 ' + recovered + ' 条遗留作业记录(queued 重入队 / running 标记中断 / settled 补写;绝不置 passed)')
|
|
1594
|
+
return { recovered: recovered }
|
|
1595
|
+
}
|
|
1596
|
+
/** 会话卸载/销毁:终止在跑的编译并标记 interrupted(不留孤儿进程,spec §2.5)。 */
|
|
1597
|
+
function disposeLeanJobs() {
|
|
1598
|
+
let n = 0
|
|
1599
|
+
for (const job of leanJobs.values()) {
|
|
1600
|
+
if (job.state === 'running') { job.interrupted = true; n++ }
|
|
1601
|
+
}
|
|
1602
|
+
for (const job of leanQueue) { job.state = 'interrupted'; job.interrupted = true; n++ }
|
|
1603
|
+
for (const h of leanRunningHandles.values()) {
|
|
1604
|
+
try { if (h && typeof h.terminate === 'function') h.terminate() } catch (e) { /* best effort */ }
|
|
1605
|
+
}
|
|
1606
|
+
leanRunningHandles.clear()
|
|
1607
|
+
if (leanSyncHandle && typeof leanSyncHandle.terminate === 'function') { try { leanSyncHandle.terminate() } catch (e) { /* best effort */ } }
|
|
1608
|
+
leanSyncHandle = null
|
|
1609
|
+
leanQueue.length = 0
|
|
1610
|
+
if (n > 0) {
|
|
1611
|
+
pushLeanNotice('【形式化结果】干预:会话卸载,' + n + ' 个 Lean 作业已标记 interrupted(未落地为 passed)')
|
|
1612
|
+
// 落盘尽力而为(disposer 不能 await):作业记录留着,供下次 init 恢复时标记。
|
|
1613
|
+
try {
|
|
1614
|
+
for (const job of leanJobs.values()) { if (job.state === 'running' || job.interrupted) writeLeanJobFile(job).catch(function () { }) }
|
|
1615
|
+
} catch (e) { /* best effort */ }
|
|
1616
|
+
}
|
|
1617
|
+
return n
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1082
1620
|
// ---- 三份索引(框架维护;契约 §9)----
|
|
1083
1621
|
async function writeFormalIndex() {
|
|
1084
1622
|
const recs = formalRecords()
|
|
@@ -1113,6 +1651,7 @@ export function apply(ctx) {
|
|
|
1113
1651
|
async function rebuildLeanLibIndexes() {
|
|
1114
1652
|
// 扫描**不执行**工具链:每次问"有什么可复用"就跑一遍 lean 既慢又出人意料。
|
|
1115
1653
|
// 每个对象的运行结果存在对象记录里,显示在 Formal/Index.md。
|
|
1654
|
+
// 依赖列(spec §4.4):把 `import` 行扫出来写进索引,"有什么 / 叫什么 / 怎么导入"三问一次答完。
|
|
1116
1655
|
const scan = async (dirAbs, dirRel, kindLabel) => {
|
|
1117
1656
|
const rows = []
|
|
1118
1657
|
try {
|
|
@@ -1125,21 +1664,23 @@ export function apply(ctx) {
|
|
|
1125
1664
|
const txt = (await readTextAbs(dirAbs + '/' + e.name)) || ''
|
|
1126
1665
|
const name = String(e.name).replace(/\.lean$/, '')
|
|
1127
1666
|
const first = (txt.split('\n').filter(function (l) { return l.trim() && !/^\s*(\/\/|--|import)/.test(l) })[0] || '').trim().slice(0, 110)
|
|
1128
|
-
|
|
1667
|
+
const deps = txt.split('\n').filter(function (l) { return /^\s*import\s+/.test(l) })
|
|
1668
|
+
.map(function (l) { return l.trim().replace(/^import\s+/, '').trim() }).filter(Boolean).slice(0, 4)
|
|
1669
|
+
rows.push('| ' + name + ' | ' + rel + ' | ' + kindLabel + ' | ' + (deps.length ? deps.join(', ') : '—').replace(/\|/g, '/') + ' | ' + first.replace(/\|/g, '/') + ' |')
|
|
1129
1670
|
}
|
|
1130
1671
|
} catch (e) { /* 列表尽力而为 */ }
|
|
1131
1672
|
return rows
|
|
1132
1673
|
}
|
|
1133
1674
|
const libRows = await scan(vibeRoot() + '/Formal/Lib', 'Formal/Lib', 'def')
|
|
1134
1675
|
await writeTextAbs(vibeRoot() + '/Formal/Lib/Index.md', ['# 可复用 Lean 定义库(跨项目)|' + currentProject, '',
|
|
1135
|
-
'>
|
|
1136
|
-
'| 名称 | 文件 | 类别 | 摘要 |', '
|
|
1137
|
-
.concat(libRows.length ? libRows : ['| (暂无) | | | |']).join('\n') + '\n')
|
|
1676
|
+
'> 写新定义之前先查这里:能复用就不要重新定义。复用方式:`import Formal.Lib.<名称>`(模块根 = VibeMath 根)。', '',
|
|
1677
|
+
'| 名称 | 文件 | 类别 | 依赖(import) | 摘要 |', '|---|---|---|---|---|']
|
|
1678
|
+
.concat(libRows.length ? libRows : ['| (暂无) | | | | |']).join('\n') + '\n')
|
|
1138
1679
|
const provedRows = await scan(vibeRoot() + '/Formal/Proved', 'Formal/Proved', 'lemma')
|
|
1139
1680
|
await writeTextAbs(vibeRoot() + '/Formal/Proved/Index.md', ['# 已成立的 Lean 命题 / 引理(机器已核对,可跨项目复用)|' + currentProject, '',
|
|
1140
|
-
'> 这些文件是通过内核检查的引理,可直接 import
|
|
1141
|
-
'| 名称 | 文件 | 类别 | 陈述 |', '
|
|
1142
|
-
.concat(provedRows.length ? provedRows : ['| (暂无) | | | |']).join('\n') + '\n')
|
|
1681
|
+
'> 这些文件是通过内核检查的引理,可直接 import 复用:`import Formal.Proved.<名称>`。', '',
|
|
1682
|
+
'| 名称 | 文件 | 类别 | 依赖(import) | 陈述 |', '|---|---|---|---|---|']
|
|
1683
|
+
.concat(provedRows.length ? provedRows : ['| (暂无) | | | | |']).join('\n') + '\n')
|
|
1143
1684
|
await writeFormalIndex()
|
|
1144
1685
|
await writeFormalTodo()
|
|
1145
1686
|
return { lib: libRows.length, proved: provedRows.length, objects: Object.keys(formalRecords()).length }
|
|
@@ -1147,20 +1688,110 @@ export function apply(ctx) {
|
|
|
1147
1688
|
|
|
1148
1689
|
// 执行一个 Lean 文件,记录运行结果(可按 target 归属到对象),刷新索引,并**原样**回报结果。
|
|
1149
1690
|
// 刻意在 `off` 档也照常工作:人要调试自己的工具链时仍然可用。
|
|
1691
|
+
// leanAsync=true(默认):入队后立即返回(成员不阻塞);false:逐字保留今天的同步路径。
|
|
1150
1692
|
async function leanRunTool(memberId, o) {
|
|
1151
1693
|
const args = o || {}
|
|
1152
|
-
const
|
|
1694
|
+
const rel = String(args.file || '').trim()
|
|
1695
|
+
const target = String(args.target || '').trim()
|
|
1696
|
+
if (params.leanAsync !== false) {
|
|
1697
|
+
const guard = await leanResolveRunTarget(rel)
|
|
1698
|
+
if (!guard.ok) return guard
|
|
1699
|
+
const plan = leanBuildPlan()
|
|
1700
|
+
const ctx = leanPlanContext(plan)
|
|
1701
|
+
const fp = leanJobFingerprint(guard.text, ctx)
|
|
1702
|
+
const jobId = leanJobId(target || guard.rel, fp)
|
|
1703
|
+
const job = await enqueueLeanJob({ jobId: jobId, kind: 'run', key: target || guard.rel, rel: guard.rel, scope: 'project', target: target, sha: guard.sha, buildSha: fp, buildCtx: ctx, plan: plan, memberId: memberId, timeoutMs: args.timeout_ms })
|
|
1704
|
+
return leanAsyncReturn(job, { file: guard.rel, state: job.state, hint: '已入队。**在它落地为通过之前,不得把该对象当成已通过**;结果会在下一轮提示的【形式化结果】行里公告,或用 vibe_math_lean_job / vibe_math_lean_lib 的 jobs 字段查看(lean_job 可 waitMs=… 等待)。' })
|
|
1705
|
+
}
|
|
1706
|
+
const run = await leanRunFile(rel, args.timeout_ms)
|
|
1153
1707
|
if (run.ok || run.file) {
|
|
1154
|
-
if (
|
|
1708
|
+
if (target) await formalSetRun(target, run)
|
|
1155
1709
|
await writeFormalIndex()
|
|
1156
1710
|
}
|
|
1157
1711
|
if (run.ok) logActivity('formal', (memberId || 'office') + ' 运行 Lean 通过:' + run.file + '(' + (run.ms / 1000).toFixed(1) + 's)' + (args.target ? '|对象 ' + args.target : ''))
|
|
1158
1712
|
return Object.assign({ ok: !!run.ok }, run, {
|
|
1713
|
+
async: null,
|
|
1159
1714
|
hint: run.ok
|
|
1160
1715
|
? '通过。若是某个对象的证明,请用 vibe_math_lean_archive kind=\'proof\' 归档(会写入 Verified/Lean/ 并把审查对象变成忠实性);若是可复用定义/引理,用 kind=\'def\'/\'lemma\' 归档到全局库(归档时会先跑一次,跑不通不要入库)。'
|
|
1161
1716
|
: '未通过。请按上面的编译器输出修复后重跑;若判断无法完成,用 vibe_math_lean_archive kind=\'blocked\' 记录原因。',
|
|
1162
1717
|
})
|
|
1163
1718
|
}
|
|
1719
|
+
/** 异步档的路径/存在性校验(与 leanRunFile 同一套守卫与错误码)。 */
|
|
1720
|
+
async function leanResolveRunTarget(relPath) {
|
|
1721
|
+
const rel = String(relPath || '').trim()
|
|
1722
|
+
if (!rel) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'file is required' }
|
|
1723
|
+
const abs = leanAbsPath(rel)
|
|
1724
|
+
if (abs === null) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'Lean 文件必须位于 ' + vibeRoot() + '/ 之内(收到 ' + rel + ')' }
|
|
1725
|
+
if (!/\.lean$/.test(abs)) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: '只有 .lean 文件可以执行' }
|
|
1726
|
+
const txt = await readTextAbs(abs)
|
|
1727
|
+
if (txt === undefined) return { ok: false, code: 'V2_NOT_FOUND', message: 'no such file: ' + rel }
|
|
1728
|
+
return { ok: true, rel: rel, abs: abs, sha: leanContentSha(txt), text: txt }
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* `lean_job`(修订 §3,只读):不带 jobId ⇒ 本会话作业清单;带 ⇒ 该作业的 state/exitCode/
|
|
1732
|
+
* 回执路径/归档路径。`waitMs>0` 时最多等这么久(内部轮询,**不阻塞心跳**——心跳是独立 timer),
|
|
1733
|
+
* 超时就返回当前 state。
|
|
1734
|
+
*/
|
|
1735
|
+
async function leanJobTool(o) {
|
|
1736
|
+
const args = o || {}
|
|
1737
|
+
const wantId = String(args.jobId || '').trim()
|
|
1738
|
+
const waitMs = Math.max(0, Math.min(600000, Number(args.waitMs) || 0))
|
|
1739
|
+
if (!wantId) {
|
|
1740
|
+
const jobs = []
|
|
1741
|
+
for (const j of leanJobs.values()) jobs.push(Object.assign(leanJobPublic(j), { exitCode: j.exitCode === undefined ? null : j.exitCode, paths: leanJobPaths(j), buildSha256: j.buildSha || '' }))
|
|
1742
|
+
jobs.sort(function (a, b) { return (b.settledAt || 0) - (a.settledAt || 0) || String(a.jobId).localeCompare(String(b.jobId)) })
|
|
1743
|
+
return { ok: true, async: params.leanAsync !== false, maxParallel: leanJobsMaxParallel(), queued: leanQueue.length, running: leanActiveCount(), jobs: jobs, paths: { receipts: 'Formal/Jobs/', proofs: 'Verified/Lean/', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/' }, hint: 'state=settled 且 exitCode=0(且内容哈希/构建上下文一致)才算通过;queued/running 一律还不算。' }
|
|
1744
|
+
}
|
|
1745
|
+
let job = leanJobs.get(wantId)
|
|
1746
|
+
if (!job) {
|
|
1747
|
+
const rec = await readJson('Formal/Jobs/' + wantId + '.json')
|
|
1748
|
+
if (!rec || !rec.jobId) return { ok: false, code: 'V2_NOT_FOUND', message: 'no such Lean job: ' + wantId + '(用不带 jobId 的 vibe_math_lean_job 列清单)' }
|
|
1749
|
+
job = hydrateLeanJob(rec)
|
|
1750
|
+
if (!leanJobs.has(job.jobId)) leanJobs.set(job.jobId, job)
|
|
1751
|
+
}
|
|
1752
|
+
const startedAt = now()
|
|
1753
|
+
while (waitMs > 0 && (job.state === 'queued' || job.state === 'running') && (now() - startedAt) < waitMs) {
|
|
1754
|
+
await leanSleep(50)
|
|
1755
|
+
const cur = leanJobs.get(wantId)
|
|
1756
|
+
if (cur) job = cur
|
|
1757
|
+
}
|
|
1758
|
+
return {
|
|
1759
|
+
ok: true, jobId: job.jobId, state: job.state, exitCode: job.exitCode === undefined ? null : job.exitCode,
|
|
1760
|
+
attempt: Number(job.attempts || 1), timedOut: !!job.timedOut, interrupted: !!job.interrupted, note: job.note || '',
|
|
1761
|
+
buildSha256: job.buildSha || '', buildContext: job.buildCtx || '',
|
|
1762
|
+
waitedMs: now() - startedAt, stillRunning: (job.state === 'queued' || job.state === 'running'),
|
|
1763
|
+
passed: leanJobSettledOk(leanJobRecord(job)),
|
|
1764
|
+
paths: leanJobPaths(job),
|
|
1765
|
+
run: job.run || null,
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
/** `lean_read`:只读地取回归档的 Lean 原文(verbatim 复用;§1.2)。 */
|
|
1769
|
+
async function leanReadTool(o) {
|
|
1770
|
+
const args = o || {}
|
|
1771
|
+
const rawName = String(args.name || '').trim()
|
|
1772
|
+
if (!rawName) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'name is required' }
|
|
1773
|
+
if (rawName.indexOf('..') !== -1 || /[\\/]/.test(rawName) || /^[a-z]:/i.test(rawName)) {
|
|
1774
|
+
return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'name must be a bare library name (no path separators, no "..", no absolute path)' }
|
|
1775
|
+
}
|
|
1776
|
+
const name = safeId(rawName)
|
|
1777
|
+
const kind = (args.kind === 'lib' || args.kind === 'proved') ? args.kind : 'auto'
|
|
1778
|
+
const order = kind === 'auto' ? ['lib', 'proved'] : [kind]
|
|
1779
|
+
for (let i = 0; i < order.length; i++) {
|
|
1780
|
+
const k = order[i]
|
|
1781
|
+
const rel = 'Formal/' + (k === 'lib' ? 'Lib' : 'Proved') + '/' + name + '.lean'
|
|
1782
|
+
const dir = normalizeAbsPath(vibeRoot() + '/Formal/' + (k === 'lib' ? 'Lib' : 'Proved'))
|
|
1783
|
+
const abs = normalizeAbsPath(vibeAbs(rel))
|
|
1784
|
+
// 双保险:规范化后必须真的落在那两个目录之内(name 已过 safeId,这里是路径守卫)
|
|
1785
|
+
if (abs.indexOf(dir + '/') !== 0) continue
|
|
1786
|
+
const txt = await readTextAbs(abs)
|
|
1787
|
+
if (txt === undefined) continue
|
|
1788
|
+
const LIMIT = 64 * 1024
|
|
1789
|
+
const bytes = Buffer.byteLength(txt, 'utf8')
|
|
1790
|
+
const truncated = bytes > LIMIT
|
|
1791
|
+
return { ok: true, name: name, file: rel, kind: k, sha256: leanContentSha(txt), bytes: bytes, text: truncated ? Buffer.from(txt, 'utf8').slice(0, LIMIT).toString('utf8') : txt, truncated: truncated }
|
|
1792
|
+
}
|
|
1793
|
+
return { ok: false, code: 'V2_NOT_FOUND', message: 'no archived Lean file named ' + name + '(查过 Formal/Lib 与 Formal/Proved);可先用 vibe_math_lean_lib 看清单' }
|
|
1794
|
+
}
|
|
1164
1795
|
async function leanArchive(memberId, o) {
|
|
1165
1796
|
const args = o || {}
|
|
1166
1797
|
const kind = String(args.kind || '')
|
|
@@ -1181,6 +1812,22 @@ export function apply(ctx) {
|
|
|
1181
1812
|
if (body === undefined) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
|
|
1182
1813
|
const rel = 'Formal/' + (kind === 'def' ? 'Lib' : 'Proved') + '/' + name + '.lean'
|
|
1183
1814
|
const abs = vibeAbs(rel)
|
|
1815
|
+
const sha = leanContentSha(body)
|
|
1816
|
+
const plan = leanBuildPlan()
|
|
1817
|
+
const ctx = leanPlanContext(plan)
|
|
1818
|
+
const fp = leanJobFingerprint(body, ctx) // 修订 §4:指纹含内容 + 构建上下文
|
|
1819
|
+
const jobId = leanJobId(name, fp)
|
|
1820
|
+
// 去重(§4.3):同内容 + 同构建上下文且已成功归档过 ⇒ 跳过重写与重编译。
|
|
1821
|
+
const dedupe = await leanDedupeLookup(jobId, abs, sha, null)
|
|
1822
|
+
if (dedupe) {
|
|
1823
|
+
logActivity('formal', (memberId || 'office') + ' 归档 ' + kind + ' `' + name + '` 命中去重(同内容与构建上下文,跳过重写与重编译)')
|
|
1824
|
+
return { ok: true, kind: kind, name: name, file: rel, deduped: true, sha256: sha, buildSha256: fp, note: '内容与构建上下文都与已归档并编译通过的版本一致:跳过重写与重编译(去重)。' }
|
|
1825
|
+
}
|
|
1826
|
+
if (params.leanAsync !== false && args.run !== false) {
|
|
1827
|
+
if (!await writeTextAbs(abs, body)) return { ok: false, code: 'V2_WRITE_FAILED', message: 'could not write ' + rel }
|
|
1828
|
+
const job = await enqueueLeanJob({ jobId: jobId, kind: 'archive', key: name, rel: rel, scope: 'root', name: name, target: '', sha: sha, buildSha: fp, buildCtx: ctx, plan: plan, memberId: memberId, archive: { kind: kind } })
|
|
1829
|
+
return leanAsyncReturn(job, { kind: kind, name: name, file: rel, sha256: sha, buildSha256: fp, deduped: false, note: '文件已写入 ' + rel + ';编译已入队(并发上限 leanJobsMaxParallel)。**落地为通过之前,请不要把它当作可复用定义**;结果会在下一轮提示的【形式化结果】行公告,也可用 vibe_math_lean_job 查/等。' })
|
|
1830
|
+
}
|
|
1184
1831
|
if (!await writeTextAbs(abs, body)) return { ok: false, code: 'V2_WRITE_FAILED', message: 'could not write ' + rel }
|
|
1185
1832
|
// 全局库在项目树之外,必须用**绝对路径**执行(相对形式会被解析到项目根之内)。
|
|
1186
1833
|
const run = args.run === false ? null : await leanRunFile(abs)
|
|
@@ -1207,6 +1854,35 @@ export function apply(ctx) {
|
|
|
1207
1854
|
}
|
|
1208
1855
|
if (body === undefined) return { ok: false, code: 'V2_INVALID_ARGUMENT', message: 'provide content, or from=<existing .lean file>' }
|
|
1209
1856
|
const workRel = 'Formal/' + target + '.lean'
|
|
1857
|
+
const sha = leanContentSha(body)
|
|
1858
|
+
const plan = leanBuildPlan()
|
|
1859
|
+
const ctx = leanPlanContext(plan)
|
|
1860
|
+
const fp = leanJobFingerprint(body, ctx)
|
|
1861
|
+
const jobId = leanJobId(target, fp)
|
|
1862
|
+
// 去重(§4.3):对象已 passed 且归档证明与新提交内容一致 ⇒ 跳过。
|
|
1863
|
+
const dedupe = await leanDedupeLookup(jobId, null, sha, target)
|
|
1864
|
+
if (dedupe) {
|
|
1865
|
+
logActivity('formal', (memberId || 'office') + ' 归档证明 ' + target + ' 命中去重(已 passed 且内容一致)')
|
|
1866
|
+
return { ok: true, kind: kind, target: target, file: workRel, proof: dedupe.file, passed: true, deduped: true, sha256: sha, buildSha256: fp, status: 'passed', note: '该对象已是 passed,且归档证明与新提交内容一致:跳过重写与重编译(去重)。' }
|
|
1867
|
+
}
|
|
1868
|
+
if (params.leanAsync !== false) {
|
|
1869
|
+
if (!await writeText(workRel, body)) return { ok: false, code: 'V2_WRITE_FAILED', message: 'could not write ' + workRel }
|
|
1870
|
+
const prevA = formalOf(target)
|
|
1871
|
+
await putFormal(target, Object.assign({}, prevA, {
|
|
1872
|
+
status: prevA.status === 'passed' ? 'passed' : 'attempted',
|
|
1873
|
+
file: workRel,
|
|
1874
|
+
decision: 'used',
|
|
1875
|
+
note: String(args.note || prevA.note || ''),
|
|
1876
|
+
async: { jobId: jobId, state: 'queued', attempts: 0, enqueuedAt: now(), startedAt: 0, settledAt: 0, exitCode: null, buildSha256: fp },
|
|
1877
|
+
updatedAt: now(),
|
|
1878
|
+
}))
|
|
1879
|
+
await writeFormalIndex()
|
|
1880
|
+
const job = await enqueueLeanJob({ jobId: jobId, kind: 'archive', key: target, rel: workRel, scope: 'project', target: target, sha: sha, buildSha: fp, buildCtx: ctx, plan: plan, memberId: memberId, archive: { kind: 'proof', target: target } })
|
|
1881
|
+
return leanAsyncReturn(job, {
|
|
1882
|
+
kind: kind, target: target, file: workRel, status: 'attempted', sha256: sha, buildSha256: fp,
|
|
1883
|
+
note: '工作文件已写入 ' + workRel + ';编译已入队。**只有该作业在同一个构建上下文下落地为通过,才会写入 Verified/Lean/' + target + '.lean 并把状态变为 passed**——在它落地之前,这个对象不是"已通过形式化"。',
|
|
1884
|
+
})
|
|
1885
|
+
}
|
|
1210
1886
|
if (!await writeText(workRel, body)) return { ok: false, code: 'V2_WRITE_FAILED', message: 'could not write ' + workRel }
|
|
1211
1887
|
const run = await leanRunFile(workRel)
|
|
1212
1888
|
const prev = formalOf(target)
|
|
@@ -1514,10 +2190,13 @@ export function apply(ctx) {
|
|
|
1514
2190
|
// 提示词里不能出现任何 Lean 字样(contract §2 / §10.1)。
|
|
1515
2191
|
function formalWorkSection() {
|
|
1516
2192
|
const t = formalWorkLine()
|
|
1517
|
-
|
|
2193
|
+
const notice = leanNoticeSection()
|
|
2194
|
+
const math = mathWorkLine()
|
|
2195
|
+
if (!t && !notice && !math) return ''
|
|
1518
2196
|
// 回执契约(契约 §6.3):工作轮也必须被告知 formal 字段,否则"顺手形式化"里做出的难度判断
|
|
1519
2197
|
// 无处可写,代理只能沉默——那正是 v2 首版死通道的成因。
|
|
1520
|
-
|
|
2198
|
+
// 数学计算可用性行(prompts.md §2+§4):与"顺手形式化"并列注入;off 档为空串。
|
|
2199
|
+
return '\n' + t + formalReplyNote() + notice + math + '\n'
|
|
1521
2200
|
}
|
|
1522
2201
|
function capabilitiesText(role) {
|
|
1523
2202
|
const maxCalls = role === 'solver' ? params.solverMaxToolCalls : params.verifierMaxToolCalls
|
|
@@ -1759,11 +2438,18 @@ export function apply(ctx) {
|
|
|
1759
2438
|
await maybeWriteReport(false)
|
|
1760
2439
|
const qs = await getQs()
|
|
1761
2440
|
const unsolved = qs.filter(function (q) { return !q.已解决 && q.优先级 !== 'never' })
|
|
1762
|
-
|
|
1763
|
-
|
|
2441
|
+
// 收口判据(spec v2 §A2/§A3):**排除**论文撰写子代理——它自己不能阻止收口,也不该被算作"在途工作"。
|
|
2442
|
+
const realAgents = Object.keys(agentRegistry).filter(function (cid) { const m = agentRegistry[cid]; return m && m.role !== 'paper' }).length
|
|
2443
|
+
if (unsolved.length === 0 && realAgents === 0 && Object.keys(tasks).length === 0) {
|
|
1764
2444
|
logActivity('stop', 'all active problems solved (never-priority problems excluded) and no active agents/tasks — scheduler stopped (strict termination)')
|
|
2445
|
+
// ★ 最终论文(修订 §A2):**必须在 running=false 之前**派遣——停止之后 scheduleTick() 是空操作,
|
|
2446
|
+
// 而且 v2 没有任何激活上限处理,被拒的撰写者会静默消失;派不出去就排队,由 apply 级心跳重试。
|
|
2447
|
+
if (params.finalPaper !== false) {
|
|
2448
|
+
try { await maybeWritePaper('auto') } catch (e) { logActivity('paper', '自动撰写最终论文失败:' + String((e && e.message) || e)) }
|
|
2449
|
+
}
|
|
2450
|
+
scheduler.running = false
|
|
1765
2451
|
await saveAll(); await maybeWriteReport(true); await maybePushReport(true)
|
|
1766
|
-
} else if (
|
|
2452
|
+
} else if (realAgents === 0 && Object.keys(tasks).length === 0 && unsolved.length > 0) {
|
|
1767
2453
|
let allBlocked = true
|
|
1768
2454
|
for (let i = 0; i < unsolved.length; i++) {
|
|
1769
2455
|
const prog = parseProgress(unsolved[i])
|
|
@@ -2663,15 +3349,507 @@ export function apply(ctx) {
|
|
|
2663
3349
|
// 卡片写入结果 → 布尔(向后兼容既有的 if (await writeVerifiedCardIfNeeded(p)) 语义)。
|
|
2664
3350
|
function cardOk(r) { return !!(r && r.ok) }
|
|
2665
3351
|
|
|
3352
|
+
// ==========================================================================================
|
|
3353
|
+
// 最终论文(规格:docs/final-paper.md(含本轮修订))—— 会话侧。
|
|
3354
|
+
//
|
|
3355
|
+
// 时序(修订 §A2):v2 的收口信号是严格终止(本文件 tick() 的 branch,见 `strict termination`)。
|
|
3356
|
+
// `scheduler.running=false` 之后 `scheduleTick()` 变空操作,所以**撰写子代理必须在停止翻转之前**
|
|
3357
|
+
// 派遣;派不出去(宿主激活上限 ACTIVATION_LIMIT_REACHED)时排队,由 apply 级心跳
|
|
3358
|
+
// (paperRetryDue/runPaperRetry)重试并给出可见告警。子代理的回复由 onChildEnd 的 `paper` 分支
|
|
3359
|
+
// 处理(未知 role 会静默丢失)。收口判据里 paper 子代理被排除(realAgents),避免它自己阻止收口。
|
|
3360
|
+
// 落盘(修订 §C/§D):md/tex 是文本(走宿主 fs),**paper.pdf 只能由编译器子进程生成**,插件只 stat;
|
|
3361
|
+
// paper.meta.json 最后写(提交点)。幂等 = run id + finalizedAt + 逐产物存在性(不引入材料稳定哈希)。
|
|
3362
|
+
// ==========================================================================================
|
|
3363
|
+
let paperInFlight = '' // 正在撰写的 childId(空 = 无)
|
|
3364
|
+
let paperInFlightAt = 0 // 派遣时刻(判定卡死窗口;force 与心跳都据它降级)
|
|
3365
|
+
let paperReaps = 0 // 本 run 内已回收卡死撰写者的次数(自动重派上限)
|
|
3366
|
+
const paperAbandoned = {} // childId -> true:已放弃的撰写者;它事后返回时输出被丢弃并留痕
|
|
3367
|
+
let paperPending = null // 激活上限排队重试:{ tries, retryAt, reason, limit, trigger, opts }
|
|
3368
|
+
const PAPER_RETRY_MS = 5000 // 激活上限拒绝后的重试间隔
|
|
3369
|
+
const PAPER_MAX_RETRIES = 12 // 连续被拒上限(超过则放弃并给可见告警)
|
|
3370
|
+
const PAPER_STALE_MS = 10 * 60 * 1000 // 撰写者卡死窗口:超过它,force 与心跳都会回收并重派(10 分钟)
|
|
3371
|
+
const PAPER_MAX_AUTO_REAPS = 2 // 心跳自动回收+重派的次数上限,之后交给 /vibe paper force
|
|
3372
|
+
const PAPER_LOCK_STALE_MS = 120000 // paper 作用域锁的失效时间(跨会话/跨进程尽力而为)
|
|
3373
|
+
/** 宿主激活上限(maxActiveSubagents)报错里的人数:v4/v5 用的同一判据。 */
|
|
3374
|
+
function activationLimitFrom(message) { const m = /active child limit:\s*(\d+)/.exec(String(message == null ? '' : message)); return m ? Number(m[1]) : undefined }
|
|
3375
|
+
function paperId(opts) { return paperDirId((opts && opts.id) || currentProject) }
|
|
3376
|
+
function paperDir(id) { return 'Paper/' + paperDirId(id) }
|
|
3377
|
+
function paperAbsDir(id) { return frameworkRoot() + '/' + paperDir(id) }
|
|
3378
|
+
function paperAuthorLine() { return 'Vibe Math V2(单作者:论文撰写子代理)· 项目 ' + currentProject }
|
|
3379
|
+
/**
|
|
3380
|
+
* 本次撰写的参数(spec §1/§2):命令行的 lang=/format= 覆盖参数,参数覆盖默认值。
|
|
3381
|
+
* 非法值已在 paperCommand 里拒绝;这里再做一次保守回退(落到纸面上的参数永远合法)。
|
|
3382
|
+
*/
|
|
3383
|
+
function paperOpts(opts) {
|
|
3384
|
+
const o = opts || {}
|
|
3385
|
+
const fmt = (o.format === 'md' || o.format === 'tex' || o.format === 'both') ? o.format : ((params.paperFormat === 'md' || params.paperFormat === 'tex') ? params.paperFormat : 'both')
|
|
3386
|
+
const lang = (o.lang === 'en' || o.lang === 'zh') ? o.lang : (params.paperLanguage === 'en' ? 'en' : 'zh')
|
|
3387
|
+
const compilePdf = (o.compilePdf === true || o.compilePdf === false) ? o.compilePdf : (params.paperCompilePdf !== false)
|
|
3388
|
+
return { format: fmt, lang: lang, compilePdf: compilePdf, force: o.force === true, id: o.id }
|
|
3389
|
+
}
|
|
3390
|
+
async function paperReadMeta(id) { const m = await readJson(paperDir(id) + '/paper.meta.json'); return (m && typeof m === 'object' && !Array.isArray(m)) ? m : undefined }
|
|
3391
|
+
async function paperPathExists(rel) { try { const t = await fsTarget(rel); return (await fs.stat(t)) !== undefined } catch (e) { return false } }
|
|
3392
|
+
async function paperAppendLog(id, tag, text) {
|
|
3393
|
+
const rel = paperDir(id) + '/paper.log.md'
|
|
3394
|
+
const prev = await readText(rel)
|
|
3395
|
+
const head = (prev === undefined || !String(prev).trim()) ? (paperLogHead(id).join('\n') + '\n') : String(prev)
|
|
3396
|
+
await writeText(rel, head + paperLogLine(now(), tag, text) + '\n')
|
|
3397
|
+
}
|
|
3398
|
+
/**
|
|
3399
|
+
* paper 作用域锁(修订 §A3;v3 在触发前已释放项目锁,两个会话可能同时写同一棵 Paper/ 树)。
|
|
3400
|
+
* 尽力而为:同一会话总是可重入(用于续租 at);别的会话持有的锁未过期则拒绝。
|
|
3401
|
+
*/
|
|
3402
|
+
async function paperLockAcquire(id) {
|
|
3403
|
+
const rel = paperDir(id) + '/paper.lock.json'
|
|
3404
|
+
const cur = await readJson(rel)
|
|
3405
|
+
if (cur && cur.sessionId && cur.sessionId !== sessionId && (now() - Number(cur.at || 0)) < PAPER_LOCK_STALE_MS) return { ok: false, holder: String(cur.sessionId), at: Number(cur.at || 0) }
|
|
3406
|
+
await writeJson(rel, { sessionId: sessionId, at: now(), project: currentProject })
|
|
3407
|
+
return { ok: true }
|
|
3408
|
+
}
|
|
3409
|
+
async function paperLockRelease(id) {
|
|
3410
|
+
const rel = paperDir(id) + '/paper.lock.json'
|
|
3411
|
+
const cur = await readJson(rel)
|
|
3412
|
+
if (cur && cur.sessionId === sessionId) await writeJson(rel, { sessionId: '', at: now(), released: true })
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* 回收一个卡死的撰写者(修订 §1:死掉的撰写者绝不能把论文永久卡住)。
|
|
3416
|
+
* 做法:清状态 + 尽力中断 + 从 agentRegistry 摘除(否则它一直算"活跃子代理",也会挡住后续派发)
|
|
3417
|
+
* + 记进 paperAbandoned(事后返回的输出被明确丢弃并留痕,不是静默消失)+ 可见告警。
|
|
3418
|
+
*/
|
|
3419
|
+
async function paperReapWriter(id, why) {
|
|
3420
|
+
const cid = paperInFlight
|
|
3421
|
+
paperInFlight = ''; paperInFlightAt = 0
|
|
3422
|
+
if (cid) {
|
|
3423
|
+
try { await interruptChild(cid) } catch (e) { /* best effort */ }
|
|
3424
|
+
delete agentRegistry[cid]
|
|
3425
|
+
paperAbandoned[cid] = true
|
|
3426
|
+
}
|
|
3427
|
+
await paperAppendLog(id, 'reap', '回收卡死的撰写者 ' + (cid || '(未知)') + ':' + why + '(论文不会被永久卡住;force/心跳会继续)')
|
|
3428
|
+
logActivity('paper', '回收卡死的论文撰写子代理 ' + (cid || '(未知)') + ':' + why)
|
|
3429
|
+
console.error('vibe-math-v2: reaped a stalled paper writer (' + (cid || 'unknown') + '): ' + why)
|
|
3430
|
+
reportDirty = true
|
|
3431
|
+
return cid
|
|
3432
|
+
}
|
|
3433
|
+
function paperWriterVerdictNow(force) {
|
|
3434
|
+
return paperWriterVerdict({ inFlight: !!paperInFlight, force: !!force, ageMs: now() - Number(paperInFlightAt || 0), staleMs: PAPER_STALE_MS, childInRegistry: paperInFlight ? (agentRegistry[paperInFlight] !== undefined) : true })
|
|
3435
|
+
}
|
|
3436
|
+
function paperOneLine(s, cap) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, cap || 400) }
|
|
3437
|
+
/** 证据索引(spec §3.9):只列**确实存在**的文件;绝不含 Paper/ 自己(否则输入哈希会自我污染)。
|
|
3438
|
+
* off 档必须真的是无操作(formal-verify-v2 的语料守卫会断言"off 的提示词里零 Lean 文本"):
|
|
3439
|
+
* formal 未启用时,形式化相关的路径与字段一概不写进材料。 */
|
|
3440
|
+
async function paperEvidenceIndex() {
|
|
3441
|
+
const formalNow = formalOn()
|
|
3442
|
+
const out = ['qs/qs.json']
|
|
3443
|
+
if (formalNow && await paperPathExists('VibeMath_State/formal.json')) out.push('VibeMath_State/formal.json')
|
|
3444
|
+
for (const f of await listFiles('Propos')) out.push('Propos/' + f)
|
|
3445
|
+
for (const f of await listFiles('Verified')) out.push('Verified/' + f)
|
|
3446
|
+
if (formalNow) for (const f of await listFiles('Verified/Lean')) out.push('Verified/Lean/' + f)
|
|
3447
|
+
const dirs = ['Verification_logs', 'Progress_Logs'].concat(formalNow ? ['Formal'] : [])
|
|
3448
|
+
for (let i = 0; i < dirs.length; i++) { const files = await listFiles(dirs[i]); for (let j = 0; j < files.length; j++) out.push(dirs[i] + '/' + files[j]) }
|
|
3449
|
+
return out.filter(function (x) { return x.indexOf('Paper/') !== 0 }).sort()
|
|
3450
|
+
}
|
|
3451
|
+
/** 汇总材料(spec §4):只含既有证据 + 未决/被否证的显式标注。不含时间戳 ⇒ 哈希稳定。 */
|
|
3452
|
+
async function buildPaperDigest() {
|
|
3453
|
+
const L = []
|
|
3454
|
+
L.push('PRESET: vibe-math-v2 (single-author)')
|
|
3455
|
+
L.push('PROJECT: ' + currentProject)
|
|
3456
|
+
const qs = await getQs()
|
|
3457
|
+
L.push('')
|
|
3458
|
+
L.push('[ORIGINAL PROBLEMS] (qs/qs.json)')
|
|
3459
|
+
if (qs.length === 0) L.push('- (none)')
|
|
3460
|
+
for (let i = 0; i < qs.length; i++) {
|
|
3461
|
+
const q = qs[i]
|
|
3462
|
+
L.push('- id=' + q.id + ' | 已解决=' + (q.已解决 === true) + ' | 优先级=' + q.优先级 + ' | 概述=' + paperOneLine(q.概述, 800))
|
|
3463
|
+
const sols = q.解法列表 || []
|
|
3464
|
+
if (sols.length === 0) L.push(' · 解法:无')
|
|
3465
|
+
for (let j = 0; j < sols.length; j++) {
|
|
3466
|
+
const s = sols[j]
|
|
3467
|
+
L.push(' · 解法#' + j + ' 正确概率=' + s.正确概率 + ' 已验=' + (s.已验 === true) + ' 证据=Verification_logs/ | ' + paperOneLine(s.完整解法, 1200))
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
const props = await getPropos()
|
|
3471
|
+
L.push('')
|
|
3472
|
+
L.push('[PROPOSITIONS] (Propos/*.json)')
|
|
3473
|
+
if (props.length === 0) L.push('- (none)')
|
|
3474
|
+
for (let i = 0; i < props.length; i++) {
|
|
3475
|
+
const p = props[i]
|
|
3476
|
+
const rec = formalOn() ? formalOf(p.id) : null // off 档不把形式化字段写进材料(off 是真无操作)
|
|
3477
|
+
L.push('- id=' + p.id + ' | 布尔估计=' + p.布尔估计 + ' | 状态=' + (p.已验证 ? '已验证' : '未定论') + ' | 优先级=' + p.优先级 + ' | 价值/关键性=' + p['价值/关键性'] + (rec ? (' | 形式化=' + ((rec && rec.status) || 'none') + (rec && rec.proof ? ('(' + rec.proof + ')') : '')) : '') + ' | 概述=' + paperOneLine(p.概述, 800))
|
|
3478
|
+
const sides = [['证明', p.证明列表 || []], ['证伪', p.证伪列表 || []]]
|
|
3479
|
+
for (let k = 0; k < sides.length; k++) {
|
|
3480
|
+
const arr = sides[k][1]
|
|
3481
|
+
for (let j = 0; j < arr.length; j++) if (arr[j] && arr[j].正确概率 === 1) L.push(' · 已检验通过:' + sides[k][0] + '#' + j + '(正确概率=1) | ' + paperOneLine(arr[j].完整过程, 900))
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
L.push('')
|
|
3485
|
+
L.push('[UNRESOLVED / REFUTED — 论文里必须显式标注,不得当成已成立的结论]')
|
|
3486
|
+
let any = false
|
|
3487
|
+
for (let i = 0; i < qs.length; i++) {
|
|
3488
|
+
const sols = qs[i].解法列表 || []
|
|
3489
|
+
for (let j = 0; j < sols.length; j++) if (!(sols[j].正确概率 === 1 || sols[j].正确概率 === 0)) { any = true; L.push('- 未定论:问题 ' + qs[i].id + ' 的解法#' + j + '(正确概率=' + sols[j].正确概率 + ')') }
|
|
3490
|
+
}
|
|
3491
|
+
for (let i = 0; i < props.length; i++) {
|
|
3492
|
+
if (!(props[i].布尔估计 === 1 || props[i].布尔估计 === 0)) { any = true; L.push('- 未定论:命题 ' + props[i].id + '(布尔估计=' + props[i].布尔估计 + ')') }
|
|
3493
|
+
else if (props[i].布尔估计 === 0) L.push('- 已被否证:命题 ' + props[i].id + '(布尔估计=0)')
|
|
3494
|
+
}
|
|
3495
|
+
if (!any) L.push('- (none — 所有对象均已定论)')
|
|
3496
|
+
L.push('')
|
|
3497
|
+
L.push('[EVIDENCE INDEX] (only files that exist)')
|
|
3498
|
+
const ev = await paperEvidenceIndex()
|
|
3499
|
+
for (let i = 0; i < ev.length; i++) L.push('- ' + ev[i])
|
|
3500
|
+
return L.join('\n')
|
|
3501
|
+
}
|
|
3502
|
+
/** 「论文撰写」子代理提示词(单作者;v2/v3 同一形状)。 */
|
|
3503
|
+
function paperWriterPrompt(digest, o) {
|
|
3504
|
+
const langName = o.lang === 'en' ? 'English' : '中文(Chinese)'
|
|
3505
|
+
return 'You are the DEDICATED PAPER WRITER (single-author mode) of a math research run that has just CONVERGED.\n' +
|
|
3506
|
+
'Write its final paper in ' + langName + ', using ONLY the evidence in the MATERIAL section below.\n\n' +
|
|
3507
|
+
'HARD RULES:\n' +
|
|
3508
|
+
'- NEVER invent content: no new proposition, no new computation, no citation that is not in the MATERIAL.\n' +
|
|
3509
|
+
'- Unresolved or refuted items MUST be explicitly labelled (「未定论」/「已被否证」, or "unresolved"/"refuted" in English); never present them as established.\n' +
|
|
3510
|
+
'- Fixed 9-section skeleton — provide bodies for these EXACT `## ` headings (the framework writes the headings, author/date and the evidence index itself):\n' +
|
|
3511
|
+
PAPER_SKELETON.map(function (s, i) { return ' ' + (i + 1) + '. ' + s.key + ' — ' + s.spec }).join('\n') + '\n' +
|
|
3512
|
+
'- A section with no evidence must be exactly 「' + PAPER_NO_EVIDENCE + '」 (do not pad it).\n' +
|
|
3513
|
+
'- Markdown subset only: `#`/`##`/`###`, `- ` lists, `**bold**`, `*em*`, `` `code` ``, and inline math as `$...$`. No tables, images, footnotes or raw HTML.\n\n' +
|
|
3514
|
+
'OUTPUT CONTRACT — respond with ONLY one ```json code fence, no prose:\n' +
|
|
3515
|
+
'{"title":"<paper title>","abstract":"<original problem + main results>","sections":[{"name":"<one of the 9 headings>","body":"<markdown>"}, ...]}\n\n' +
|
|
3516
|
+
'MATERIAL (evidence only — do not add anything beyond it):\n' + digest
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* LaTeX 检测(spec §5 + 修订 §D):`paperLatexCommand` 非空时只用它(不猜);
|
|
3520
|
+
* 否则中文优先 xelatex、英文优先 pdflatex/latexmk。缺 subprocess/resolveExecutable 一律
|
|
3521
|
+
* 视为"未检测到"(照 LEAN_NOT_FOUND 的降级方式,永不抛错)。
|
|
3522
|
+
*/
|
|
3523
|
+
async function paperDetectLatex(lang) {
|
|
3524
|
+
const sub = subprocessOf()
|
|
3525
|
+
if (sub === undefined || typeof sub.spawn !== 'function') return { available: [], reason: 'no-subprocess' }
|
|
3526
|
+
if (typeof sub.resolveExecutable !== 'function') return { available: [], reason: 'no-resolveExecutable' }
|
|
3527
|
+
const forced = String(params.paperLatexCommand || '').trim()
|
|
3528
|
+
if (forced) {
|
|
3529
|
+
try { const p = await sub.resolveExecutable(forced); return p ? { available: [{ name: forced, path: String(p) }], reason: 'ok', forced: true } : { available: [], reason: 'paperLatexCommand not found: ' + forced, forced: true } }
|
|
3530
|
+
catch (e) { return { available: [], reason: 'paperLatexCommand not found: ' + forced, forced: true } }
|
|
3531
|
+
}
|
|
3532
|
+
const order = lang === 'en' ? PAPER_ENGINE_ORDER_EN : PAPER_ENGINE_ORDER_ZH
|
|
3533
|
+
const available = []
|
|
3534
|
+
for (let i = 0; i < order.length; i++) {
|
|
3535
|
+
try { const p = await sub.resolveExecutable(order[i]); if (p) available.push({ name: order[i], path: String(p) }) } catch (e) { /* 未安装 */ }
|
|
3536
|
+
}
|
|
3537
|
+
return { available: available, reason: available.length ? 'ok' : 'none' }
|
|
3538
|
+
}
|
|
3539
|
+
function paperArgvFor(engine) {
|
|
3540
|
+
if (engine.name === 'latexmk') return [engine.path, '-pdf', '-interaction=nonstopmode', '-halt-on-error', 'paper.tex']
|
|
3541
|
+
if (engine.name === 'tectonic') return [engine.path, '--keep-logs', 'paper.tex']
|
|
3542
|
+
return [engine.path, '-interaction=nonstopmode', '-halt-on-error', 'paper.tex']
|
|
3543
|
+
}
|
|
3544
|
+
/** 一次编译(nonstopmode),带主动超时 terminate(与 leanRunFile 同一模式)。 */
|
|
3545
|
+
async function paperRunOnce(engine, dirAbs, capMs) {
|
|
3546
|
+
const sub = subprocessOf()
|
|
3547
|
+
if (sub === undefined || typeof sub.spawn !== 'function') return { ok: false, exitCode: null, code: 'NO_SUBPROCESS' }
|
|
3548
|
+
let handle
|
|
3549
|
+
try { handle = await spawnHandle(sub, { argv: paperArgvFor(engine), cwd: dirAbs, stdio: { stdin: 'ignore', stdout: { maxBytes: 64 * 1024 }, stderr: { maxBytes: 64 * 1024 } }, graceMs: capMs }) }
|
|
3550
|
+
catch (e) { return { ok: false, exitCode: null, code: 'SPAWN_FAILED', message: String((e && e.message) || e) } }
|
|
3551
|
+
let timedOut = false, timer = null, outcome
|
|
3552
|
+
try {
|
|
3553
|
+
outcome = await Promise.race([
|
|
3554
|
+
handle.done,
|
|
3555
|
+
new Promise(function (resolve) { timer = setTimeout(function () { timedOut = true; try { if (handle && typeof handle.terminate === 'function') handle.terminate() } catch (e) { /* best effort */ } resolve({ exitCode: null }) }, capMs) }),
|
|
3556
|
+
])
|
|
3557
|
+
} catch (e) { if (timer !== null) clearTimeout(timer); return { ok: false, exitCode: null, code: 'RUN_FAILED', message: String((e && e.message) || e) } }
|
|
3558
|
+
if (timer !== null) clearTimeout(timer)
|
|
3559
|
+
let err = ''
|
|
3560
|
+
try { if (handle.collected && handle.collected.stderr) err = String(handle.collected.stderr.readFrom(0).text || '') } catch (e) { /* best effort */ }
|
|
3561
|
+
return { ok: !timedOut && !!outcome && outcome.exitCode === 0, exitCode: outcome ? outcome.exitCode : null, timedOut: timedOut, stderr: err.slice(-1200) }
|
|
3562
|
+
}
|
|
3563
|
+
/**
|
|
3564
|
+
* 编译 paper.tex(spec §5 + 修订 §D/§E):检测 → 主引擎(nonstopmode,两遍)→ 换引擎 → 去不支持
|
|
3565
|
+
* 宏包 → 最小模板再试一次;然后报告并降级(保留 tex+md,不改写论文内容)。
|
|
3566
|
+
* **永不删除/覆盖已有 pdf**(§D):旧的 paper.pdf 原样保留,成功判据 = 本次运行 exit 0 **且** pdf 存在。
|
|
3567
|
+
*/
|
|
3568
|
+
async function paperCompile(id, o, texPrimary, bodyTex) {
|
|
3569
|
+
const relDir = paperDir(id), absDir = paperAbsDir(id)
|
|
3570
|
+
const attempts = []
|
|
3571
|
+
if (!o.compilePdf) return { compile: 'skipped', reason: 'paperCompilePdf=false', engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
|
|
3572
|
+
// §E:tex 未产出(paperFormat=md)时**跳过**编译,且不得报"缺 tex 无法编译"的警告。
|
|
3573
|
+
if (o.format === 'md') return { compile: 'skipped', reason: 'paperFormat=md(不产出 tex,跳过编译)', engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
|
|
3574
|
+
const det = await paperDetectLatex(o.lang)
|
|
3575
|
+
if (det.available.length === 0) {
|
|
3576
|
+
await paperAppendLog(id, 'latex', '未检测到任何 LaTeX 引擎(' + det.reason + ')→ 只保留 paper.tex + paper.md,不编译 pdf(spec §5:不阻塞定稿)')
|
|
3577
|
+
return { compile: 'not-detected', reason: det.reason, engine: null, attempts: attempts, pdfPreserved: await paperPathExists(relDir + '/paper.pdf') }
|
|
3578
|
+
}
|
|
3579
|
+
await paperAppendLog(id, 'latex', '检测到引擎 ' + det.available.map(function (x) { return x.name }).join(', ') + (det.forced ? '(paperLatexCommand 指定)' : ('(顺序 ' + (o.lang === 'en' ? PAPER_ENGINE_ORDER_EN : PAPER_ENGINE_ORDER_ZH).join(' > ') + ')')))
|
|
3580
|
+
const plan = [{ engine: det.available[0], tex: texPrimary, mode: 'primary' }]
|
|
3581
|
+
if (det.available[1]) plan.push({ engine: det.available[1], tex: texPrimary, mode: 'engine-fallback' })
|
|
3582
|
+
plan.push({ engine: det.available[0], tex: paperSanitizeTex(texPrimary), mode: 'drop-unsupported-packages' })
|
|
3583
|
+
plan.push({ engine: det.available[0], tex: paperMinimalTexDoc({ lang: o.lang, bodyTex: bodyTex }), mode: 'minimal-template' })
|
|
3584
|
+
const cap = 120000
|
|
3585
|
+
for (let i = 0; i < plan.length; i++) {
|
|
3586
|
+
const step = plan[i]
|
|
3587
|
+
await writeText(relDir + '/paper.tex', step.tex)
|
|
3588
|
+
const runs = []
|
|
3589
|
+
for (let pass = 0; pass < 2; pass++) runs.push(await paperRunOnce(step.engine, absDir, cap)) // spec §5:跑两遍
|
|
3590
|
+
const last = runs[runs.length - 1]
|
|
3591
|
+
const pdf = !!last.ok && await paperPathExists(relDir + '/paper.pdf')
|
|
3592
|
+
attempts.push({ engine: step.engine.name, mode: step.mode, ok: pdf, exitCode: last.exitCode, timedOut: !!last.timedOut, stderr: last.stderr || '' })
|
|
3593
|
+
await paperAppendLog(id, 'compile', step.mode + ' engine=' + step.engine.name + ' → ' + (pdf ? 'ok(paper.pdf 已生成)' : ('failed(exit=' + String(last.exitCode) + (last.timedOut ? ', timeout' : '') + ')')))
|
|
3594
|
+
if (pdf) return { compile: (i === 0) ? 'ok' : 'repaired', engine: step.engine.name, mode: step.mode, attempts: attempts, pdfPreserved: false }
|
|
3595
|
+
}
|
|
3596
|
+
await writeText(relDir + '/paper.tex', texPrimary) // 失败时保留**主** tex(信息量最大;spec §5 保留 tex+md)
|
|
3597
|
+
const preserved = await paperPathExists(relDir + '/paper.pdf') // §D:已有的 pdf 绝不删除
|
|
3598
|
+
await paperAppendLog(id, 'compile', '所有尝试均失败:保留 paper.tex + paper.md' + (preserved ? '(paper.pdf 是**上一次成功编译**留下的,未被覆盖)' : ',未生成 paper.pdf') + '(不阻塞定稿)')
|
|
3599
|
+
logActivity('paper', '论文 pdf 编译失败(已尝试换引擎/去不支持宏包/最小模板),已保留 tex+md:' + relDir)
|
|
3600
|
+
reportDirty = true
|
|
3601
|
+
return { compile: 'failed', engine: det.available[0].name, attempts: attempts, pdfPreserved: preserved }
|
|
3602
|
+
}
|
|
3603
|
+
/** 落盘 md/tex/pdf/meta(幂等:内容相同则不重写;spec §2/§5)。 */
|
|
3604
|
+
async function paperEmitArtifacts(id, o, info) {
|
|
3605
|
+
const relDir = paperDir(id)
|
|
3606
|
+
const dateStr = fmtTime(now()).slice(0, 10)
|
|
3607
|
+
const author = paperAuthorLine()
|
|
3608
|
+
const evidence = info.evidence || (await paperEvidenceIndex())
|
|
3609
|
+
const title = String(info.title || '').trim() || ('研究报告:' + currentProject)
|
|
3610
|
+
const md = paperBuildMarkdown({ title: title, author: author, date: dateStr, abstract: info.abstract, sections: info.sections, evidence: evidence })
|
|
3611
|
+
const artifacts = { md: false, tex: false, pdf: false }
|
|
3612
|
+
let compile = 'skipped', engine = null, attempts = [], pdfPreserved = false
|
|
3613
|
+
if (o.format === 'md' || o.format === 'both') { await writeText(relDir + '/paper.md', md); artifacts.md = true }
|
|
3614
|
+
if (o.format === 'tex' || o.format === 'both') {
|
|
3615
|
+
const bodyTex = paperMdToTexBody(md)
|
|
3616
|
+
const tex = paperTexDoc({ title: title, author: author, date: dateStr, lang: o.lang, bodyTex: bodyTex })
|
|
3617
|
+
await writeText(relDir + '/paper.tex', tex)
|
|
3618
|
+
artifacts.tex = true
|
|
3619
|
+
const c = await paperCompile(id, o, tex, bodyTex)
|
|
3620
|
+
compile = c.compile; engine = c.engine || null; attempts = c.attempts || []
|
|
3621
|
+
pdfPreserved = !!c.pdfPreserved
|
|
3622
|
+
if (compile === 'ok' || compile === 'repaired') artifacts.pdf = await paperPathExists(relDir + '/paper.pdf')
|
|
3623
|
+
}
|
|
3624
|
+
const filled = PAPER_SKELETON.map(function (s) { return s.key }).filter(function (k) { return !!(info.sections && String(info.sections[k] || '').trim()) })
|
|
3625
|
+
const meta = {
|
|
3626
|
+
id: id, dir: relDir, preset: 'vibe-math-v2', singleAuthor: true, project: currentProject,
|
|
3627
|
+
title: title, author: author, date: dateStr, abstract: String(info.abstract || ''),
|
|
3628
|
+
sectionBodies: info.sections || {}, sections: PAPER_SKELETON.map(function (s) { return s.key }), filledSections: filled,
|
|
3629
|
+
raw: String(info.raw == null ? '' : info.raw), notes: Array.isArray(info.notes) ? info.notes : [],
|
|
3630
|
+
trigger: info.trigger || 'manual', runStartedAt: Number(scheduler.startedAt) || 0,
|
|
3631
|
+
params: { finalPaper: params.finalPaper !== false, paperFormat: o.format, paperLanguage: o.lang, paperCompilePdf: !!o.compilePdf, paperLatexCommand: String(params.paperLatexCommand || '') },
|
|
3632
|
+
finalizedAt: now(), finalizedAtText: fmtTime(now()),
|
|
3633
|
+
artifacts: artifacts, compile: compile, compileEngine: engine, compileAttempts: attempts, pdfPreserved: pdfPreserved,
|
|
3634
|
+
material: info.material || null, evidence: evidence,
|
|
3635
|
+
}
|
|
3636
|
+
// meta **最后**写:它是"这次定稿已提交"的唯一标记(修订 §C 的原子提交点)。
|
|
3637
|
+
await writeJson(relDir + '/paper.meta.json', meta)
|
|
3638
|
+
return meta
|
|
3639
|
+
}
|
|
3640
|
+
async function paperMissingArtifacts(id, o) {
|
|
3641
|
+
const relDir = paperDir(id)
|
|
3642
|
+
const need = []
|
|
3643
|
+
if (o.format === 'md' || o.format === 'both') { if (!(await paperPathExists(relDir + '/paper.md'))) need.push('paper.md') }
|
|
3644
|
+
if (o.format === 'tex' || o.format === 'both') { if (!(await paperPathExists(relDir + '/paper.tex'))) need.push('paper.tex') }
|
|
3645
|
+
return need
|
|
3646
|
+
}
|
|
3647
|
+
/**
|
|
3648
|
+
* 派遣一次(内部):材料摘要只作记录(修订 §C 不再用稳定哈希);激活上限拒绝 → 排队重试(§A2)。
|
|
3649
|
+
*/
|
|
3650
|
+
async function paperDispatch(trigger, o, id, prevTries) {
|
|
3651
|
+
const relDir = paperDir(id)
|
|
3652
|
+
const digest = await buildPaperDigest()
|
|
3653
|
+
let childId
|
|
3654
|
+
try {
|
|
3655
|
+
childId = await spawnChild('paper-writer:' + id, paperWriterPrompt(digest, o), { role: 'paper', paperId: id, trigger: trigger, lang: o.lang, format: o.format, compilePdf: o.compilePdf })
|
|
3656
|
+
} catch (e) {
|
|
3657
|
+
const message = String((e && e.message) || e)
|
|
3658
|
+
const limit = activationLimitFrom(message)
|
|
3659
|
+
if (limit !== undefined || /ACTIVATION_LIMIT_REACHED|active child limit/i.test(message)) {
|
|
3660
|
+
const tries = Number(prevTries || 0) + 1
|
|
3661
|
+
if (tries > PAPER_MAX_RETRIES) {
|
|
3662
|
+
paperPending = null
|
|
3663
|
+
await paperAppendLog(id, 'dispatch-failed', '宿主激活上限连续拒绝 ' + PAPER_MAX_RETRIES + ' 次,已放弃自动派遣:' + message)
|
|
3664
|
+
logActivity('paper', '论文撰写子代理被宿主激活上限连续拒绝(' + PAPER_MAX_RETRIES + ' 次),已放弃自动派遣(稍后可 /vibe paper force 重试):' + message)
|
|
3665
|
+
console.error('vibe-math-v2: paper writer refused by the host activation limit ' + PAPER_MAX_RETRIES + ' times; giving up (retry later with /vibe paper force) — ' + message)
|
|
3666
|
+
await paperLockRelease(id)
|
|
3667
|
+
return { ok: false, message: 'paper writer refused by the activation limit ' + PAPER_MAX_RETRIES + ' times: ' + message, id: id, dir: relDir, limit: limit }
|
|
3668
|
+
}
|
|
3669
|
+
paperPending = { tries: tries, retryAt: now() + PAPER_RETRY_MS, reason: message, limit: limit, trigger: trigger, opts: { lang: o.lang, format: o.format, compilePdf: o.compilePdf } }
|
|
3670
|
+
if (tries === 1 || tries % 3 === 0) {
|
|
3671
|
+
await paperAppendLog(id, 'dispatch-queued', '宿主激活上限(' + (limit === undefined ? '?' : limit) + ')拒绝派遣(第 ' + tries + ' 次):' + (PAPER_RETRY_MS / 1000) + 's 后重试')
|
|
3672
|
+
logActivity('paper', '论文撰写子代理被宿主激活上限拒绝(第 ' + tries + ' 次),已排队重试:' + message)
|
|
3673
|
+
console.error('vibe-math-v2: paper writer queued after an activation-limit refusal (try ' + tries + '): ' + message)
|
|
3674
|
+
}
|
|
3675
|
+
reportDirty = true
|
|
3676
|
+
return { ok: true, queued: true, reason: 'activation-limit-reached', tries: tries, retryAt: paperPending.retryAt, limit: limit, id: id, dir: relDir }
|
|
3677
|
+
}
|
|
3678
|
+
await paperAppendLog(id, 'dispatch-failed', message)
|
|
3679
|
+
logActivity('paper', '「论文撰写」子代理派遣失败:' + message)
|
|
3680
|
+
await paperLockRelease(id)
|
|
3681
|
+
return { ok: false, message: 'paper writer dispatch failed: ' + message, id: id, dir: relDir }
|
|
3682
|
+
}
|
|
3683
|
+
paperInFlight = childId
|
|
3684
|
+
paperInFlightAt = now()
|
|
3685
|
+
// 只有"非自动重试"的派遣才重置回收计数:auto-retry 必须保留计数,否则卡死重派会无限循环。
|
|
3686
|
+
if (trigger !== 'auto-retry') paperReaps = 0
|
|
3687
|
+
paperPending = null
|
|
3688
|
+
const material = paperMaterialSummary(digest)
|
|
3689
|
+
await paperAppendLog(id, 'dispatch', 'trigger=' + trigger + ' child=' + childId + ' format=' + o.format + ' lang=' + o.lang + ' compilePdf=' + o.compilePdf + (o.force ? ' force=true' : '') + ' run=' + (Number(scheduler.startedAt) || 0) + ' material=' + material.chars + ' chars/' + material.lines + ' lines')
|
|
3690
|
+
logActivity('paper', '已派遣「论文撰写」子代理(' + trigger + ',' + o.format + '/' + o.lang + ')→ ' + relDir)
|
|
3691
|
+
reportDirty = true
|
|
3692
|
+
return { ok: true, dispatched: true, childId: childId, id: id, dir: relDir, trigger: trigger, format: o.format, lang: o.lang, compilePdf: o.compilePdf }
|
|
3693
|
+
}
|
|
3694
|
+
/**
|
|
3695
|
+
* 触发入口(spec §2/§3 + 修订 §A2/§A3):自动(收口)与手动命令共用。
|
|
3696
|
+
* 幂等 = **同一 run(scheduler.startedAt)+ finalizedAt + 逐产物存在性**(修订 §C);force 跳过全部检查。
|
|
3697
|
+
* 已有定稿但缺产物 → 用已存内容补写(不重新派遣作者)。别的会话持有 paper 锁 → 跳过并留痕。
|
|
3698
|
+
*/
|
|
3699
|
+
async function maybeWritePaper(trigger, opts) {
|
|
3700
|
+
const o = paperOpts(opts)
|
|
3701
|
+
const id = paperId(opts)
|
|
3702
|
+
const relDir = paperDir(id)
|
|
3703
|
+
const meta = await paperReadMeta(id)
|
|
3704
|
+
const sameRun = !!meta && Number(meta.runStartedAt || 0) === (Number(scheduler.startedAt) || 0)
|
|
3705
|
+
if (!o.force && meta && meta.finalizedAt && sameRun) {
|
|
3706
|
+
const missing = await paperMissingArtifacts(id, o)
|
|
3707
|
+
if (missing.length === 0) {
|
|
3708
|
+
await paperAppendLog(id, 'skip', '幂等:同一 run(' + (Number(scheduler.startedAt) || 0) + ')已有定稿且产物齐全 → 不重复撰写(/vibe paper force 可重写)')
|
|
3709
|
+
return { ok: true, skipped: true, reason: 'already-finalized-this-run', id: id, dir: relDir, finalizedAt: meta.finalizedAt, artifacts: meta.artifacts || {}, compile: meta.compile, note: '同一 run 只写一次;重复触发只补写缺失产物(spec §2)' }
|
|
3710
|
+
}
|
|
3711
|
+
await paperAppendLog(id, 'fill', '幂等:已有定稿但缺少 ' + missing.join(', ') + ' → 用已存的定稿内容补写(不重新派遣作者)')
|
|
3712
|
+
const filled = await paperEmitArtifacts(id, o, { title: meta.title, abstract: meta.abstract, sections: meta.sectionBodies || {}, evidence: meta.evidence, raw: meta.raw, notes: (meta.notes || []).concat(['补写缺失产物:' + missing.join(', ')]), trigger: meta.trigger || trigger, material: meta.material })
|
|
3713
|
+
return { ok: true, skipped: true, reason: 'filled-missing-artifacts', filled: missing, id: id, dir: relDir, finalizedAt: filled.finalizedAt, artifacts: filled.artifacts, compile: filled.compile }
|
|
3714
|
+
}
|
|
3715
|
+
if (paperInFlight) {
|
|
3716
|
+
const verdict = paperWriterVerdictNow(o.force)
|
|
3717
|
+
if (verdict === 'reap') {
|
|
3718
|
+
const gone = agentRegistry[paperInFlight] === undefined
|
|
3719
|
+
await paperReapWriter(id, gone ? '子代理已不在 agentRegistry(end 事件丢失 / 宿主丢弃)' : ('超过卡死窗口 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟未返回'))
|
|
3720
|
+
// 回收后继续走下面的 paper 锁 + 派遣路径(force 与收口重试都是这个语义)
|
|
3721
|
+
} else if (verdict === 'refuse') {
|
|
3722
|
+
const ageMs = now() - Number(paperInFlightAt || 0)
|
|
3723
|
+
await paperAppendLog(id, 'force-refused', '仍有在途撰写者 ' + paperInFlight + '(' + Math.round(ageMs / 1000) + 's):超过 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟才会被 force 放弃')
|
|
3724
|
+
return { ok: false, reason: 'writer-in-flight', childId: paperInFlight, ageMs: ageMs, staleMs: PAPER_STALE_MS, id: id, dir: relDir, message: '已有在途的论文撰写子代理 ' + paperInFlight + '(已 ' + Math.round(ageMs / 1000) + 's 未返回)。等它返回,或等超过 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟后 force 会自动回收并重派(心跳也会自动回收)。' }
|
|
3725
|
+
} else {
|
|
3726
|
+
return { ok: true, skipped: true, reason: 'writer-in-flight', childId: paperInFlight, ageMs: now() - Number(paperInFlightAt || 0), staleMs: PAPER_STALE_MS, id: id, dir: relDir }
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
if (paperPending) return { ok: true, queued: true, reason: 'retry-queued', retryAt: paperPending.retryAt, tries: paperPending.tries, id: id, dir: relDir }
|
|
3730
|
+
const lock = await paperLockAcquire(id)
|
|
3731
|
+
if (!lock.ok) {
|
|
3732
|
+
await paperAppendLog(id, 'skip', 'paper 锁由会话 ' + lock.holder + ' 持有(未过期)→ 本次不派遣(修订 §A3 的 paper 作用域锁)')
|
|
3733
|
+
return { ok: true, skipped: true, reason: 'locked-by-another-session', holder: lock.holder, id: id, dir: relDir }
|
|
3734
|
+
}
|
|
3735
|
+
return await paperDispatch(trigger, o, id, 0)
|
|
3736
|
+
}
|
|
3737
|
+
/** apply 级心跳用:有排队的论文派遣且到点了吗(同步判断,便宜)。 */
|
|
3738
|
+
function paperRetryDue() { return !!(paperPending && now() >= Number(paperPending.retryAt || 0)) }
|
|
3739
|
+
/**
|
|
3740
|
+
* 心跳(修订 §1):在途 → 续租 paper 锁;**卡死则自动回收**(child 不在注册表,或超过卡死窗口),
|
|
3741
|
+
* 并自动重派(上限 PAPER_MAX_AUTO_REAPS,之后给出可执行提示交给 /vibe paper force);有排队则重试派遣。
|
|
3742
|
+
*/
|
|
3743
|
+
async function runPaperRetry() {
|
|
3744
|
+
const id = paperId()
|
|
3745
|
+
if (paperInFlight) {
|
|
3746
|
+
if (paperWriterVerdictNow(false) === 'reap') {
|
|
3747
|
+
const gone = agentRegistry[paperInFlight] === undefined
|
|
3748
|
+
await paperReapWriter(id, gone ? '子代理已不在 agentRegistry(end 事件丢失 / 宿主丢弃)' : ('超过卡死窗口 ' + Math.round(PAPER_STALE_MS / 60000) + ' 分钟未返回'))
|
|
3749
|
+
const meta = await paperReadMeta(id)
|
|
3750
|
+
if (params.finalPaper !== false && !(meta && meta.finalizedAt) && Number(paperReaps || 0) < PAPER_MAX_AUTO_REAPS) {
|
|
3751
|
+
paperReaps = Number(paperReaps || 0) + 1
|
|
3752
|
+
await paperAppendLog(id, 'retry-after-reap', '第 ' + paperReaps + ' 次自动重派(上限 ' + PAPER_MAX_AUTO_REAPS + ')')
|
|
3753
|
+
await paperDispatch('auto-retry', paperOpts({}), id, 0)
|
|
3754
|
+
} else {
|
|
3755
|
+
await paperAppendLog(id, 'reap-giveup', '自动重派已达上限 ' + PAPER_MAX_AUTO_REAPS + '(或已有定稿):不再自动派遣,请用 /vibe paper force 手动重写')
|
|
3756
|
+
logActivity('paper', '论文撰写子代理连续卡死/已放弃:不再自动重派,可用 /vibe paper force 手动重写')
|
|
3757
|
+
}
|
|
3758
|
+
return
|
|
3759
|
+
}
|
|
3760
|
+
try { await paperLockAcquire(id) } catch (e) { /* 续租失败不致命 */ }
|
|
3761
|
+
return
|
|
3762
|
+
}
|
|
3763
|
+
if (!paperRetryDue()) return
|
|
3764
|
+
const p = paperPending
|
|
3765
|
+
paperPending = null
|
|
3766
|
+
const o = paperOpts(p.opts)
|
|
3767
|
+
await paperDispatch(p.trigger || 'auto-retry', o, id, Number(p.tries || 0))
|
|
3768
|
+
}
|
|
3769
|
+
/** `/vibe paper [lang=zh|en] [format=both|md|tex] [force]`(spec §2/§6)。非法参数 → ok:false(命令层转 kind:'error')。 */
|
|
3770
|
+
async function paperCommand(args) {
|
|
3771
|
+
const rest = Array.isArray(args) ? args : []
|
|
3772
|
+
const opts = {}
|
|
3773
|
+
for (let i = 0; i < rest.length; i++) {
|
|
3774
|
+
const a = String(rest[i] || '')
|
|
3775
|
+
if (a === 'force') { opts.force = true; continue }
|
|
3776
|
+
const eq = a.indexOf('=')
|
|
3777
|
+
const k = eq > 0 ? a.slice(0, eq).toLowerCase() : ''
|
|
3778
|
+
const v = eq > 0 ? a.slice(eq + 1).toLowerCase() : ''
|
|
3779
|
+
if (k === 'lang') { if (v !== 'zh' && v !== 'en') return { ok: false, message: '/vibe paper lang= 只接受 zh|en(收到 ' + JSON.stringify(a) + ')' }; opts.lang = v; continue }
|
|
3780
|
+
if (k === 'format') { if (v !== 'both' && v !== 'md' && v !== 'tex') return { ok: false, message: '/vibe paper format= 只接受 both|md|tex(收到 ' + JSON.stringify(a) + ')' }; opts.format = v; continue }
|
|
3781
|
+
return { ok: false, message: 'unknown /vibe paper option: ' + a + '(可用 lang=zh|en、format=both|md|tex、force)' }
|
|
3782
|
+
}
|
|
3783
|
+
const r = await maybeWritePaper('manual', opts)
|
|
3784
|
+
if (r.ok === false) return r
|
|
3785
|
+
const autoNote = params.finalPaper === false ? '(自动触发已关闭 finalPaper=false;手动命令仍可用)' : ''
|
|
3786
|
+
const msg = r.dispatched ? ('已派遣「论文撰写」子代理,产物将写入 ' + r.dir + '/')
|
|
3787
|
+
: r.queued ? ('宿主激活上限已满:论文撰写已排队,' + (PAPER_RETRY_MS / 1000) + 's 后自动重试(第 ' + r.tries + ' 次)')
|
|
3788
|
+
: (r.reason === 'already-finalized-this-run' ? '同一 run 已有定稿且产物齐全:不重复撰写(需要重写请用 /vibe paper force)'
|
|
3789
|
+
: (r.reason === 'filled-missing-artifacts' ? ('已用既有定稿补写缺失产物:' + (r.filled || []).join(', ')) : ('未重新派遣:' + (r.reason || ''))))
|
|
3790
|
+
return Object.assign({}, r, { message: msg + autoNote, autoDisabled: params.finalPaper === false })
|
|
3791
|
+
}
|
|
3792
|
+
/** 论文撰写子代理结束(spec §4):解析回复 → 组装 9 节 → 落盘 → 编译 → 记日志 → 释放 paper 锁。 */
|
|
3793
|
+
async function handlePaperWriter(childId, meta, output) {
|
|
3794
|
+
const id = meta.paperId || paperId()
|
|
3795
|
+
paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0
|
|
3796
|
+
delete paperAbandoned[childId]
|
|
3797
|
+
const o = paperOpts({ lang: meta.lang, format: meta.format, compilePdf: meta.compilePdf })
|
|
3798
|
+
const parsed = parseJson(output)
|
|
3799
|
+
const info = paperSkeletonFromReply(parsed, output)
|
|
3800
|
+
if (!info.title) info.title = paperTitleFromMd(String(output || '')) || ('研究报告:' + currentProject)
|
|
3801
|
+
for (let i = 0; i < info.notes.length; i++) await paperAppendLog(id, 'note', info.notes[i])
|
|
3802
|
+
if (!String(output || '').trim()) await paperAppendLog(id, 'reply', '作者回复为空:只写骨架 + 占位说明(绝不编造内容)')
|
|
3803
|
+
let written
|
|
3804
|
+
try {
|
|
3805
|
+
written = await paperEmitArtifacts(id, o, { title: info.title, abstract: info.abstract, sections: info.sections, raw: String(output || ''), notes: info.notes, trigger: meta.trigger || 'auto', material: paperMaterialSummary(await buildPaperDigest()) })
|
|
3806
|
+
} catch (e) {
|
|
3807
|
+
await paperAppendLog(id, 'finalize-failed', String((e && e.message) || e))
|
|
3808
|
+
logActivity('paper', '论文落盘失败:' + String((e && e.message) || e))
|
|
3809
|
+
await paperLockRelease(id)
|
|
3810
|
+
return
|
|
3811
|
+
}
|
|
3812
|
+
await paperAppendLog(id, 'finalize', '产物 ' + JSON.stringify(written.artifacts) + ' compile=' + written.compile + (written.compileEngine ? (' engine=' + written.compileEngine) : '') + ' run=' + (Number(scheduler.startedAt) || 0))
|
|
3813
|
+
logActivity('paper', '论文(最终稿)已落盘:' + paperDir(id) + '/(' + Object.keys(written.artifacts).filter(function (k) { return written.artifacts[k] }).join('/') + ',编译=' + written.compile + ')')
|
|
3814
|
+
await paperLockRelease(id)
|
|
3815
|
+
reportDirty = true
|
|
3816
|
+
await saveAll()
|
|
3817
|
+
}
|
|
3818
|
+
/** status 里的论文视图(可观测性;命令与守卫都读它)。 */
|
|
3819
|
+
async function paperStatusView() {
|
|
3820
|
+
const id = paperId()
|
|
3821
|
+
const meta = await paperReadMeta(id)
|
|
3822
|
+
return {
|
|
3823
|
+
id: id, dir: paperDir(id), inFlight: paperInFlight || null,
|
|
3824
|
+
inFlightSince: paperInFlight ? Number(paperInFlightAt || 0) : null,
|
|
3825
|
+
inFlightAgeMs: paperInFlight ? (now() - Number(paperInFlightAt || 0)) : null,
|
|
3826
|
+
staleMs: PAPER_STALE_MS, reapedThisRun: Number(paperReaps || 0),
|
|
3827
|
+
queued: paperPending ? { tries: paperPending.tries, retryAt: paperPending.retryAt, reason: paperPending.reason, limit: paperPending.limit === undefined ? null : paperPending.limit } : null,
|
|
3828
|
+
autoFinalPaper: params.finalPaper !== false, format: params.paperFormat, language: params.paperLanguage, compilePdf: params.paperCompilePdf !== false,
|
|
3829
|
+
finalizedAt: meta ? meta.finalizedAt : null, artifacts: meta ? (meta.artifacts || {}) : {}, compile: meta ? meta.compile : null,
|
|
3830
|
+
}
|
|
3831
|
+
}
|
|
3832
|
+
|
|
2666
3833
|
// ================= child result dispatch =================
|
|
2667
3834
|
async function onChildEnd(info) {
|
|
2668
3835
|
const meta = agentRegistry[info.id]
|
|
2669
|
-
if (meta === undefined)
|
|
3836
|
+
if (meta === undefined) {
|
|
3837
|
+
// 已放弃(回收)的撰写者事后才返回:输出被丢弃,但必须**留痕**,不能静默消失(修订 §1)。
|
|
3838
|
+
if (paperAbandoned[info.id]) {
|
|
3839
|
+
delete paperAbandoned[info.id]
|
|
3840
|
+
logActivity('paper', '已回收的论文撰写子代理 ' + info.id + ' 事后返回:其输出被丢弃(已重派新的撰写者)')
|
|
3841
|
+
reportDirty = true
|
|
3842
|
+
}
|
|
3843
|
+
return
|
|
3844
|
+
}
|
|
2670
3845
|
const output = blocksToText(info.lastAssistantMessage)
|
|
2671
3846
|
try {
|
|
2672
3847
|
if (meta.role === 'explorer') await handleExplorer(info.id, meta, output)
|
|
2673
3848
|
else if (meta.role === 'solver') await handleSolver(info.id, meta, output, info.stopReason)
|
|
2674
3849
|
else if (meta.role === 'verifier') await handleVerifier(info.id, meta, output, info.stopReason)
|
|
3850
|
+
// 修订 §A2:论文撰写子代理必须有**自己的分支**——此前未知 role 会静默丢失(撰写成果永远落不了盘)。
|
|
3851
|
+
else if (meta.role === 'paper') await handlePaperWriter(info.id, meta, output)
|
|
3852
|
+
else console.error('vibe-math-v2: subagent ' + info.id + ' ended with an unknown role "' + String(meta.role) + '" — its output is dropped')
|
|
2675
3853
|
} catch (e) { console.error('vibe-math-v2 onChildEnd error: ' + String((e && e.stack) || e)) }
|
|
2676
3854
|
await saveAll()
|
|
2677
3855
|
scheduleTick()
|
|
@@ -2693,17 +3871,27 @@ export function apply(ctx) {
|
|
|
2693
3871
|
if (Object.keys(agentRegistry).length > 0 || Object.keys(tasks).length > 0) {
|
|
2694
3872
|
logActivity(fresh ? 'start' : 'resume', 'cleared ' + Object.keys(agentRegistry).length + ' agent(s) and ' + Object.keys(tasks).length + ' task(s) (' + (fresh ? 'restart' : 'stale from previous process') + ')')
|
|
2695
3873
|
agentRegistry = {}; tasks = {}
|
|
3874
|
+
// 论文的派遣态是本进程内存态:子代理已被中断/丢弃,排队与在途标记必须一起清掉,
|
|
3875
|
+
// 否则 paperInFlight 会永久挡住后续 /vibe paper(修订 §A2 的"静默消失"同类问题)。
|
|
3876
|
+
if (paperInFlight || paperPending) logActivity('paper', '重启/跨进程恢复:清空论文撰写态(inFlight=' + (paperInFlight || '-') + (paperPending ? ', queued' : '') + ')')
|
|
3877
|
+
paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null
|
|
2696
3878
|
}
|
|
2697
3879
|
// 并发计数由 agentRegistry 推导:清空 registry 后自然归零,无需显式赋值。
|
|
2698
3880
|
}
|
|
2699
3881
|
await writeJson('VibeMath_State/process_epoch.json', processEpoch)
|
|
3882
|
+
// Lean 异步作业的崩溃恢复(spec §2.6):**同一进程 continue 不重驱**(作业还在内存队列里正常跑),
|
|
3883
|
+
// 只有"新进程/新会话接手这棵树"(fresh 或 stale epoch)才按 Formal/Jobs/*.json 恢复;
|
|
3884
|
+
// 恢复绝不置 passed(settle 的唯一判据仍然要重新校验内容哈希与 exit code)。
|
|
3885
|
+
if (fresh || stale) { try { await recoverLeanJobs() } catch (e) { console.error('vibe-math-v2: lean job recovery failed: ' + String((e && e.message) || e)) } }
|
|
3886
|
+
// 数学计算的引擎探测(异步)在 init 时先跑一次:提示词侧只读缓存现算(mathWorkLine)。
|
|
3887
|
+
try { await refreshMathProbe(true) } catch (e) { /* 探测失败不影响 init:mathWorkLine 会因此为空 */ }
|
|
2700
3888
|
await saveAll()
|
|
2701
3889
|
return { ok: true }
|
|
2702
3890
|
}
|
|
2703
3891
|
async function startScheduler() { const r = await init(true); if (!r.ok) return r; abandonGatedDecision('scheduler restarted'); scheduler.running = true; scheduler.startedAt = now(); logActivity('start', 'scheduler started for project ' + currentProject); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler started', project: currentProject, frameworkRoot: frameworkRoot() } }
|
|
2704
3892
|
async function resumeScheduler() { const r = await init(false); if (!r.ok) return r; abandonGatedDecision('scheduler resumed (离开那次运行,挂起的节点不再等待)'); scheduler.running = true; logActivity('resume', 'scheduler resumed'); await saveAll(); await maybeWriteReport(true); scheduleTick(); return { ok: true, message: 'scheduler resumed', project: currentProject, frameworkRoot: frameworkRoot() } }
|
|
2705
3893
|
async function pauseScheduler() { scheduler.running = false; logActivity('pause', 'scheduler paused'); await saveAll(); return { ok: true, message: 'scheduler paused' } }
|
|
2706
|
-
async function abortScheduler() { scheduler.running = false; abandonGatedDecision('scheduler aborted'); const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
|
|
3894
|
+
async function abortScheduler() { scheduler.running = false; abandonGatedDecision('scheduler aborted'); const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) await interruptChild(ids[i]); agentRegistry = {}; paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null; logActivity('abort', 'scheduler aborted, ' + ids.length + ' child(ren) interrupted'); await saveAll(); return { ok: true, message: 'scheduler aborted', interrupted: ids.length } }
|
|
2707
3895
|
// auto 模式语义 = 无人值守自动通过关键节点:切回 auto 时把仍挂起的人工决策按自动策略放行
|
|
2708
3896
|
async function autoResolvePending() {
|
|
2709
3897
|
const pending = decisionQueue.filter(function (d) { return d.status === 'pending' })
|
|
@@ -2743,6 +3931,8 @@ export function apply(ctx) {
|
|
|
2743
3931
|
todo: formalTodo(),
|
|
2744
3932
|
paths: { project: 'Formal/', lib: vibeRoot() + '/Formal/Lib/', proved: vibeRoot() + '/Formal/Proved/', proofs: 'Verified/Lean/' },
|
|
2745
3933
|
},
|
|
3934
|
+
// 最终论文的可观测面(spec §2/§6):自动开关、在途/排队、定稿时间、产物与编译结果。
|
|
3935
|
+
paper: await paperStatusView(),
|
|
2746
3936
|
}
|
|
2747
3937
|
}
|
|
2748
3938
|
|
|
@@ -2755,9 +3945,15 @@ export function apply(ctx) {
|
|
|
2755
3945
|
else abandonGatedDecision('project switched')
|
|
2756
3946
|
currentProject = slug; await writeCurrentProject(); await ensureDirs()
|
|
2757
3947
|
if ((await readJson('qs/qs.json')) === undefined) await writeJson('qs/qs.json', [])
|
|
2758
|
-
params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; lastReportWrite = 0; lastPushReport = 0; reportDirty = false
|
|
3948
|
+
params = Object.assign({}, DEFAULT_PARAMS); scheduler = { running: false, startedAt: 0, lastCheckpoint: 0, gate: null }; agentRegistry = {}; decisionQueue = []; verifierAccuracy = {}; tasks = {}; explorerRetries = {}; activityLog = []; lastReportWrite = 0; lastPushReport = 0; reportDirty = false; paperInFlight = ''; paperInFlightAt = 0; paperReaps = 0; paperPending = null
|
|
2759
3949
|
formalState = { records: {}, todo: [] } // 形式化记录随项目切换(loadState 会读新项目的 formal.json)
|
|
2760
|
-
await loadSettings(); await migrateLegacyParams(); await loadState()
|
|
3950
|
+
await loadSettings(); await migrateLegacyParams(); await loadState()
|
|
3951
|
+
// 切到/新建一个项目 = "接手这棵树的遗留作业":Formal/Jobs/*.json 里本进程不认识的记录按 §2.6 恢复
|
|
3952
|
+
// (queued 重入队 / running 标记 interrupted / settled 只在哈希与构建上下文都一致时补写)。
|
|
3953
|
+
try { await recoverLeanJobs() } catch (e) { console.error('vibe-math-v2: lean job recovery on project switch failed: ' + String((e && e.message) || e)) }
|
|
3954
|
+
// 切项目后引擎探测缓存过期(mathEngines/路径可能不同):重探一次再让提示词读它。
|
|
3955
|
+
try { await refreshMathProbe(true) } catch (e) { /* 探测失败不影响切换 */ }
|
|
3956
|
+
await saveAll()
|
|
2761
3957
|
return { ok: true, project: slug, frameworkRoot: frameworkRoot() }
|
|
2762
3958
|
}
|
|
2763
3959
|
|
|
@@ -2770,6 +3966,13 @@ export function apply(ctx) {
|
|
|
2770
3966
|
function objParams(props, required) { return { type: 'object', properties: props, additionalProperties: false, required: required || [] } }
|
|
2771
3967
|
const handlers = {}
|
|
2772
3968
|
function registerTool(name, description, parameters, executeFn) { handlers[name] = executeFn }
|
|
3969
|
+
// math_computation 的**会话层**注册(共享模块的 host 在上面构造,绑定本会话的 params/Paths/fs)。
|
|
3970
|
+
// 模块调用一次 host.register(...) ⇒ handlers['math_computation'] = 该会话的 handler;
|
|
3971
|
+
// apply 层(工具面)在 apply 作用域单独注册一次,按名路由回这里(FREEZE §4 要求两层都在)。
|
|
3972
|
+
mathTool = registerMathComputation(mathHost)
|
|
3973
|
+
// 会话层挂载(**字面量形态**):handler 是模块为**本会话**构造的那一个(闭包住本会话的 params/Paths/fs);
|
|
3974
|
+
// apply 层的同名词由宿主按 handlerName 路由回这里。两条路径的名字/描述/schema 三处逐字一致。
|
|
3975
|
+
registerTool('math_computation', MATH_TOOL_DESCRIPTION, MATH_TOOL_SCHEMA, async function (args, agent) { return await mathTool.handler(args || {}, agent) })
|
|
2773
3976
|
// Lean 工具需要知道调用者是谁(记进活动日志与索引署名)。调用者身份**显式传递**,
|
|
2774
3977
|
// 不从任何全局可变状态推断(审计清单 §1.1)。无法识别时退回 'office'。
|
|
2775
3978
|
function memberIdOf(agent) { try { const id = agent && agent.id ? String(agent.id) : ''; return id || 'office' } catch (e) { return 'office' } }
|
|
@@ -2780,7 +3983,7 @@ export function apply(ctx) {
|
|
|
2780
3983
|
registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), async function () { await refreshParams(); return await getStatus() })
|
|
2781
3984
|
registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json.', objParams({}), async function () { await refreshParams(); await maybeWriteReport(true); return await buildReport() })
|
|
2782
3985
|
registerTool('vibe_math_set_mode', 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.', objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), async function (args) { params.mode = args.mode; await saveAll(); await saveSettings(); if (params.mode === 'auto') await autoResolvePending(); return { ok: true, mode: params.mode } })
|
|
2783
|
-
registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean
|
|
3986
|
+
registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式(框架会在用户 leanArgs 之后、文件名之前自动追加 `--search-path <VibeMath 根>`,用户已显式给出就不注入);leanAsync = true(默认,后台队列:入队即返回,只有作业落地 ok 才置 passed 并写归档证明)| false(同步 await 的旧语义)。leanJobsMaxParallel = 后台编译并发上限(默认 1 = 串行);leanInitiative = off|normal|eager 控制**日常的**形式化主动性(与 formalVerify 的验证要求强度是两件事:formalVerify=off 只关验证阶段,eager 仍注入日线);leanSearchPaths = 额外 Lean 搜索路径(默认空 = 只用自动注入的 VibeMath 根;非空时先注入它们、再注入自动根)。最终论文:finalPaper(默认 true;收口时自动派遣一名「论文撰写」子代理)/ paperFormat = both|md|tex / paperLanguage = zh|en / paperCompilePdf(检测到 LaTeX 时编译 paper.pdf)/ paperLatexCommand(指定引擎,空 = 自动探测),产物在 Paper/<项目>/。数学计算(工具 math_computation,回执落在 Computation/<id>/):mathComputation = off|auto|on(默认 auto;off 时提示词零提及)· mathMode = typed|typed+shell(默认 typed+shell;typed 时提示词不含 shell 兜底段且 cli 返回 REFUSED{reason:policy})· mathEngines = 允许的引擎列表(默认含 cli,cli 默认开启)· mathTimeoutMs(默认 60000,最小 1000;到时主动 terminate)· mathPackages(需预检的包,缺包只报告+给安装计划)· mathInstallScope = user|system(默认 user;system 只对当次显式调用生效、永不记忆)。', objParams({ maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' }, leanAsync: { type: 'boolean' }, leanJobsMaxParallel: { type: 'integer' }, leanInitiative: { type: 'string', enum: ['off', 'normal', 'eager'] }, leanSearchPaths: { type: 'array', items: { type: 'string' } }, mathComputation: { type: 'string', enum: ['off', 'auto', 'on'] }, mathMode: { type: 'string', enum: ['typed', 'typed+shell'] }, mathEngines: { type: 'array', items: { type: 'string' } }, mathTimeoutMs: { type: 'integer' }, mathPackages: { type: 'array', items: { type: 'string' } }, mathInstallScope: { type: 'string', enum: ['user', 'system'] }, finalPaper: { type: 'boolean' }, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] }, paperLanguage: { type: 'string', enum: ['zh', 'en'] }, paperCompilePdf: { type: 'boolean' }, paperLatexCommand: { type: 'string' } }), async function (args) { params = Object.assign({}, params, sanitizeParams(args)); await saveAll(); await saveSettings(); if (args && MATH_PARAM_NAMES.some(function (k) { return k in args })) { try { await refreshMathProbe(true) } catch (e) { /* 探测失败不影响参数保存 */ } } return { ok: true, params: params } })
|
|
2784
3987
|
registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', objParams({}), async function () { await refreshParams(); const list = PARAM_SCHEMA.map(function (p) { const out = Object.assign({}, p); out.current = params[p.name]; out.default = DEFAULT_PARAMS[p.name]; return out }); return { ok: true, parameters: list, saveTo: frameworkRoot() + '/vibe_math_setting.json' } })
|
|
2785
3988
|
registerTool('vibe_math_save_settings', 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.', objParams({}), async function () { return await saveSettings() })
|
|
2786
3989
|
registerTool('vibe_math_template', 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.', objParams({ where: { type: 'string', enum: ['global', 'project'] } }), async function (args) { return await createTemplate((args && args.where) || 'global') })
|
|
@@ -2801,17 +4004,24 @@ export function apply(ctx) {
|
|
|
2801
4004
|
// ---- Lean 形式化验证(契约 §5)----
|
|
2802
4005
|
registerTool('vibe_math_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the VibeMath root and report the result. Never throws: a missing toolchain returns LEAN_NOT_FOUND, a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: { type: 'string' }, target: { type: 'string' }, timeout_ms: { type: 'integer' } }, ['file']), async function (args, agent) { return await leanRunTool(memberIdOf(agent), args) })
|
|
2803
4006
|
registerTool('vibe_math_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: { type: 'string' }, target: { type: 'string' }, content: { type: 'string' }, from: { type: 'string' }, note: { type: 'string' }, run: { type: 'boolean' } }, ['kind']), async function (args, agent) { return await leanArchive(memberIdOf(agent), args) })
|
|
2804
|
-
registerTool('vibe_math_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: this project\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: { type: 'boolean' } }), async function (args) {
|
|
4007
|
+
registerTool('vibe_math_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: this project\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining. Also reports the background compile jobs (jobs[]) when leanAsync=true.', objParams({ refresh: { type: 'boolean' } }), async function (args) {
|
|
2805
4008
|
const noRefresh = !!(args && args.refresh === false)
|
|
2806
4009
|
const r = noRefresh ? { lib: null, proved: null, objects: Object.keys(formalRecords()).length } : await rebuildLeanLibIndexes()
|
|
4010
|
+
const jobs = []
|
|
4011
|
+
for (const j of leanJobs.values()) jobs.push(Object.assign(leanJobPublic(j), { exitCode: j.exitCode === undefined ? null : j.exitCode, paths: leanJobPaths(j), buildSha256: j.buildSha || '' }))
|
|
4012
|
+
jobs.sort(function (a, b) { return (b.settledAt || 0) - (a.settledAt || 0) || String(a.jobId).localeCompare(String(b.jobId)) })
|
|
2807
4013
|
return {
|
|
2808
4014
|
ok: true, mode: formalMode(), rebuilt: !noRefresh,
|
|
2809
4015
|
counts: r, todo: formalTodo(),
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
4016
|
+
async: params.leanAsync !== false,
|
|
4017
|
+
jobs: jobs,
|
|
4018
|
+
objects: Object.keys(formalRecords()).map(function (k) { const rec = formalRecords()[k] || {}; return { target: k, status: rec.status, file: rec.file, proof: rec.proof, note: rec.note, async: rec.async || null } }),
|
|
4019
|
+
paths: { project: 'Formal/(相对项目根)', lib: 'VibeMath/Formal/Lib/', proved: 'VibeMath/Formal/Proved/', proofs: 'Verified/Lean/', searchPath: vibeRoot(), jobs: 'Formal/Jobs/' },
|
|
4020
|
+
hint: '复用优先:先在 Lib/ 里找现成定义(vibe_math_lean_read 可看原文);新定义用 vibe_math_lean_archive kind=\'def\' 归档,已证引理用 kind=\'lemma\'(归档前先跑通,跑不通不要入库)。异步档用 jobs 字段查后台编译:**只有 state=settled 才是通过**。',
|
|
2813
4021
|
}
|
|
2814
4022
|
})
|
|
4023
|
+
registerTool('vibe_math_lean_read', '(member) Read back the original text of one archived Lean file (verbatim reuse). Only files under Formal/Lib and Formal/Proved are readable; name is id-sanitised and path escapes are rejected. Returns {ok,name,file,kind,sha256,bytes,text,truncated} (text capped at 64KB).', objParams({ name: { type: 'string' }, kind: { type: 'string', enum: ['auto', 'lib', 'proved'] } }, ['name']), async function (args) { return await leanReadTool(args) })
|
|
4024
|
+
registerTool('vibe_math_lean_job', '(member) Read-only view of the background Lean compile jobs. Without jobId: the session job list (state/rel/target/attempts/paths). With jobId: that job state/exitCode/receipt + archive paths. waitMs>0 waits up to that many ms for a queued/running job to settle (polling; it does not block the heartbeat) and returns the current state on timeout. Only state=settled with exitCode=0 (same content hash AND same build context) counts as passed.', objParams({ jobId: { type: 'string' }, waitMs: { type: 'integer' } }), async function (args) { return await leanJobTool(args) })
|
|
2815
4025
|
|
|
2816
4026
|
// ================= slash command /vibe =================
|
|
2817
4027
|
async function dispatchVibeCommand(cmd, args) {
|
|
@@ -2835,7 +4045,8 @@ export function apply(ctx) {
|
|
|
2835
4045
|
}
|
|
2836
4046
|
if (cmd === 'decisions') return { ok: true, decisions: decisionQueue.filter(function (d) { return d.status === 'pending' }).map(function (d) { return { id: d.id, node: d.node, context: d.context } }) }
|
|
2837
4047
|
if (cmd === 'agents') { const out = []; const ids = Object.keys(agentRegistry); for (let i = 0; i < ids.length; i++) { const m = agentRegistry[ids[i]]; out.push({ childId: ids[i], role: m.role, qid: m.qid, direction: m.direction, round: m.round }) } return { ok: true, agents: out } }
|
|
2838
|
-
|
|
4048
|
+
if (cmd === 'paper') return await paperCommand(args)
|
|
4049
|
+
return { ok: false, usage: 'start | resume | pause | abort | status | report | mode <auto|manual> | setup | save | template [global|project] | add <id> <desc> | add-proposition <id> <概述> | list-propositions | project [list|new <name>|<name>] | decisions | agents | paper [lang=zh|en] [format=both|md|tex] [force]', message: 'unknown /vibe subcommand: ' + (cmd || '(empty)') }
|
|
2839
4050
|
}
|
|
2840
4051
|
|
|
2841
4052
|
// ================= session surface =================
|
|
@@ -2850,6 +4061,21 @@ export function apply(ctx) {
|
|
|
2850
4061
|
// 每次工具调用前同步当前项目(按会话读 current.json;多会话互不干扰)
|
|
2851
4062
|
refreshProject: async function () { if (rootAgent) currentProject = await readCurrentProject() },
|
|
2852
4063
|
getRunning: function () { return scheduler.running },
|
|
4064
|
+
// apply 级心跳用(修订 §A2):running=false 之后 scheduleTick() 是空操作,论文的排队重试
|
|
4065
|
+
// (激活上限)与 paper 锁续租必须由**独立于调度器**的心跳驱动。
|
|
4066
|
+
paperRetryDue: paperRetryDue,
|
|
4067
|
+
runPaperRetry: runPaperRetry,
|
|
4068
|
+
// Lean 异步队列(spec §2):心跳推进队列;dispose 时由 apply 级 disposer 终止在跑的编译。
|
|
4069
|
+
runLeanQueue: runLeanQueue,
|
|
4070
|
+
disposeLeanJobs: disposeLeanJobs,
|
|
4071
|
+
// 数学计算(FREEZE §4):心跳用 TTL 刷新探测缓存;off 档不探测(真 no-op)。
|
|
4072
|
+
mathProbeDue: mathProbeDue,
|
|
4073
|
+
refreshMathProbe: refreshMathProbe,
|
|
4074
|
+
mathProbe: function () { return mathProbe },
|
|
4075
|
+
// P2a:persona 两个文本块必须含的「归档→编辑→重跑」规则(文本取自共享模块常量,persona 不手抄)。
|
|
4076
|
+
mathArchiveWorkflowLine: MATH_ARCHIVE_WORKFLOW_LINE,
|
|
4077
|
+
leanQueueSize: function () { return leanQueue.length },
|
|
4078
|
+
leanJobsPublic: function () { const out = []; for (const j of leanJobs.values()) out.push(leanJobPublic(j)); return out },
|
|
2853
4079
|
// childOwner 裁剪用:这个会话当前仍"可能再发 subagent/end"的 child(在册的 + 任务正在等的)。
|
|
2854
4080
|
referencedChildIds: function () {
|
|
2855
4081
|
const out = Object.keys(agentRegistry)
|
|
@@ -2895,7 +4121,7 @@ export function apply(ctx) {
|
|
|
2895
4121
|
registerTool('vibe_math_status', 'Show scheduler status, params, active agents, projects, and recent activity.', objParams({}), 'vibe_math_status')
|
|
2896
4122
|
registerTool('vibe_math_report', 'Return the full progress report and write it to Progress_Logs/report.json.', objParams({}), 'vibe_math_report')
|
|
2897
4123
|
registerTool('vibe_math_set_mode', 'Switch between manual and auto (preset) mode. Switching to auto auto-resolves any pending manual decisions.', objParams({ mode: { type: 'string', enum: ['manual', 'auto'] } }, ['mode']), 'vibe_math_set_mode')
|
|
2898
|
-
registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean
|
|
4124
|
+
registerTool('vibe_math_set_params', 'Update scheduler parameters (partial). Lean 形式化验证:formalVerify = off(默认,不额外要求)| encourage(按实现难度自行决定是否形式化;一旦 Lean 通过,验证转为对 Lean 陈述的「忠实性审查」)| require(同上,且加门禁:对象的 formal.status 未达到 passed/blocked 之前,真/假裁定记为未定论、原因 formal-required,并进入 Formal/TODO.md);leanCommand/leanArgs/leanTimeoutMs 控制 Lean 工具链的调用方式(框架会在用户 leanArgs 之后、文件名之前自动追加 `--search-path <VibeMath 根>`,用户已显式给出就不注入);leanAsync = true(默认,后台队列:入队即返回,只有作业落地 ok 才置 passed 并写归档证明)| false(同步 await 的旧语义)。leanJobsMaxParallel = 后台编译并发上限(默认 1 = 串行);leanInitiative = off|normal|eager 控制**日常的**形式化主动性(与 formalVerify 的验证要求强度是两件事:formalVerify=off 只关验证阶段,eager 仍注入日线);leanSearchPaths = 额外 Lean 搜索路径(默认空 = 只用自动注入的 VibeMath 根;非空时先注入它们、再注入自动根)。最终论文:finalPaper(默认 true;收口时自动派遣一名「论文撰写」子代理)/ paperFormat = both|md|tex / paperLanguage = zh|en / paperCompilePdf(检测到 LaTeX 时编译 paper.pdf)/ paperLatexCommand(指定引擎,空 = 自动探测),产物在 Paper/<项目>/。数学计算(工具 math_computation,回执落在 Computation/<id>/):mathComputation = off|auto|on(默认 auto;off 时提示词零提及)· mathMode = typed|typed+shell(默认 typed+shell;typed 时提示词不含 shell 兜底段且 cli 返回 REFUSED{reason:policy})· mathEngines = 允许的引擎列表(默认含 cli,cli 默认开启)· mathTimeoutMs(默认 60000,最小 1000;到时主动 terminate)· mathPackages(需预检的包,缺包只报告+给安装计划)· mathInstallScope = user|system(默认 user;system 只对当次显式调用生效、永不记忆)。', objParams({ maxParallelThreshold: { type: 'integer' }, solverMaxRounds: { type: 'integer' }, verifierCount: { type: 'integer' }, debateMaxRounds: { type: 'integer' }, verdictMode: { type: 'string', enum: ['flat', 'forced'] }, reportMode: { type: 'string', enum: ['file', 'push', 'both'] }, promoteValueThreshold: { type: 'number' }, priorityAdjust: { type: 'string', enum: ['none', 'deadend-deprioritize', 'survival-map'] }, proposPriorityAdjust: { type: 'string', enum: ['none', 'progress-graded'] }, provider: { type: 'string' }, model: { type: 'string' }, solverPersona: { type: 'string' }, verifierPersona: { type: 'string' }, explorerPersona: { type: 'string' }, knowledgeContext: { type: 'string' }, solverToolAllow: { type: 'array', items: { type: 'string' } }, solverToolDeny: { type: 'array', items: { type: 'string' } }, verifierToolAllow: { type: 'array', items: { type: 'string' } }, verifierToolDeny: { type: 'array', items: { type: 'string' } }, solverAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowNetwork: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, verifierAllowScripts: { oneOf: [{ type: 'boolean' }, { type: 'string', enum: [''] }] }, solverMaxToolCalls: { type: 'integer' }, verifierMaxToolCalls: { type: 'integer' }, reportIntervalMs: { type: 'integer' }, tickIntervalMs: { type: 'integer' }, activityLogCap: { type: 'integer' }, maxExplorerRetries: { type: 'integer' }, directionsPerSolver: { type: 'integer' }, formalVerify: { type: 'string', enum: ['off', 'encourage', 'require'] }, leanCommand: { type: 'string' }, leanArgs: { type: 'array', items: { type: 'string' } }, leanTimeoutMs: { type: 'integer' }, leanAsync: { type: 'boolean' }, leanJobsMaxParallel: { type: 'integer' }, leanInitiative: { type: 'string', enum: ['off', 'normal', 'eager'] }, leanSearchPaths: { type: 'array', items: { type: 'string' } }, mathComputation: { type: 'string', enum: ['off', 'auto', 'on'] }, mathMode: { type: 'string', enum: ['typed', 'typed+shell'] }, mathEngines: { type: 'array', items: { type: 'string' } }, mathTimeoutMs: { type: 'integer' }, mathPackages: { type: 'array', items: { type: 'string' } }, mathInstallScope: { type: 'string', enum: ['user', 'system'] }, finalPaper: { type: 'boolean' }, paperFormat: { type: 'string', enum: ['both', 'md', 'tex'] }, paperLanguage: { type: 'string', enum: ['zh', 'en'] }, paperCompilePdf: { type: 'boolean' }, paperLatexCommand: { type: 'string' } }), 'vibe_math_set_params')
|
|
2899
4125
|
registerTool('vibe_math_setup', 'Return the interactive parameter schema for guided configuration.', objParams({}), 'vibe_math_setup')
|
|
2900
4126
|
registerTool('vibe_math_save_settings', 'Write the current params to vibe_math_setting.json (JSON with comments) as new defaults.', objParams({}), 'vibe_math_save_settings')
|
|
2901
4127
|
registerTool('vibe_math_template', 'Create a fresh vibe_math_setting.json template (with defaults + comments) in the workspace (global) or current project folder.', objParams({ where: { type: 'string', enum: ['global', 'project'] } }), 'vibe_math_template')
|
|
@@ -2917,13 +4143,23 @@ export function apply(ctx) {
|
|
|
2917
4143
|
// 人/代理主动调用时一样可用。
|
|
2918
4144
|
registerTool('vibe_math_lean_run', '(member) Execute the Lean toolchain on one .lean file inside the VibeMath root and report the result. Never throws: a missing toolchain returns LEAN_NOT_FOUND, a non-zero exit returns the compiler output. Pass target=<object id> to also record the run against that object.', objParams({ file: { type: 'string' }, target: { type: 'string' }, timeout_ms: { type: 'integer' } }, ['file']), 'vibe_math_lean_run')
|
|
2919
4145
|
registerTool('vibe_math_lean_archive', '(member) Archive Lean code. kind="def": a REUSABLE definition/object/assumption → the global cross-project library (Formal/Lib). kind="lemma": a machine-checked lemma → Formal/Proved. kind="proof": the formal proof of a project object → Formal/<target>.lean, and (when the run passes) also Verified/Lean/<target>.lean, marking the object Lean-passed. kind="blocked": record an explicit, reasoned "cannot/not worth formalizing" decision (note required).', objParams({ kind: { type: 'string', enum: ['def', 'lemma', 'proof', 'blocked'] }, name: { type: 'string' }, target: { type: 'string' }, content: { type: 'string' }, from: { type: 'string' }, note: { type: 'string' }, run: { type: 'boolean' } }, ['kind']), 'vibe_math_lean_archive')
|
|
2920
|
-
registerTool('vibe_math_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: this project\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining.', objParams({ refresh: { type: 'boolean' } }), 'vibe_math_lean_lib')
|
|
4146
|
+
registerTool('vibe_math_lean_lib', '(member) List (and by default rebuild) the Lean reuse library: this project\'s Formal/Index.md, plus the global cross-project Formal/Lib and Formal/Proved indexes. Look here BEFORE writing a new definition so you reuse instead of redefining. Also reports the background compile jobs (jobs[]) when leanAsync=true.', objParams({ refresh: { type: 'boolean' } }), 'vibe_math_lean_lib')
|
|
4147
|
+
registerTool('vibe_math_lean_read', '(member) Read back the original text of one archived Lean file (verbatim reuse). Only files under Formal/Lib and Formal/Proved are readable; name is id-sanitised and path escapes are rejected. Returns {ok,name,file,kind,sha256,bytes,text,truncated} (text capped at 64KB).', objParams({ name: { type: 'string' }, kind: { type: 'string', enum: ['auto', 'lib', 'proved'] } }, ['name']), 'vibe_math_lean_read')
|
|
4148
|
+
registerTool('vibe_math_lean_job', '(member) Read-only view of the background Lean compile jobs. Without jobId: the session job list (state/rel/target/attempts/paths). With jobId: that job state/exitCode/receipt + archive paths. waitMs>0 waits up to that many ms for a queued/running job to settle (polling; it does not block the heartbeat) and returns the current state on timeout. Only state=settled with exitCode=0 (same content hash AND same build context) counts as passed.', objParams({ jobId: { type: 'string' }, waitMs: { type: 'integer' } }), 'vibe_math_lean_job')
|
|
4149
|
+
|
|
4150
|
+
// ── math_computation:apply 层(工具面)────────────────────────────────────────────────────
|
|
4151
|
+
// 共享模块的 handler 在**会话层**构造(它闭包住该会话的 params/Paths/fs);这一层只把
|
|
4152
|
+
// 工具名/描述/schema 挂到宿主工具面,执行时由 apply 级包装按 handlerName 路由回
|
|
4153
|
+
// s.handlers['math_computation'](即上面那个会话级实例)。两层都注册是 v2/v3 的硬要求。
|
|
4154
|
+
// **字面量形态**(名字字面量 + 描述/schema 常量):与 v3 的两层声明保持同一形状,
|
|
4155
|
+
// 静态扫描器/注册面审计(audit-registration、audit-v3-registration-parity)都能看见。
|
|
4156
|
+
registerTool('math_computation', MATH_TOOL_DESCRIPTION, MATH_TOOL_SCHEMA, 'math_computation')
|
|
2921
4157
|
|
|
2922
4158
|
// /vibe slash command (registered once; routed per session)
|
|
2923
4159
|
ctx.effect(() => commands.register({
|
|
2924
4160
|
name: 'vibe',
|
|
2925
4161
|
description: 'control the Vibe Math V2 solver (start/pause/projects/setup/save/decisions/agents/propositions)',
|
|
2926
|
-
input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|project [list|new <name>|<name>]|decisions|agents]' },
|
|
4162
|
+
input: { hint: '[start|resume|pause|abort|status|report|mode <auto|manual>|setup|save|template [global|project]|add <id> <desc>|add-proposition <id> <概述>|list-propositions|project [list|new <name>|<name>]|decisions|agents|paper [lang=zh|en] [format=both|md|tex] [force]]' },
|
|
2927
4163
|
handler: async function (invocation) {
|
|
2928
4164
|
const s = getSession(invocation && invocation.agent)
|
|
2929
4165
|
if (!s) return { kind: 'error', text: JSON.stringify({ ok: false, error: 'no vibe-math session for this agent' }) }
|
|
@@ -2956,14 +4192,31 @@ export function apply(ctx) {
|
|
|
2956
4192
|
ctx.effect(() => {
|
|
2957
4193
|
let beat = 0
|
|
2958
4194
|
const t = setInterval(function () {
|
|
2959
|
-
for (const s of sessions.values()) {
|
|
4195
|
+
for (const s of sessions.values()) {
|
|
4196
|
+
if (s.getRunning() && !s.tickInFlight && s.tickDue() && s.scheduler.gate === null) s.scheduleTick()
|
|
4197
|
+
// 论文心跳(修订 §A2):**独立于 scheduler.running** —— 收口后 running=false,但被激活上限
|
|
4198
|
+
// 拒绝的撰写派遣仍要重试,在途时还要续租 paper 锁(否则别的会话 2 分钟后可接管同一棵树)。
|
|
4199
|
+
// runPaperRetry() 在两种情形都不成立时立即返回(一次布尔判断)。
|
|
4200
|
+
if (typeof s.runPaperRetry === 'function') s.runPaperRetry().catch(function (e) { console.error('vibe-math-v2: paper retry failed: ' + String((e && e.message) || e)) })
|
|
4201
|
+
// Lean 异步队列(spec §2.2):同样独立于 scheduler.running —— 作业在收口后仍要落地。
|
|
4202
|
+
// runLeanQueue() 内部**不 await 编译**,所以这条心跳不会被一个编译占住。
|
|
4203
|
+
if (typeof s.runLeanQueue === 'function') s.runLeanQueue().catch(function (e) { console.error('vibe-math-v2: lean queue failed: ' + String((e && e.message) || e)) })
|
|
4204
|
+
// 数学计算的探测缓存 TTL 刷新(15 分钟或从未探测过):mathWorkLine 只读缓存,不能自己 await。
|
|
4205
|
+
if (typeof s.mathProbeDue === 'function' && s.mathProbeDue()) s.refreshMathProbe(false).catch(function (e) { console.error('vibe-math-v2: math probe failed: ' + String((e && e.message) || e)) })
|
|
4206
|
+
}
|
|
2960
4207
|
// childOwner / sessions 裁剪:每 30 拍(约 30s)一次,成本是"会话数 × 映射数"的一次扫描。
|
|
2961
4208
|
if ((++beat % 30) === 0) {
|
|
2962
4209
|
try { pruneChildOwner() } catch (e) { console.error('vibe-math-v2: pruneChildOwner failed: ' + String((e && e.message) || e)) }
|
|
2963
4210
|
try { pruneSessions() } catch (e) { console.error('vibe-math-v2: pruneSessions failed: ' + String((e && e.message) || e)) }
|
|
2964
4211
|
}
|
|
2965
4212
|
}, 1000)
|
|
2966
|
-
return () =>
|
|
4213
|
+
return () => {
|
|
4214
|
+
clearInterval(t)
|
|
4215
|
+
// 卸载/销毁:终止在跑的编译并标记 interrupted(不留孤儿进程,spec §2.5)。
|
|
4216
|
+
for (const s of sessions.values()) {
|
|
4217
|
+
try { if (typeof s.disposeLeanJobs === 'function') s.disposeLeanJobs() } catch (e) { console.error('vibe-math-v2: lean dispose failed: ' + String((e && e.message) || e)) }
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
2967
4220
|
})
|
|
2968
4221
|
}
|
|
2969
4222
|
|
|
@@ -2980,6 +4233,326 @@ export function apply(ctx) {
|
|
|
2980
4233
|
// to the clock) and `parseProgress` normalises the object it is handed in place (pre-existing).
|
|
2981
4234
|
// Nothing here is used by the plugin at runtime except through `apply()`, and behaviour is
|
|
2982
4235
|
// byte-identical to the previous in-`apply` declarations.
|
|
4236
|
+
// ============================================================================================
|
|
4237
|
+
// 最终论文(规格:docs/final-paper.md)—— **纯函数部分**(module scope,v2/v3 逐字同构;
|
|
4238
|
+
// 只有数据来源不同)。会话相关的落盘/派遣/编译在 makeSession 里(见 paperCommand/maybeWritePaper)。
|
|
4239
|
+
// ============================================================================================
|
|
4240
|
+
/** 固定 9 节骨架(spec §3)。key 同时是 md 的 `## ` 标题与 tex 的 `\section{}`。 */
|
|
4241
|
+
const PAPER_SKELETON = [
|
|
4242
|
+
{ key: '摘要', spec: '1 标题、作者、日期、摘要(原问题 + 主要结论)' },
|
|
4243
|
+
{ key: '引言与问题背景', spec: '2 原问题的完整陈述' },
|
|
4244
|
+
{ key: '原问题的完整解法', spec: '3 最终答案 + 完整推理链' },
|
|
4245
|
+
{ key: '已检验通过的命题', spec: '4 逐条列出,含判定为真的估计值与证据来源' },
|
|
4246
|
+
{ key: '已解决的子问题与中间成果', spec: '5' },
|
|
4247
|
+
{ key: '创造或发现的有价值之物', spec: '6 方法、理论、思想、有价值经验、数学理解' },
|
|
4248
|
+
{ key: '规律总结', spec: '7 从上述条目归纳出的可复用规律' },
|
|
4249
|
+
{ key: '讨论、局限与展望', spec: '8' },
|
|
4250
|
+
{ key: '附录:证据与文件索引', spec: '9 Verified/、Logs/、关键卡片路径' },
|
|
4251
|
+
]
|
|
4252
|
+
const PAPER_NO_EVIDENCE = '(本节暂无证据支持的内容——不编造。)'
|
|
4253
|
+
const PAPER_EVIDENCE_HEADING = '证据与文件索引'
|
|
4254
|
+
/** LaTeX 引擎检测顺序(spec §5):中文优先 xelatex,英文优先 pdflatex/latexmk。 */
|
|
4255
|
+
const PAPER_ENGINE_ORDER_ZH = ['xelatex', 'latexmk', 'pdflatex', 'lualatex', 'tectonic']
|
|
4256
|
+
const PAPER_ENGINE_ORDER_EN = ['pdflatex', 'latexmk', 'xelatex', 'lualatex', 'tectonic']
|
|
4257
|
+
/** 只用常见宏包(spec §5);修复阶段会丢弃不在这个名单里的 \usepackage。 */
|
|
4258
|
+
const PAPER_ALLOWED_PACKAGES = ['amsmath', 'amssymb', 'amsthm', 'geometry', 'hyperref', 'longtable', 'booktabs']
|
|
4259
|
+
/** tex 必须转义的 10 个字符(spec §5):\ _ % & # $ { } ~ ^ ——一次替换,避免二次转义。 */
|
|
4260
|
+
const PAPER_TEX_MAP = { '\\': '\\textbackslash{}', '_': '\\_', '%': '\\%', '&': '\\&', '#': '\\#', '$': '\\$', '{': '\\{', '}': '\\}', '~': '\\textasciitilde{}', '^': '\\textasciicircum{}' }
|
|
4261
|
+
function texEscape(s) { return String(s == null ? '' : s).replace(/[\\_%&#${}~^]/g, function (c) { return PAPER_TEX_MAP[c] }) }
|
|
4262
|
+
/** 输入材料摘要(仅供 paper.log.md/meta 记录规模,**不**用于幂等判定:spec v2 §C 用 run id + finalizedAt + 逐产物存在性)。 */
|
|
4263
|
+
function paperMaterialSummary(digest) {
|
|
4264
|
+
const t = String(digest == null ? '' : digest)
|
|
4265
|
+
return { chars: t.length, lines: t.split('\n').length }
|
|
4266
|
+
}
|
|
4267
|
+
/**
|
|
4268
|
+
* 撰写者卡死判定(纯函数,便于守卫覆盖两条分支):
|
|
4269
|
+
* 'none' 正常在途(还没到卡死窗口,且 force 未要求放弃)
|
|
4270
|
+
* 'reap' 必须回收:child 已不在 agentRegistry(end 事件丢失 / 宿主丢弃,它永远不会再回来),
|
|
4271
|
+
* 或已超过卡死窗口——force 与心跳都据此自动降级,论文不能被一个死掉的撰写者永久卡住
|
|
4272
|
+
* 'refuse' force 但还没到窗口:调用方必须给出**可执行**的报错(告诉调用者还要等多久)
|
|
4273
|
+
*/
|
|
4274
|
+
function paperWriterVerdict(o) {
|
|
4275
|
+
if (!(o && o.inFlight)) return 'none'
|
|
4276
|
+
if (o.childInRegistry === false) return 'reap'
|
|
4277
|
+
const age = Number((o && o.ageMs) || 0)
|
|
4278
|
+
const stale = Math.max(1, Number((o && o.staleMs) || 0))
|
|
4279
|
+
if (age >= stale) return 'reap'
|
|
4280
|
+
return (o && o.force) ? 'refuse' : 'none'
|
|
4281
|
+
}
|
|
4282
|
+
/** 行内 md → tex:``code`` / **bold** / *em* 转成命令,其余文本按 spec §5 转义;`$...$` 原样保留(数学模式由作者书写)。 */
|
|
4283
|
+
function paperInlineToTex(s) {
|
|
4284
|
+
const t = String(s == null ? '' : s)
|
|
4285
|
+
let out = ''
|
|
4286
|
+
let i = 0
|
|
4287
|
+
while (i < t.length) {
|
|
4288
|
+
const c = t[i]
|
|
4289
|
+
if (c === '$') { const j = t.indexOf('$', i + 1); if (j > i) { out += t.slice(i, j + 1); i = j + 1; continue } }
|
|
4290
|
+
if (c === '`') { const j = t.indexOf('`', i + 1); if (j > i) { out += '\\texttt{' + texEscape(t.slice(i + 1, j)) + '}'; i = j + 1; continue } }
|
|
4291
|
+
if (c === '*' && t[i + 1] === '*') { const j = t.indexOf('**', i + 2); if (j > i) { out += '\\textbf{' + paperInlineToTex(t.slice(i + 2, j)) + '}'; i = j + 2; continue } }
|
|
4292
|
+
if (c === '*') { const j = t.indexOf('*', i + 1); if (j > i) { out += '\\emph{' + paperInlineToTex(t.slice(i + 1, j)) + '}'; i = j + 1; continue } }
|
|
4293
|
+
// 普通字符:按 10 个字符的映射逐字转义(一次一个字符,天然不会二次转义)
|
|
4294
|
+
out += PAPER_TEX_MAP[c] !== undefined ? PAPER_TEX_MAP[c] : c
|
|
4295
|
+
i++
|
|
4296
|
+
}
|
|
4297
|
+
return out
|
|
4298
|
+
}
|
|
4299
|
+
/** md 正文 → tex 正文(标题/列表/段落;只认 compose 侧约定的最小语法)。 */
|
|
4300
|
+
function paperMdToTexBody(md) {
|
|
4301
|
+
const out = []
|
|
4302
|
+
let inList = false
|
|
4303
|
+
let buf = []
|
|
4304
|
+
const flush = function () { if (buf.length) { out.push(paperInlineToTex(buf.join(' '))); buf = [] } }
|
|
4305
|
+
const closeList = function () { if (inList) { out.push('\\end{itemize}'); inList = false } }
|
|
4306
|
+
const lines = String(md == null ? '' : md).split(/\r?\n/)
|
|
4307
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4308
|
+
const line = lines[i].replace(/\s+$/, '')
|
|
4309
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(line)
|
|
4310
|
+
if (h) {
|
|
4311
|
+
flush(); closeList()
|
|
4312
|
+
const txt = paperInlineToTex(h[2])
|
|
4313
|
+
const lv = h[1].length
|
|
4314
|
+
out.push(lv <= 1 ? ('\\section*{' + txt + '}') : lv === 2 ? ('\\section{' + txt + '}') : lv === 3 ? ('\\subsection{' + txt + '}') : ('\\subsubsection{' + txt + '}'))
|
|
4315
|
+
continue
|
|
4316
|
+
}
|
|
4317
|
+
const li = /^\s*[-*]\s+(.*)$/.exec(line)
|
|
4318
|
+
if (li) { flush(); if (!inList) { out.push('\\begin{itemize}'); inList = true } out.push(' \\item ' + paperInlineToTex(li[1])); continue }
|
|
4319
|
+
if (!line.trim()) { flush(); closeList(); out.push(''); continue }
|
|
4320
|
+
buf.push(line.trim())
|
|
4321
|
+
}
|
|
4322
|
+
flush(); closeList()
|
|
4323
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
4324
|
+
}
|
|
4325
|
+
/** 主模板(spec §5):中文 ctexart / 英文 article,只加常见宏包。 */
|
|
4326
|
+
function paperTexDoc(o) {
|
|
4327
|
+
const lang = (o && o.lang === 'en') ? 'en' : 'zh'
|
|
4328
|
+
return [
|
|
4329
|
+
'\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}',
|
|
4330
|
+
'\\usepackage[margin=2.5cm]{geometry}',
|
|
4331
|
+
'\\usepackage{amsmath,amssymb,amsthm}',
|
|
4332
|
+
'\\usepackage{hyperref}',
|
|
4333
|
+
'\\usepackage{longtable,booktabs}',
|
|
4334
|
+
'\\title{' + texEscape(o && o.title) + '}',
|
|
4335
|
+
'\\author{' + texEscape(o && o.author) + '}',
|
|
4336
|
+
'\\date{' + texEscape(o && o.date) + '}',
|
|
4337
|
+
'\\begin{document}',
|
|
4338
|
+
'\\maketitle',
|
|
4339
|
+
String((o && o.bodyTex) || ''),
|
|
4340
|
+
'\\end{document}',
|
|
4341
|
+
].join('\n') + '\n'
|
|
4342
|
+
}
|
|
4343
|
+
/** 修复阶段的**最小模板**(spec §5):只留 documentclass + 正文(section/itemize/texttt 都在 LaTeX 核心)。 */
|
|
4344
|
+
function paperMinimalTexDoc(o) {
|
|
4345
|
+
const lang = (o && o.lang === 'en') ? 'en' : 'zh'
|
|
4346
|
+
return [
|
|
4347
|
+
'\\documentclass[11pt]{' + (lang === 'en' ? 'article' : 'ctexart') + '}',
|
|
4348
|
+
'\\begin{document}',
|
|
4349
|
+
String((o && o.bodyTex) || ''),
|
|
4350
|
+
'\\end{document}',
|
|
4351
|
+
].join('\n') + '\n'
|
|
4352
|
+
}
|
|
4353
|
+
/** 修复阶段:丢弃不在常见宏包名单里的 \usepackage(其余原样保留)。 */
|
|
4354
|
+
function paperSanitizeTex(tex) {
|
|
4355
|
+
const keep = []
|
|
4356
|
+
const lines = String(tex == null ? '' : tex).split('\n')
|
|
4357
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4358
|
+
const m = /^\s*\\usepackage(\[[^\]]*\])?\{([^}]*)\}\s*$/.exec(lines[i])
|
|
4359
|
+
if (m) {
|
|
4360
|
+
const pkgs = String(m[2]).split(',').map(function (x) { return x.trim() }).filter(Boolean)
|
|
4361
|
+
const ok = pkgs.filter(function (p) { return PAPER_ALLOWED_PACKAGES.indexOf(p) >= 0 })
|
|
4362
|
+
if (ok.length === 0) continue
|
|
4363
|
+
keep.push('\\usepackage' + (m[1] || '') + '{' + ok.join(',') + '}')
|
|
4364
|
+
continue
|
|
4365
|
+
}
|
|
4366
|
+
keep.push(lines[i])
|
|
4367
|
+
}
|
|
4368
|
+
return keep.join('\n')
|
|
4369
|
+
}
|
|
4370
|
+
/** 骨架名匹配:允许带序号/前缀,或只写节名的一部分(例如「附录」)。 */
|
|
4371
|
+
function paperSkeletonKey(name) {
|
|
4372
|
+
const n = String(name == null ? '' : name).replace(/^#+\s*/, '').trim()
|
|
4373
|
+
if (!n) return undefined
|
|
4374
|
+
for (const s of PAPER_SKELETON) if (n === s.key) return s.key
|
|
4375
|
+
for (const s of PAPER_SKELETON) if (n.indexOf(s.key) !== -1 || s.key.indexOf(n) !== -1) return s.key
|
|
4376
|
+
return undefined
|
|
4377
|
+
}
|
|
4378
|
+
/** 按 `## ` 把任意 markdown 切成 [{name,text}](无标题时 name='')。 */
|
|
4379
|
+
function paperSplitByHeadings(md) {
|
|
4380
|
+
const out = []
|
|
4381
|
+
let cur = { name: '', text: [] }
|
|
4382
|
+
const lines = String(md == null ? '' : md).split(/\r?\n/)
|
|
4383
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4384
|
+
const h = /^##\s+(.*?)\s*$/.exec(lines[i])
|
|
4385
|
+
if (h) { if (cur.name || cur.text.length) out.push({ name: cur.name, text: cur.text.join('\n').trim() }); cur = { name: h[1], text: [] }; continue }
|
|
4386
|
+
cur.text.push(lines[i])
|
|
4387
|
+
}
|
|
4388
|
+
if (cur.name || cur.text.length) out.push({ name: cur.name, text: cur.text.join('\n').trim() })
|
|
4389
|
+
return out
|
|
4390
|
+
}
|
|
4391
|
+
/**
|
|
4392
|
+
* 把「论文撰写」子代理的回复映射到 9 节骨架上(spec §3/§4)。
|
|
4393
|
+
* 接受契约 JSON(title/abstract/sections[])或一整段 md(按 `## ` 切);**绝不编造**:
|
|
4394
|
+
* 没写到的节由调用方填 PAPER_NO_EVIDENCE,不属于骨架的节并入第 8 节并记一条说明。
|
|
4395
|
+
*/
|
|
4396
|
+
function paperSkeletonFromReply(reply, fallbackText) {
|
|
4397
|
+
const sections = {}
|
|
4398
|
+
const notes = []
|
|
4399
|
+
let title = ''
|
|
4400
|
+
let abstract = ''
|
|
4401
|
+
const put = function (key, body) {
|
|
4402
|
+
const b = String(body == null ? '' : body).trim()
|
|
4403
|
+
if (!b) return
|
|
4404
|
+
sections[key] = sections[key] ? (sections[key] + '\n\n' + b) : b
|
|
4405
|
+
}
|
|
4406
|
+
const absorbUnknown = function (name, text) {
|
|
4407
|
+
if (!name && !String(text || '').trim()) return
|
|
4408
|
+
notes.push('作者写了一节「' + (name || '(无名)') + '」,不属于固定 9 节骨架,已并入「讨论、局限与展望」')
|
|
4409
|
+
put('讨论、局限与展望', (name ? ('**' + name + '**\n\n') : '') + text)
|
|
4410
|
+
}
|
|
4411
|
+
const obj = (reply && typeof reply === 'object' && !Array.isArray(reply)) ? reply : null
|
|
4412
|
+
if (obj) {
|
|
4413
|
+
title = String(obj.title || obj.标题 || '').trim()
|
|
4414
|
+
abstract = String(obj.abstract || obj.摘要 || '').trim()
|
|
4415
|
+
const list = Array.isArray(obj.sections) ? obj.sections : (Array.isArray(obj.章节) ? obj.章节 : [])
|
|
4416
|
+
for (let i = 0; i < list.length; i++) {
|
|
4417
|
+
const s = list[i]
|
|
4418
|
+
if (!s) continue
|
|
4419
|
+
const name = String(s.name || s.key || s.heading || s.标题 || '')
|
|
4420
|
+
const body = String(s.body != null ? s.body : (s.content != null ? s.content : (s.内容 != null ? s.内容 : '')))
|
|
4421
|
+
const key = paperSkeletonKey(name)
|
|
4422
|
+
if (key === undefined) absorbUnknown(name.trim(), body)
|
|
4423
|
+
else put(key, body)
|
|
4424
|
+
}
|
|
4425
|
+
const bodyField = String(obj.body || obj.content || obj.md || obj.正文 || '')
|
|
4426
|
+
if (bodyField.trim()) {
|
|
4427
|
+
const parts = paperSplitByHeadings(bodyField)
|
|
4428
|
+
for (let i = 0; i < parts.length; i++) {
|
|
4429
|
+
const key = paperSkeletonKey(parts[i].name)
|
|
4430
|
+
if (key === undefined) { if (!parts[i].name && !abstract && !sections['摘要']) abstract = parts[i].text; else if (parts[i].name) absorbUnknown(parts[i].name, parts[i].text) }
|
|
4431
|
+
else put(key, parts[i].text)
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
} else {
|
|
4435
|
+
// 非契约 JSON:把模型**原样**写出的 md 当正文(不编造任何内容;识别不了就整体并入第 8 节)
|
|
4436
|
+
const parts = paperSplitByHeadings(String(fallbackText == null ? '' : fallbackText))
|
|
4437
|
+
let matched = 0
|
|
4438
|
+
for (let i = 0; i < parts.length; i++) {
|
|
4439
|
+
const key = paperSkeletonKey(parts[i].name)
|
|
4440
|
+
if (key === undefined) { if (parts[i].name) absorbUnknown(parts[i].name, parts[i].text) }
|
|
4441
|
+
else { put(key, parts[i].text); matched++ }
|
|
4442
|
+
}
|
|
4443
|
+
if (matched === 0) {
|
|
4444
|
+
const raw = String(fallbackText == null ? '' : fallbackText).trim()
|
|
4445
|
+
if (raw) { notes.push('作者回复不是契约 JSON,也没有可识别的 `## ` 节标题;其原样正文已记入「讨论、局限与展望」'); put('讨论、局限与展望', raw) }
|
|
4446
|
+
else notes.push('作者回复为空:没有任何内容可写(不得编造)')
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
return { title: title, abstract: abstract, sections: sections, notes: notes }
|
|
4450
|
+
}
|
|
4451
|
+
/** 组装 md(spec §3):9 节固定骨架 + 确定性证据索引;缺证据的节写占位说明。 */
|
|
4452
|
+
function paperBuildMarkdown(o) {
|
|
4453
|
+
const sec = (o && o.sections) || {}
|
|
4454
|
+
const ev = ((o && o.evidence) || []).slice()
|
|
4455
|
+
const L = []
|
|
4456
|
+
const ttl = String((o && o.title) || '').trim() || '研究报告'
|
|
4457
|
+
L.push('# ' + ttl)
|
|
4458
|
+
L.push('')
|
|
4459
|
+
L.push('- 作者:' + String((o && o.author) || ''))
|
|
4460
|
+
L.push('- 日期:' + String((o && o.date) || ''))
|
|
4461
|
+
L.push('')
|
|
4462
|
+
for (let i = 0; i < PAPER_SKELETON.length; i++) {
|
|
4463
|
+
const key = PAPER_SKELETON[i].key
|
|
4464
|
+
L.push('## ' + key)
|
|
4465
|
+
L.push('')
|
|
4466
|
+
let body = String(sec[key] || '').trim()
|
|
4467
|
+
if (key === '摘要') { const ab = String((o && o.abstract) || '').trim(); if (ab) body = body ? (ab + '\n\n' + body) : ab }
|
|
4468
|
+
if (key === '附录:证据与文件索引' && ev.length) {
|
|
4469
|
+
const idx = '### ' + PAPER_EVIDENCE_HEADING + '\n\n' + ev.map(function (x) { return '- `' + String(x) + '`' }).join('\n')
|
|
4470
|
+
body = body ? (body + '\n\n' + idx) : idx
|
|
4471
|
+
}
|
|
4472
|
+
L.push(body || PAPER_NO_EVIDENCE)
|
|
4473
|
+
L.push('')
|
|
4474
|
+
}
|
|
4475
|
+
return L.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'
|
|
4476
|
+
}
|
|
4477
|
+
/** paper.log.md 的**唯一格式**(v2/v3 逐字一致,spec §4)。 */
|
|
4478
|
+
function paperLogHead(id) {
|
|
4479
|
+
return ['# 论文写作日志|' + String(id), '',
|
|
4480
|
+
'> 单作者流程(v2/v3 同一格式):收口触发 → 派遣「论文撰写」子代理 → 落盘 md/tex → 编译 pdf。', '']
|
|
4481
|
+
}
|
|
4482
|
+
function paperLogLine(at, tag, text) { return '- ' + fmtTime(at) + ' [' + String(tag) + '] ' + String(text == null ? '' : text) }
|
|
4483
|
+
/** 论文目录 id:只允许安全字符(同一把归一化 ⇒ 不可能写出 Paper/ 之外,spec §6 越权用例)。 */
|
|
4484
|
+
function paperDirId(raw) { return (safeId(String(raw == null ? '' : raw).trim()) || 'run') }
|
|
4485
|
+
/** 从回复里抽出可能存在的标题行(md 非契约路径的兜底)。 */
|
|
4486
|
+
function paperTitleFromMd(md) {
|
|
4487
|
+
const m = /^#\s+(.*?)\s*$/m.exec(String(md == null ? '' : md))
|
|
4488
|
+
return m ? m[1].trim() : ''
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
// ============================================================================================
|
|
4492
|
+
// Lean 增量 + 异步(规格:docs/formal-verification.md;设计:_oneoff/lean-incremental-async-spec.md)
|
|
4493
|
+
// **纯函数部分**(module scope,v2/v3 逐字同构)。
|
|
4494
|
+
// ============================================================================================
|
|
4495
|
+
/** 搜索路径 flag:收敛成一个常量(不同 Lean 版本拼写可能不同,改这里即可,接口不变)。 */
|
|
4496
|
+
const LEAN_SEARCH_PATH_FLAG = '--search-path'
|
|
4497
|
+
/** 用户已显式给出搜索路径就不再注入(显式覆盖优先)。 */
|
|
4498
|
+
function hasLeanSearchFlag(args) {
|
|
4499
|
+
return (Array.isArray(args) ? args : []).some(function (a) {
|
|
4500
|
+
const s = String(a)
|
|
4501
|
+
return s === LEAN_SEARCH_PATH_FLAG || s.indexOf(LEAN_SEARCH_PATH_FLAG + '=') === 0 || s === '-R' || s === '--root'
|
|
4502
|
+
})
|
|
4503
|
+
}
|
|
4504
|
+
/**
|
|
4505
|
+
* 搜索路径注入计划(修订 §2):用户 leanArgs 已显式给过 ⇒ 什么都不注入(显式覆盖优先);
|
|
4506
|
+
* 否则**先注入用户给的 leanSearchPaths,再注入自动根**,去重且保持顺序。
|
|
4507
|
+
*/
|
|
4508
|
+
function leanSearchPathPlan(userArgs, extraPaths, autoRoot) {
|
|
4509
|
+
const args = Array.isArray(userArgs) ? userArgs.map(String) : []
|
|
4510
|
+
if (hasLeanSearchFlag(args)) return { inject: [], paths: [], explicit: true }
|
|
4511
|
+
const out = []
|
|
4512
|
+
const seen = {}
|
|
4513
|
+
const add = function (p) {
|
|
4514
|
+
const s = String(p == null ? '' : p).trim()
|
|
4515
|
+
if (!s || seen[s]) return
|
|
4516
|
+
seen[s] = true
|
|
4517
|
+
out.push(s)
|
|
4518
|
+
}
|
|
4519
|
+
for (const p of (Array.isArray(extraPaths) ? extraPaths : [])) add(p)
|
|
4520
|
+
add(autoRoot)
|
|
4521
|
+
const inject = []
|
|
4522
|
+
for (const p of out) { inject.push(LEAN_SEARCH_PATH_FLAG, p) }
|
|
4523
|
+
return { inject: inject, paths: out, explicit: false }
|
|
4524
|
+
}
|
|
4525
|
+
/** 规范化文本:换行统一 \n、去行尾空白、去尾部空行——内容哈希只反映语义内容。 */
|
|
4526
|
+
function normalizeLeanText(t) {
|
|
4527
|
+
return String(t == null ? '' : t).replace(/\r\n?/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '') + '\n'
|
|
4528
|
+
}
|
|
4529
|
+
/** 内容 sha256(归档去重与作业幂等的键)。 */
|
|
4530
|
+
function leanContentSha(t) { return createHash('sha256').update(normalizeLeanText(t), 'utf8').digest('hex') }
|
|
4531
|
+
/**
|
|
4532
|
+
* 构建上下文(修订 §4):同一段代码在不同引擎/参数/搜索路径下结果可能不同,
|
|
4533
|
+
* 所以"已通过"的判定必须绑定**构建上下文**,不能只看内容。
|
|
4534
|
+
*/
|
|
4535
|
+
function leanBuildContext(engine, args, searchPaths) {
|
|
4536
|
+
return JSON.stringify({
|
|
4537
|
+
engine: String(engine == null ? '' : engine),
|
|
4538
|
+
args: (Array.isArray(args) ? args : []).map(String),
|
|
4539
|
+
searchPaths: (Array.isArray(searchPaths) ? searchPaths : []).map(String),
|
|
4540
|
+
})
|
|
4541
|
+
}
|
|
4542
|
+
/** 作业指纹 = sha256(内容 + 构建上下文)(修订 §4:jobId 取它的前 12 位)。 */
|
|
4543
|
+
function leanJobFingerprint(contentText, buildCtx) {
|
|
4544
|
+
return createHash('sha256').update(normalizeLeanText(contentText) + '\n' + String(buildCtx || ''), 'utf8').digest('hex')
|
|
4545
|
+
}
|
|
4546
|
+
/** jobId = `<target|name>-<指纹[0:12]>`:同一内容 + 同一构建上下文天然只有一个作业。 */
|
|
4547
|
+
function leanJobId(key, fingerprint) {
|
|
4548
|
+
const k = String(key == null ? '' : key).replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^_+|_+$/g, '').slice(0, 60) || 'job'
|
|
4549
|
+
return k + '-' + String(fingerprint == null ? '' : fingerprint).slice(0, 12)
|
|
4550
|
+
}
|
|
4551
|
+
/** "已落地且通过"的**唯一**判据:state=settled 且 exit 0 且未超时未中断(内容哈希由 settle 校验)。 */
|
|
4552
|
+
function leanJobSettledOk(rec) { return !!rec && rec.state === 'settled' && rec.exitCode === 0 && !rec.timedOut && !rec.interrupted }
|
|
4553
|
+
/** 主动性档位(修订 §1):与"验证时的要求强度"(formalVerify)是两件事。 */
|
|
4554
|
+
const LEAN_INITIATIVE_MODES = ['off', 'normal', 'eager']
|
|
4555
|
+
|
|
2983
4556
|
export const __testHelpers = {
|
|
2984
4557
|
uuid,
|
|
2985
4558
|
shortId,
|
|
@@ -2994,6 +4567,38 @@ export const __testHelpers = {
|
|
|
2994
4567
|
parseProgress,
|
|
2995
4568
|
sanitizeToolFilter,
|
|
2996
4569
|
registeredToolsFromError,
|
|
4570
|
+
// Lean 增量/异步(§7 的纯函数守卫面)
|
|
4571
|
+
LEAN_SEARCH_PATH_FLAG,
|
|
4572
|
+
hasLeanSearchFlag,
|
|
4573
|
+
leanSearchPathPlan,
|
|
4574
|
+
normalizeLeanText,
|
|
4575
|
+
leanContentSha,
|
|
4576
|
+
leanBuildContext,
|
|
4577
|
+
leanJobFingerprint,
|
|
4578
|
+
leanJobId,
|
|
4579
|
+
leanJobSettledOk,
|
|
4580
|
+
LEAN_INITIATIVE_MODES,
|
|
4581
|
+
// final paper(spec §6 的纯函数守卫面)
|
|
4582
|
+
PAPER_SKELETON,
|
|
4583
|
+
PAPER_NO_EVIDENCE,
|
|
4584
|
+
PAPER_ALLOWED_PACKAGES,
|
|
4585
|
+
PAPER_ENGINE_ORDER_ZH,
|
|
4586
|
+
PAPER_ENGINE_ORDER_EN,
|
|
4587
|
+
texEscape,
|
|
4588
|
+
paperMaterialSummary,
|
|
4589
|
+
paperWriterVerdict,
|
|
4590
|
+
paperInlineToTex,
|
|
4591
|
+
paperMdToTexBody,
|
|
4592
|
+
paperTexDoc,
|
|
4593
|
+
paperMinimalTexDoc,
|
|
4594
|
+
paperSanitizeTex,
|
|
4595
|
+
paperSkeletonKey,
|
|
4596
|
+
paperSkeletonFromReply,
|
|
4597
|
+
paperBuildMarkdown,
|
|
4598
|
+
paperLogHead,
|
|
4599
|
+
paperLogLine,
|
|
4600
|
+
paperDirId,
|
|
4601
|
+
paperTitleFromMd,
|
|
2997
4602
|
}
|
|
2998
4603
|
|
|
2999
4604
|
function now() { return Date.now() }
|