mingdao-harness 0.6.42 → 0.6.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/CONFIG.md CHANGED
@@ -28,7 +28,9 @@
28
28
  可选字段:`temperature`、`maxOutputTokens`、`includeUsage`(流式请求 usage 统计,个别网关不支持
29
29
  `stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
30
30
  `autoCompact`(上下文自动压缩,默认开,见下)、`audit`(工具调用审计,默认开,见下)、
31
- `eventLog`(会话事件日志双写,默认开,见下)。
31
+ `eventLog`(会话事件日志双写,默认开,见下)、`eventSource`(会话读取真源,`messages`(默认)/
32
+ `events`,见下——**灰度开关,风险提示**:`events` 会用事件日志派生消息,一旦与消息文件对拍不一致
33
+ 就 warn 并回退到消息数组;只在验证「事件流能否当唯一真源」时临时开启,**不要长期依赖**)。
32
34
 
33
35
  ### WebUI 设置项的「默认值」列(只读,不落盘)
34
36
 
@@ -105,6 +107,12 @@ MingDao 先用 executor 模型(路由关闭时为当前模型)把被裁段
105
107
  resume / 空会话 / `tool_calls` 等场景与消息数组 deepEqual。
106
108
  - **回滚开关**:`"eventLog": false`(默认开)或环境变量 `MINGDAO_EVENT_LOG=0`。关闭后**完全**
107
109
  停止事件写入(含 `message.append` 与 `messages.replace` 双写),会话文件照常写入。
110
+ - **读取真源(灰度开关,默认 `messages`)**:`"eventSource": "events"` 时 `loadSession()` 尝试用事件日志
111
+ 重放派生消息;只有 `session.start.data.format >= 2` **且**与 `.jsonl` 消息数组逐条对拍一致才
112
+ 采用。日志缺失 / 格式戳缺失或过旧 / 读失败 → **静默**回退消息数组(灰度期常态,不刷 warn);
113
+ 对拍不一致 → warn 一行(含文件与首个差异位置:`第 N 条`)并回退。返回值形状不变。默认
114
+ `"messages"` 保持只读消息数组、与 0.6.x 逐字节一致;**翻默认要等 `scripts/event-parity.mjs`
115
+ 的真实会话证据(不再有 incomplete)**,不要仅凭配置开启就认为事件已是唯一真源。
108
116
  - **兼容**:新增类型只追加、不改变既有事件形状;读侧对未知 type 一律跳过并计入 `skipped`,
109
117
  因此旧版本读到 `messages.replace` 只会当它不存在,旧日志(没有该类型)在新版本照常可读。
110
118
  旧会话没有 events 文件时读取返回空流;事件侧车不会出现在会话列表/最近会话中;事件文件不
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.42",
3
+ "version": "0.6.44",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -164,7 +164,7 @@ export async function runRepl(ctx) {
164
164
  '选择要恢复的会话:',
165
165
  list.map((s) => ({ value: s.file, label: `${relativeTime(s.mtime)} · ${sessionPreview(s.file)}` }))
166
166
  );
167
- const loaded = loadSession(choice);
167
+ const loaded = loadSession(choice, cfg);
168
168
  if (loaded.messages.length) {
169
169
  session = loaded;
170
170
  io.print(style(`✓ 已载入会话 ${path.basename(choice)}(${loaded.messages.length} 条消息)`, C.green));
@@ -174,7 +174,7 @@ export async function runRepl(ctx) {
174
174
  if (!session && opts.continueSession) {
175
175
  const latest = latestSession(home);
176
176
  if (latest) {
177
- const loaded = loadSession(latest.file);
177
+ const loaded = loadSession(latest.file, cfg);
178
178
  if (loaded.messages.length) {
179
179
  session = loaded;
180
180
  io.print(style(`已载入会话 ${latest.name}(${loaded.messages.length} 条消息)`, C.dim));
package/src/config.js CHANGED
@@ -227,6 +227,43 @@ function applyModelLabels(/** @type {any} */ cfg) {
227
227
  return cfg;
228
228
  }
229
229
 
230
+ /**
231
+ * 会话读取真源(v0.7.0 B2-b 灰度开关)。
232
+ *
233
+ * `messages`(默认):继续只读 `.jsonl` 消息数组——与 0.6.x 完全一致,事件侧车坏掉也不影响。
234
+ * `events`:用事件日志重放派生消息,但**必须与消息文件对拍一致**才采用;不一致就 warn 并
235
+ * 回退到消息数组。这只用于灰度验证「事件流能否当唯一真源」,**不是**新的默认值:
236
+ * 翻默认要等真实会话的 `scripts/event-parity.mjs` 证据(pass 覆盖足够广、incomplete 清零)。
237
+ *
238
+ * 校验口径与 `config.eventLog` 一致:只认字面量 `'events'`,其余(缺省 / 大小写不符 /
239
+ * 任意非法值)一律按 `'messages'` 处理,绝不因为一个拼错的值把读取路径切到实验分支。
240
+ */
241
+ export const EVENT_SOURCE_MESSAGES = 'messages';
242
+ export const EVENT_SOURCE_EVENTS = 'events';
243
+ /** 默认真源:`messages`。改这里等于改所有 ENOENT/未配置用户的读取路径,必须等灰度证据。 */
244
+ export const DEFAULT_EVENT_SOURCE = EVENT_SOURCE_MESSAGES;
245
+ const EVENT_SOURCE_SET = new Set([EVENT_SOURCE_MESSAGES, EVENT_SOURCE_EVENTS]);
246
+
247
+ /**
248
+ * 归一化 `config.eventSource`:合法枚举原样返回,其余一律回落默认值 `messages`。
249
+ * 纯函数,读配置/写配置/直接调用 `loadSession(file, cfg)` 三条路径共用同一判据。
250
+ * @param {any} value
251
+ * @returns {'messages'|'events'}
252
+ */
253
+ export function normalizeEventSource(value) {
254
+ return EVENT_SOURCE_SET.has(value) ? value : DEFAULT_EVENT_SOURCE;
255
+ }
256
+
257
+ /**
258
+ * 就地归一化配置对象里的 `eventSource`(字段不存在时**不新增**,保持旧配置文件字节不变)。
259
+ * @param {any} cfg
260
+ */
261
+ function applyEventSource(/** @type {any} */ cfg) {
262
+ if (!isConfigObject(cfg) || cfg.eventSource === undefined) return cfg;
263
+ cfg.eventSource = normalizeEventSource(cfg.eventSource);
264
+ return cfg;
265
+ }
266
+
230
267
  // ---------------------------------------------------------------------------
231
268
  // v0.7.0 §1.3:配置写入的「基线 + 增量」。
232
269
  //
@@ -350,12 +387,12 @@ function prepareConfigForWrite(cfg) {
350
387
  quarantineCorruptConfig(`保存配置时读不出来:${strict.error}`);
351
388
  base = {};
352
389
  }
353
- if (!sameHome) return cfg; // 无基线(新配置/字面量)或换了 home → 保持原语义:整份写入
390
+ if (!sameHome) return applyEventSource(cfg); // 无基线(新配置/字面量)或换了 home → 保持原语义:整份写入
354
391
  /** @type {{set: [string[], any][], del: string[][]}} */
355
392
  const patch = { set: [], del: [] };
356
393
  collectConfigPatch(snapshot.data, cfg, [], patch);
357
394
  applyConfigPatch(base, patch);
358
- return base;
395
+ return applyEventSource(base);
359
396
  }
360
397
 
361
398
  /**
@@ -379,7 +416,7 @@ function applyEventLogGate(cfg) {
379
416
  export function loadConfig() {
380
417
  const r = readConfigStrict();
381
418
  if (!r.ok) return null;
382
- const cfg = applyModelLabels(applyCachedModelDefaults(r.data));
419
+ const cfg = applyEventSource(applyModelLabels(applyCachedModelDefaults(r.data)));
383
420
  rememberConfig(cfg); // v0.7.0 §1.3:记下基线,saveConfig 只合并增量
384
421
  applyEventLogGate(cfg); // v0.7.0 §1.1:config.eventLog=false 在「先落盘、后建 agent」路径也生效
385
422
  return cfg;
@@ -401,7 +438,7 @@ export function loadConfig() {
401
438
  export function loadConfigForWrite(why = '某个写配置的命令') {
402
439
  const r = readConfigStrict();
403
440
  if (r.ok) {
404
- const cfg = r.data || {};
441
+ const cfg = applyEventSource(r.data || {});
405
442
  rememberConfig(cfg);
406
443
  applyEventLogGate(cfg);
407
444
  return cfg;
@@ -418,7 +455,7 @@ export function loadConfigForWrite(why = '某个写配置的命令') {
418
455
  export function ensureMinimalConfig() {
419
456
  const strict = readConfigStrict();
420
457
  if (strict.ok && strict.data) {
421
- const cfg = applyModelLabels(strict.data);
458
+ const cfg = applyEventSource(applyModelLabels(strict.data));
422
459
  rememberConfig(cfg);
423
460
  return cfg;
424
461
  }
@@ -457,6 +494,7 @@ export function ensureMinimalConfig() {
457
494
  export function saveConfig(cfg) {
458
495
  try {
459
496
  ensureHome();
497
+ applyEventSource(cfg); // B2-b:写回前也归一化,保证内存基线与落盘值一致
460
498
  withStateFileLockSync(configLockFile(), () => {
461
499
  writeConfigUnlocked(prepareConfigForWrite(cfg));
462
500
  rememberConfig(cfg); // 成功后把这版对象当作下次增量比较的基线
@@ -479,6 +517,7 @@ export function saveConfig(cfg) {
479
517
  export async function saveConfigAsync(cfg) {
480
518
  try {
481
519
  ensureHome();
520
+ applyEventSource(cfg); // B2-b:写回前也归一化,保证内存基线与落盘值一致
482
521
  await withFileLock(configLockFile(), () => {
483
522
  writeConfigUnlocked(prepareConfigForWrite(cfg));
484
523
  rememberConfig(cfg);
@@ -513,7 +552,7 @@ export function updateConfigSync(/** @type {(cfg: any) => any} */ mutate) {
513
552
  cfg = {};
514
553
  }
515
554
  const next = typeof mutate === 'function' ? mutate(cfg) : cfg;
516
- const out = isConfigObject(next) ? next : cfg;
555
+ const out = applyEventSource(isConfigObject(next) ? next : cfg);
517
556
  writeConfigUnlocked(out);
518
557
  rememberConfig(out);
519
558
  return { ok: true, error: null, config: out };
@@ -543,7 +582,7 @@ export async function updateConfig(/** @type {(cfg: any) => any} */ mutate) {
543
582
  cfg = {};
544
583
  }
545
584
  const next = typeof mutate === 'function' ? mutate(cfg) : cfg;
546
- const out = isConfigObject(next) ? next : cfg;
585
+ const out = applyEventSource(isConfigObject(next) ? next : cfg);
547
586
  writeConfigUnlocked(out);
548
587
  rememberConfig(out);
549
588
  return { ok: true, error: null, config: out };
@@ -75,6 +75,18 @@ export const SYNTHETIC_REASON = '进程中断(启动时合成闭合事件)';
75
75
  /** 回滚开关:`MINGDAO_EVENT_LOG=0`(或 false/off/no)时全局关闭双写。 */
76
76
  export const EVENT_LOG_ENV = 'MINGDAO_EVENT_LOG';
77
77
 
78
+ /**
79
+ * 事件日志**格式版本**(写在 `session.start` 的 `data.format` 里)。
80
+ *
81
+ * 为什么需要:0.6.42 之前 `rewriteSession()` 完全不写事件,旧日志**无法**从事件流恢复历史。
82
+ * 对拍工具需要一个可靠依据来区分"旧日志不可回溯(历史噪声)"与"新日志重放回归(真 bug)"——
83
+ * 靠"条数/前缀"猜是不行的(实测会把内容不一致误判成历史噪声)。这就是 DSH `session-format-v*` 的同一思路。
84
+ *
85
+ * 1 = 0.6.35–0.6.41:只有 appendMessages 双写,rewrite 无事件
86
+ * 2 = 0.6.42 起:rewriteSession 也写 `messages.replace`
87
+ */
88
+ export const EVENT_LOG_FORMAT = 2;
89
+
78
90
  /** 中断时补的 tool 占位文案——与 `agent.js#finishTurn` 的既有文案保持逐字一致。 */
79
91
  export const INTERRUPTED_TOOL_CONTENT = '(已中断,未执行)';
80
92
 
@@ -765,6 +777,39 @@ export function replayToMessages(events) {
765
777
  return messages;
766
778
  }
767
779
 
780
+ /**
781
+ * 比较两个消息数组(v0.7.0 B2-b 从 `scripts/event-parity.mjs` 抽出的共享判据)。
782
+ *
783
+ * 逐条按 `role + content + tool_calls`(tool_calls 只比 id,排序后)比较;`_` 开头的运行时注记
784
+ * 不落盘、不参与比较。**必须与 scripts/event-parity.mjs 的 `diffMessages` 保持同一语义**:
785
+ * 对拍工具说「等价」的会话,`loadSession({eventSource:'events'})` 才能采用派生结果;两边判据
786
+ * 一旦漂移,对拍结论与灰度读取就会互相打脸。
787
+ *
788
+ * 为什么不直接 import 脚本:`scripts/` 不在 `package.json#files`(npm 安装形态没有它),
789
+ * 且 tsconfig 会把脚本拉进类型检查;因此判据放在 src 侧,脚本侧保留同名实现(硬约束:本切片
790
+ * 不改 `scripts/event-parity.mjs`)。
791
+ *
792
+ * @param {any[]} a
793
+ * @param {any[]} b
794
+ * @returns {{ok:boolean, index:number, reason:string}}
795
+ */
796
+ export function diffMessages(a, b) {
797
+ const A = Array.isArray(a) ? a : [];
798
+ const B = Array.isArray(b) ? b : [];
799
+ if (A.length !== B.length) return { ok: false, index: Math.min(A.length, B.length), reason: `条数不同:消息 ${A.length} vs 重放 ${B.length}` };
800
+ const norm = (/** @type {any} */ m) => {
801
+ /** @type {{role:string, content:any, tool_calls?:string[], tool_call_id?:string}} */
802
+ const out = { role: String(m?.role ?? ''), content: m?.content ?? '' };
803
+ if (Array.isArray(m?.tool_calls)) out.tool_calls = m.tool_calls.map((/** @type {any} */ t) => String(t?.id ?? '')).sort();
804
+ if (m?.tool_call_id != null) out.tool_call_id = String(m.tool_call_id);
805
+ return JSON.stringify(out);
806
+ };
807
+ for (let i = 0; i < A.length; i += 1) {
808
+ if (norm(A[i]) !== norm(B[i])) return { ok: false, index: i, reason: `第 ${i + 1} 条不同` };
809
+ }
810
+ return { ok: true, index: -1, reason: '' };
811
+ }
812
+
768
813
  /**
769
814
  * 供后续 UI/诊断用的 turn 摘要(可选;纯函数)。只从事件自己推导,不读消息正文。
770
815
  * @param {any[]} events
package/src/session.js CHANGED
@@ -3,7 +3,10 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { appendEvent, eventFileFor, eventLogEnabled, isEventLogName, repairEventLog, warnEventFailure } from './session-events.js';
6
+ import { appendEvent, eventFileFor, eventLogEnabled, diffMessages, isEventLogName, readEvents, replayToMessages, repairEventLog, warnEventFailure, EVENT_LOG_FORMAT } from './session-events.js';
7
+ // v0.7.0 B2-b:读取真源开关。归一化放在 config.js(配置读取/写回/直接传 cfg 三条路径共用),
8
+ // 对拍比较函数抽在 session-events.js 的 `diffMessages`(与 scripts/event-parity.mjs 同一判据)。
9
+ import { normalizeEventSource, EVENT_SOURCE_EVENTS } from './config.js';
7
10
 
8
11
  // —— 冲突备份名(单一事实来源,v0.4.6)——
9
12
  // 同步冲突时写入的备份:<会话名>.server-<时间戳>[-<随机后缀>].jsonl(远端版本)与 .remote-(远端拉取版本)。
@@ -134,7 +137,7 @@ function doubleWriteSessionEvents(file, messages) {
134
137
  try {
135
138
  // 事件流的第一条:只在文件尚不存在时写一次(并发下最多重复一条 session.start,无害)。
136
139
  if (!fs.existsSync(eventFile)) {
137
- const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)) } });
140
+ const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)), format: EVENT_LOG_FORMAT } });
138
141
  if (!start.ok) {
139
142
  warnEventFailure(eventFile, start.error ?? null);
140
143
  return;
@@ -192,7 +195,7 @@ function doubleWriteReplaceEvents(file, messages, reason) {
192
195
  try {
193
196
  // 与 appendMessages 一致:事件流的第一条 session.start 只在文件尚不存在时写一次。
194
197
  if (!fs.existsSync(eventFile)) {
195
- const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)) } });
198
+ const start = appendEvent(eventFile, { type: 'session.start', data: { session: path.basename(String(file)), format: EVENT_LOG_FORMAT } });
196
199
  if (!start.ok) {
197
200
  warnEventFailure(eventFile, start.error ?? null);
198
201
  return;
@@ -262,7 +265,44 @@ export function repairSessionEvents(file, cfg = null) {
262
265
  }
263
266
  }
264
267
 
265
- /** @param {any} file @param {any} [cfg] */
268
+ /**
269
+ * v0.7.0 B2-b:从事件侧车派生消息(灰度读取真源)。
270
+ *
271
+ * 返回 `null` 表示**不采用事件源**——无侧车 / 格式戳缺失或早于本版 / 事件日志读失败。
272
+ * 这三种都是灰度期常态(旧会话本来就没有 `messages.replace`),调用方必须**静默**回退到
273
+ * 消息数组,不刷 warn;只有「格式够新、派生出来了、但与消息文件对拍不一致」才是需要可见的异常。
274
+ * @param {any} file 会话文件(.jsonl)
275
+ * @returns {any[]|null}
276
+ */
277
+ function messagesFromEvents(file) {
278
+ const eventFile = eventFileFor(file);
279
+ if (!eventFile) return null;
280
+ try {
281
+ if (!fs.existsSync(eventFile)) return null;
282
+ const read = readEvents(eventFile);
283
+ if (!read.ok) return null;
284
+ // 与 scripts/event-parity.mjs 同一判据:format >= EVENT_LOG_FORMAT 才认为日志可完整回溯
285
+ // (旧日志没有 rewrite 记录,派生出来必然缺窗口,不能拿它当读取真源)。
286
+ const newFormat = read.events.some((ev) => ev.type === 'session.start' && Number(ev.data?.format) >= EVENT_LOG_FORMAT);
287
+ if (!newFormat) return null;
288
+ return replayToMessages(read.events);
289
+ } catch {
290
+ // 事件侧车是旁路:任何异常都只意味着「这次不用事件源」,绝不能让会话读不出来。
291
+ return null;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * 读取会话。默认(`eventSource` 缺省或 `messages`)只读 `.jsonl` 消息数组,行为与 0.6.x 一致。
297
+ *
298
+ * `cfg.eventSource === 'events'`(v0.7.0 B2-b 灰度开关)时额外尝试事件日志派生,但必须通过
299
+ * **对拍兜底**:派生结果与消息文件逐条等价才采用;不一致就 warn(带文件与首个差异位置)并
300
+ * 回退到消息数组。事件源缺失 / 格式旧 / 读失败一律静默回退。
301
+ *
302
+ * @param {any} file
303
+ * @param {any} [cfg] 传入时尊重 `cfg.eventSource` 与 `cfg.eventLog === false`(后者只影响修补器)
304
+ * @returns {{file:any, messages:any[]}} 形状与旧版完全一致
305
+ */
266
306
  export function loadSession(file, cfg = null) {
267
307
  const raw = fs.readFileSync(file, 'utf8');
268
308
  const messages = [];
@@ -275,10 +315,30 @@ export function loadSession(file, cfg = null) {
275
315
  }
276
316
  } catch {}
277
317
  }
318
+ let result = messages;
319
+ if (normalizeEventSource(cfg?.eventSource) === EVENT_SOURCE_EVENTS) {
320
+ const derived = messagesFromEvents(file);
321
+ if (derived) {
322
+ const d = diffMessages(messages, derived);
323
+ if (d.ok) result = derived;
324
+ else {
325
+ // 对拍失败是**真异常**(新格式日志理应可完整重放):必须可见,并带首个差异位置。
326
+ // 但读取结果仍回退到消息数组——灰度期不能让实验分支改变用户看到的历史。
327
+ const at = d.index >= 0 ? `第 ${d.index + 1} 条` : '未知';
328
+ try {
329
+ console.warn(
330
+ `[MingDao] ⚠ 会话事件源对拍不一致(${file}):${d.reason};差异位置:${at}——已回退到消息数组` +
331
+ `(config.eventSource=events 仅灰度验证用,默认仍是 messages)。`
332
+ );
333
+ } catch {}
334
+ }
335
+ }
336
+ }
278
337
  // v0.6.40:消息读完后顺带闭合悬空事件。失败只 warn(repairSessionEvents 内部吞掉),
279
338
  // 返回值与消息数组一字不动——事件侧车坏了/修不了,会话照样可读。
339
+ // v0.7.0 B2-b:放在事件派生之后,派生读到的是修补前的日志(与 scripts/event-parity.mjs 同口径)。
280
340
  repairSessionEvents(file, cfg);
281
- return { file, messages };
341
+ return { file, messages: result };
282
342
  }
283
343
 
284
344
  // 会话预览:第一条用户消息(用于会话选择器)
@@ -89,7 +89,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
89
89
  const file = url.searchParams.get('file');
90
90
  if (!file) return json(res, 400, { error: '缺少 file 参数' });
91
91
  try {
92
- const loaded = loadSession(path.join(home, 'sessions', path.basename(file)));
92
+ const loaded = loadSession(path.join(home, 'sessions', path.basename(file)), deps.cfg);
93
93
  // 载入会话时聚焦其工作空间(P3-4):全局默认切到该会话的目录,前端下拉同步显示
94
94
  const wsDir = getSessionWorkspace(path.basename(file));
95
95
  const ws = workspaceForDir(wsDir);
@@ -189,7 +189,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
189
189
  return json(res, 404, { error: `会话不存在:${file}` });
190
190
  }
191
191
  try {
192
- const loaded = loadSession(path.join(home, 'sessions', file));
192
+ const loaded = loadSession(path.join(home, 'sessions', file), deps.cfg);
193
193
  if (loaded.messages.length) {
194
194
  const wsDir = getSessionWorkspace(file) || state.workingDir;
195
195
  const { finalizeSession } = await import('../../../memory.js');
package/src/web/server.js CHANGED
@@ -819,7 +819,7 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken,
819
819
  const isNew = !body.file;
820
820
  if (body.file) {
821
821
  try {
822
- const loaded = loadSession(path.join(home, 'sessions', path.basename(body.file)));
822
+ const loaded = loadSession(path.join(home, 'sessions', path.basename(body.file)), cfg);
823
823
  if (loaded.messages.length) session = loaded;
824
824
  } catch {}
825
825
  }