mingdao-harness 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,6 +54,7 @@ import {
54
54
  relativeTime,
55
55
  searchSessions,
56
56
  } from '../session.js';
57
+ import { loadTaskState, saveTaskState, clearTaskState, resumePrompt } from '../task-state.js';
57
58
 
58
59
  const pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
59
60
 
@@ -223,6 +224,12 @@ export async function runRepl(ctx) {
223
224
  let messages = hasOldSystem
224
225
  ? [{ role: 'system', content: systemPrompt }, ...loadedMsgs.slice(1)]
225
226
  : [{ role: 'system', content: systemPrompt }, ...loadedMsgs];
227
+ // v0.3.0 P0-2:续跑——载入有未完成检查点的会话时注入进度摘要,让模型从断点继续
228
+ const resumeTs = loadTaskState(path.basename(session.file));
229
+ if (resumeTs && (resumeTs.status === 'cap' || resumeTs.status === 'interrupted')) {
230
+ messages.push({ role: 'user', content: resumePrompt(resumeTs) });
231
+ io.print(style('⚠ 检测到未完成任务,已注入续跑提示(完成后自动清除)。', C.yellow));
232
+ }
226
233
  tuiState.persisted = messages.length;
227
234
  let lastUsage = null;
228
235
  let lastText = '';
@@ -318,13 +325,16 @@ export async function runRepl(ctx) {
318
325
  await switchToModel(target);
319
326
  } else if (cmd === '/think') {
320
327
  const vals = ['low', 'high', 'max'];
328
+ const cur = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? '跟随模型默认';
321
329
  if (!arg) {
322
- io.print(`用法:/think low|high|max|off(当前:${cfg.reasoningEffort || '跟随模型默认 high'})`);
330
+ io.print(`用法:/think low|high|max|off(当前:${cur})`);
323
331
  continue;
324
332
  }
325
- if (arg === 'off') cfg.reasoningEffort = 'off'; // 显式禁用(provider 转 thinking:{type:'disabled'})
326
- else if (vals.includes(arg)) cfg.reasoningEffort = arg;
327
- else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
333
+ if (arg === 'off' || vals.includes(arg)) {
334
+ cfg.reasoningByModel = cfg.reasoningByModel || {};
335
+ cfg.reasoningByModel[modelName] = arg; // 按模型独立:只改当前模型(off 转 thinking:{type:'disabled'})
336
+ saveConfig(cfg);
337
+ } else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
328
338
  agent = createAgent({
329
339
  provider, permission, io, modelName, workingDir, cfg,
330
340
  undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
@@ -333,7 +343,7 @@ export async function runRepl(ctx) {
333
343
  tuiState.persisted = msgs.length;
334
344
  },
335
345
  });
336
- io.print(style(`✓ 思考强度:${cfg.reasoningEffort || 'off(模型默认)'}`, C.green));
346
+ io.print(style(`✓ 思考强度(${modelName}):${cfg.reasoningByModel[modelName]}`, C.green));
337
347
  } else if (cmd === '/plan') {
338
348
  planMode = !planMode;
339
349
  io.print(style(`✓ 计划模式:${planMode ? '开' : '关'}${planMode ? '(先出计划,确认后执行)' : ''}`, C.green));
@@ -670,6 +680,18 @@ export async function runRepl(ctx) {
670
680
  if (renamed) io.print(style(`✓ 会话标题:${path.basename(renamed)}`, C.dim));
671
681
  }
672
682
  }
683
+ // v0.3.0 P0-2:跑满步数(capHit)或中断(aborted)落检查点,正常完成清除
684
+ if (res.capHit || res.aborted) {
685
+ saveTaskState(path.basename(session.file), {
686
+ goal: input,
687
+ progress: res.text || '',
688
+ artifacts: [],
689
+ status: res.capHit ? 'cap' : 'interrupted',
690
+ updatedAt: new Date().toISOString(),
691
+ });
692
+ } else {
693
+ clearTaskState(path.basename(session.file));
694
+ }
673
695
  if (res.aborted) {
674
696
  io.print(style('(已中断)', C.dim));
675
697
  }
package/src/memory.js CHANGED
@@ -216,6 +216,126 @@ export async function extractMemory(/** @type {any} */ provider, /** @type {any}
216
216
  }
217
217
  }
218
218
 
219
+ // —— 项目级自动记忆(v0.3.0 P0-3)——
220
+ // 与用户记忆(偏好/约定)区分:项目记忆沉淀「关键决定/事实/结构/教训」,随工作空间走,
221
+ // 换会话/换项目不串。存储:<工作空间>/.mingdao/memory.md(git 可忽略,属本机智能体的项目笔记)。
222
+ export function projectMemoryFile(/** @type {any} */ workingDir) {
223
+ return path.join(String(workingDir || ''), '.mingdao', 'memory.md');
224
+ }
225
+
226
+ export function loadProjectMemory(/** @type {any} */ workingDir) {
227
+ try {
228
+ return fs.readFileSync(projectMemoryFile(workingDir), 'utf8');
229
+ } catch {
230
+ return '';
231
+ }
232
+ }
233
+
234
+ export function appendProjectMemory(/** @type {any} */ workingDir, /** @type {any} */ lines) {
235
+ const add = lines.map((/** @type {any} */ l) => l.trim()).filter(Boolean);
236
+ if (!add.length || !workingDir) return 0;
237
+ try {
238
+ fs.mkdirSync(path.dirname(projectMemoryFile(workingDir)), { recursive: true, mode: 0o700 });
239
+ const bp = beijingParts(new Date());
240
+ const date = `${bp.year}-${String(bp.month).padStart(2, '0')}-${String(bp.day).padStart(2, '0')}`;
241
+ fs.appendFileSync(
242
+ projectMemoryFile(workingDir),
243
+ add.map((/** @type {any} */ l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n'
244
+ );
245
+ return add.length;
246
+ } catch {
247
+ return 0;
248
+ }
249
+ }
250
+
251
+ export function dedupeProjectMemory(/** @type {any} */ workingDir) {
252
+ const raw = loadProjectMemory(workingDir);
253
+ if (!raw.trim()) return 0;
254
+ const seen = new Set();
255
+ const kept = [];
256
+ let removed = 0;
257
+ for (const line of raw.split('\n')) {
258
+ const t = line.trim();
259
+ if (!t) continue;
260
+ const norm = t.replace(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*/, '').trim().toLowerCase();
261
+ if (seen.has(norm)) { removed += 1; continue; }
262
+ seen.add(norm);
263
+ kept.push(t);
264
+ }
265
+ if (removed > 0) {
266
+ try { fs.writeFileSync(projectMemoryFile(workingDir), kept.join('\n') + '\n'); } catch {}
267
+ }
268
+ return removed;
269
+ }
270
+
271
+ // 项目记忆提取:关键决定及其原因、文件/目录结构、依赖与约定、踩过的坑与教训、未完成事项
272
+ export async function extractProjectMemory(/** @type {any} */ provider, /** @type {any} */ model, /** @type {any} */ messages, /** @type {any} */ existing) {
273
+ try {
274
+ const convo = messages
275
+ .filter((/** @type {any} */ m) => m.role === 'user' || m.role === 'assistant')
276
+ .slice(-20)
277
+ .map((/** @type {any} */ m) => `${m.role === 'user' ? '用户' : 'MingDao'}:${String(m.content || '').slice(0, 500)}`)
278
+ .join('\n');
279
+ const msgs = [
280
+ {
281
+ role: 'system',
282
+ content:
283
+ '你是 MingDao Harness 的项目记忆提取器。从对话中提取值得长期记住的「项目级事实」:关键决定及其原因、文件/目录结构、依赖与约定、踩过的坑与教训、未完成事项。每条 ≤50 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出)。只输出 JSON:{"items": ["条目1", "条目2"]};没有新增时输出 {"items": []}。\n已有记忆:\n' +
284
+ (existing || '(空)'),
285
+ },
286
+ { role: 'user', content: convo.slice(0, 8000) },
287
+ ];
288
+ try {
289
+ const res = await provider.chat({ model, messages: msgs, tools: [], temperature: 0.2, maxTokens: 250, reasoningEffort: 'low', responseFormat: { type: 'json_object' } });
290
+ const j = JSON.parse(String(res.text || '').trim());
291
+ const items = Array.isArray(j?.items) ? j.items : [];
292
+ const clean = items
293
+ .map((/** @type {any} */ l) => String(l).trim())
294
+ .filter(Boolean)
295
+ .map((/** @type {any} */ l) => (/^[·•]/.test(l) ? '- ' + l.replace(/^[·•]\s*/, '') : l.startsWith('-') ? l : '- ' + l))
296
+ .slice(0, 10);
297
+ if (clean.length || items.length === 0) return clean;
298
+ } catch {}
299
+ const res = await provider.chat({
300
+ model,
301
+ messages: [
302
+ {
303
+ role: 'system',
304
+ content:
305
+ '你是 MingDao Harness 的项目记忆提取器。从对话中提取值得长期记住的项目级事实(关键决定及原因、结构、依赖约定、教训、未完成事项)。每条一行,以 - 开头,≤50 字,只输出新条目(与「已有记忆」重复或未提及的不要输出);没有新增就只输出「无新增」。\n已有记忆:\n' +
306
+ (existing || '(空)'),
307
+ },
308
+ { role: 'user', content: convo.slice(0, 8000) },
309
+ ],
310
+ tools: [],
311
+ temperature: 0.2,
312
+ maxTokens: 300,
313
+ });
314
+ const text = String(res.text || '').trim();
315
+ if (!text || text.includes('无新增')) return [];
316
+ return text
317
+ .split('\n')
318
+ .map((/** @type {any} */ l) => l.trim())
319
+ .filter((/** @type {any} */ l) => l.startsWith('-') || /^[·•]/.test(l))
320
+ .map((/** @type {any} */ l) => l.replace(/^[·•]\s*/, '- '))
321
+ .slice(0, 10);
322
+ } catch {
323
+ return [];
324
+ }
325
+ }
326
+
327
+ // 项目记忆提取 + 追加(CLI finalizeSession 与 WebUI 收尾共用;配置 autoProjectMemory 默认开)
328
+ export async function extractAndAppendProjectMemory(/** @type {any} */ { cfg, provider, model, messages, workingDir }) {
329
+ if (!workingDir || cfg?.autoProjectMemory === false) return;
330
+ try {
331
+ const existing = loadProjectMemory(workingDir);
332
+ const { helperProvider } = await import('./providers/index.js');
333
+ const memProvider = await helperProvider(cfg, model, provider);
334
+ const lines = await extractProjectMemory(memProvider, model, messages, existing);
335
+ if (lines.length) appendProjectMemory(workingDir, lines);
336
+ } catch {}
337
+ }
338
+
219
339
  // 会话收尾:写日志 + 自动记忆(turns 太少的一次性会话只记日志不提取)
220
340
  export async function finalizeSession(/** @type {any} */ { cfg, provider, model, home, workingDir, messages, turns, lastText }) {
221
341
  const firstUser = messages.find((/** @type {any} */ m) => m.role === 'user')?.content || '';
@@ -239,4 +359,10 @@ export async function finalizeSession(/** @type {any} */ { cfg, provider, model,
239
359
  if (lines.length) appendMemory(lines);
240
360
  } catch {}
241
361
  }
362
+ // v0.3.0 P0-3:项目级自动记忆(有实际工具工作才提取,避免空话污染项目记忆;
363
+ // 单轮重型任务也会执行工具,故按「是否有 tool 消息」判定而非 turn 数)
364
+ const hadToolWork = messages.some((/** @type {any} */ m) => m.role === 'tool');
365
+ if (workingDir && (hadToolWork || turns >= 2)) {
366
+ await extractAndAppendProjectMemory({ cfg, provider, model, messages, workingDir });
367
+ }
242
368
  }
package/src/prompts.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { skillsRegistryBlock } from './skills.js';
7
7
  import { mingdaoHome, loadConfig } from './config.js';
8
- import { recentJournalBlock } from './memory.js';
8
+ import { recentJournalBlock, loadProjectMemory } from './memory.js';
9
9
 
10
10
  const BASE = `你是 MingDao Harness,一个由 MingDao Harness 驱动的 AI 编程助手。你在用户的电脑上工作:通过工具读写文件、搜索代码、执行命令,帮助用户完成编程、调试与自动化任务。
11
11
 
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
30
30
  }
31
31
  }
32
32
 
33
- /** @param {{ workingDir: any, withJournal?: boolean, [key: string]: any }} opts */
34
- export function buildSystemPrompt({ workingDir, withJournal = false }) {
33
+ /** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
34
+ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
35
35
  // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
36
36
  // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
37
37
  // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
@@ -44,6 +44,12 @@ export function buildSystemPrompt({ workingDir, withJournal = false }) {
44
44
  const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
45
45
  if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
46
46
 
47
+ // 项目级自动记忆(v0.3.0 P0-3):<工作空间>/.mingdao/memory.md 自动沉淀的决定/事实/教训。
48
+ // 与 AGENTS.md(手动约定)区分;默认截 4K 保持系统提示前缀稳定,超长可 read 工具按需读。
49
+ // projectMemory 传入则用「会话内快照」(WebUI 保证同一会话内前缀稳定),否则读文件(CLI/REPL)。
50
+ const projMem = projectMemory !== undefined ? projectMemory : loadProjectMemory(workingDir);
51
+ if (projMem) prompt += `\n\n<project_memory>\n${projMem.length > 4000 ? projMem.slice(0, 4000) + '\n…[过长已截断]' : projMem}\n</project_memory>`;
52
+
47
53
  // 最近会话日志(跨会话连续性):默认不注入——新会话应当全新开始,避免串到
48
54
  // 上一次会话的上下文(曾出现「新会话却接着给上个会话的游戏升级」的混淆)。
49
55
  // 仅当用户显式开启时注入(WebUI 勾选「带上文」/ CLI --journal)。
@@ -1,36 +1,71 @@
1
1
  // 会话检索索引(P3-2):词表倒排 + 增量同步,替代每次全量扫描全部会话文件。
2
- // 索引文件 <mingdao-home>/sessions-index.json:
3
- // { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
2
+ // 索引分片存储(v0.2.8 B1):<mingdao-home>/sessions-index/<2位sha1前缀>.json ——
3
+ // 每片 { files: { "<会话名>": { mtime, size, terms: {词: 次数} } } }
4
+ // 分片收益:>1000 会话时不再单次 JSON.parse/stringify 一个巨型索引文件;
5
+ // 同步只重写「变脏」的分片,读/写放大与内存峰值都大幅下降(256 片,每片 ~4 个会话)。
4
6
  // 同步策略:查询前对每个会话文件做 mtime/size 对比——未变化直接用缓存词表(O(词表)),
5
7
  // 变化/新增才重新分词(增量);已删除文件自动清理;>8MB 超大文件跳过并移除条目。
6
8
  // 分词:英文/数字/路径按词(小写),中文按 bigram + 单字,查询串同口径分词后按 AND 匹配。
7
9
 
8
10
  import fs from 'node:fs';
9
11
  import path from 'node:path';
12
+ import crypto from 'node:crypto';
10
13
  import { mingdaoHome, ensureHome } from './config.js';
11
14
  import { atomicWriteFileSync } from './atomic-write.js';
12
15
 
13
16
  const INDEX_MAX_FILE = 8 * 1024 * 1024;
17
+ const SHARD_HEX = 2; // sha1 前 2 个十六进制位 → 256 片
14
18
 
15
- export function sessionIndexFile() {
16
- return path.join(mingdaoHome(), 'sessions-index.json');
19
+ /** 分片目录(索引分片统一存放于此) */
20
+ export function shardDir() {
21
+ return path.join(mingdaoHome(), 'sessions-index');
17
22
  }
18
23
 
19
- export function loadSessionIndex() {
24
+ /** 会话名 → 分片名(sha1 前 2 位,稳定且均匀) */
25
+ /** @param {any} name */
26
+ export function shardOf(name) {
27
+ return crypto.createHash('sha1').update(String(name)).digest('hex').slice(0, SHARD_HEX);
28
+ }
29
+
30
+ /** @param {any} shard */
31
+ function shardFile(shard) {
32
+ return path.join(shardDir(), `${shard}.json`);
33
+ }
34
+
35
+ /** @param {any} shard */
36
+ function loadShard(shard) {
20
37
  try {
21
- const j = JSON.parse(fs.readFileSync(sessionIndexFile(), 'utf8'));
38
+ const j = JSON.parse(fs.readFileSync(shardFile(shard), 'utf8'));
22
39
  return j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j : { files: {} };
23
40
  } catch {
24
41
  return { files: {} };
25
42
  }
26
43
  }
27
44
 
28
- /** @param {any} idx */
29
- export function saveSessionIndex(idx) {
45
+ /** @param {any} shard @param {any} idx */
46
+ function saveShard(shard, idx) {
30
47
  try {
31
48
  ensureHome();
32
- const target = sessionIndexFile();
33
- atomicWriteFileSync(target, JSON.stringify(idx) + '\n'); // 质检 H4:索引原子写
49
+ const dir = shardDir();
50
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
51
+ atomicWriteFileSync(shardFile(shard), JSON.stringify(idx) + '\n'); // 质检 H4:索引原子写
52
+ } catch {}
53
+ }
54
+
55
+ // 一次性迁移:旧版单文件 sessions-index.json → 分片目录(迁移后删除旧文件)
56
+ function migrateLegacyIndex() {
57
+ const legacy = path.join(mingdaoHome(), 'sessions-index.json');
58
+ if (!fs.existsSync(legacy)) return;
59
+ try {
60
+ const j = JSON.parse(fs.readFileSync(legacy, 'utf8'));
61
+ const files = j && typeof j === 'object' && j.files && typeof j.files === 'object' ? j.files : {};
62
+ for (const [name, entry] of Object.entries(files)) {
63
+ const shard = shardOf(name);
64
+ const idx = loadShard(shard);
65
+ idx.files[name] = entry;
66
+ saveShard(shard, idx);
67
+ }
68
+ fs.unlinkSync(legacy);
34
69
  } catch {}
35
70
  }
36
71
 
@@ -81,45 +116,99 @@ export function extractSessionText(file) {
81
116
  return parts.join('\n');
82
117
  }
83
118
 
84
- // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)
85
- /** @param {any} home @param {any} sessions */
119
+ // 增量同步索引(对比 mtime/size,只重算变化的文件;清理已删除条目)。
120
+ // 返回合并后的 { files: {名称: 词表条目} } 供检索匹配(仍只持有所需词表,不再单次解析巨型文件)。
121
+ /** @param {any} home @param {any} sessions
122
+ * @returns {{ files: Record<string, any> }} */
86
123
  export function syncSessionIndex(home, sessions) {
87
- const idx = loadSessionIndex();
124
+ migrateLegacyIndex();
88
125
  const dir = path.join(home, 'sessions');
89
- let changed = false;
126
+ /** @type {{ files: Record<string, any> }} */
127
+ const combined = { files: {} };
128
+ const liveShards = new Set();
129
+
130
+ // 按分片聚合存活会话:每片只 load 一次,命中/变化只写回该片
131
+ const byShard = new Map();
90
132
  for (const s of sessions) {
91
- let st;
92
- try {
93
- st = fs.statSync(s.file);
94
- } catch {
95
- delete idx.files[s.name];
133
+ const k = shardOf(s.name);
134
+ if (!byShard.has(k)) byShard.set(k, []);
135
+ byShard.get(k).push(s);
136
+ }
137
+
138
+ for (const [k, group] of byShard) {
139
+ liveShards.add(k);
140
+ const idx = loadShard(k);
141
+ let changed = false;
142
+ for (const s of group) {
143
+ let st;
144
+ try {
145
+ st = fs.statSync(s.file);
146
+ } catch {
147
+ delete idx.files[s.name];
148
+ changed = true;
149
+ continue;
150
+ }
151
+ const cached = idx.files[s.name];
152
+ if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
96
153
  changed = true;
97
- continue;
154
+ if (st.size > INDEX_MAX_FILE) {
155
+ delete idx.files[s.name]; // 超大文件不索引
156
+ continue;
157
+ }
158
+ try {
159
+ idx.files[s.name] = {
160
+ mtime: st.mtimeMs,
161
+ size: st.size,
162
+ terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
163
+ };
164
+ } catch {
165
+ delete idx.files[s.name];
166
+ }
98
167
  }
99
- const cached = idx.files[s.name];
100
- if (cached && cached.mtime === st.mtimeMs && cached.size === st.size) continue; // 未变化:直接用缓存
101
- changed = true;
102
- if (st.size > INDEX_MAX_FILE) {
103
- delete idx.files[s.name]; // 超大文件不索引
104
- continue;
168
+ // 清理本片内已删除的会话条目(防索引膨胀)
169
+ for (const name of Object.keys(idx.files)) {
170
+ if (!fs.existsSync(path.join(dir, name))) {
171
+ delete idx.files[name];
172
+ changed = true;
173
+ }
105
174
  }
106
- try {
107
- idx.files[s.name] = {
108
- mtime: st.mtimeMs,
109
- size: st.size,
110
- terms: Object.fromEntries(tokenize(extractSessionText(s.file))),
111
- };
112
- } catch {
113
- delete idx.files[s.name];
175
+ if (changed) saveShard(k, idx);
176
+ // 合并本片存活条目供检索
177
+ for (const s of group) {
178
+ const e = idx.files[s.name];
179
+ if (e) combined.files[s.name] = e;
114
180
  }
115
181
  }
116
- // 清理已删除的会话条目(防索引无限膨胀)
117
- for (const name of Object.keys(idx.files)) {
118
- if (!fs.existsSync(path.join(dir, name))) {
119
- delete idx.files[name];
120
- changed = true;
182
+
183
+ // 清理「无存活会话」分片里的陈旧条目(这些片的会话全被删了):目录扫描逐片检查,
184
+ // 每片都很小;空片直接删除文件,避免残留索引膨胀
185
+ let entries = [];
186
+ try {
187
+ entries = fs.readdirSync(shardDir());
188
+ } catch {
189
+ return combined;
190
+ }
191
+ for (const f of entries) {
192
+ if (!f.endsWith('.json')) continue;
193
+ const k = f.slice(0, -5);
194
+ if (liveShards.has(k)) continue;
195
+ const idx = loadShard(k);
196
+ let changed = false;
197
+ for (const name of Object.keys(idx.files)) {
198
+ if (!fs.existsSync(path.join(dir, name))) {
199
+ delete idx.files[name];
200
+ changed = true;
201
+ }
202
+ }
203
+ if (changed) {
204
+ if (Object.keys(idx.files).length) saveShard(k, idx);
205
+ else {
206
+ try {
207
+ fs.unlinkSync(path.join(shardDir(), f));
208
+ } catch {}
209
+ }
121
210
  }
122
211
  }
123
- if (changed) saveSessionIndex(idx);
124
- return idx;
212
+
213
+ return combined;
125
214
  }
@@ -0,0 +1,59 @@
1
+ // 任务检查点(v0.3.0 P0-2):把「一轮 24 步跑满即止」升级为「可续跑」。
2
+ // 存储:<mingdao-home>/taskstates/<会话名>.json(独立侧车文件 + 原子写,不污染会话 JSONL 消息结构)。
3
+ // 状态:{ goal, progress, artifacts, status('cap'|'interrupted'), updatedAt }
4
+ // - cap:本轮跑满步数上限,未完成,可续跑;
5
+ // - interrupted:用户主动中断(Ctrl+C/停止),可续跑;
6
+ // - 任务正常完成时清除检查点(clearTaskState)。
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { mingdaoHome } from './config.js';
10
+ import { atomicWriteFileSync } from './atomic-write.js';
11
+
12
+ function taskStateDir() {
13
+ return path.join(mingdaoHome(), 'taskstates');
14
+ }
15
+
16
+ /** @param {any} sessionName */
17
+ export function taskStateFile(sessionName) {
18
+ return path.join(taskStateDir(), `${String(sessionName)}.json`);
19
+ }
20
+
21
+ /** @param {any} sessionName */
22
+ export function loadTaskState(sessionName) {
23
+ try {
24
+ const j = JSON.parse(fs.readFileSync(taskStateFile(sessionName), 'utf8'));
25
+ return j && typeof j === 'object' ? j : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** @param {any} sessionName @param {any} ts */
32
+ export function saveTaskState(sessionName, ts) {
33
+ try {
34
+ const dir = taskStateDir();
35
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ atomicWriteFileSync(taskStateFile(sessionName), JSON.stringify(ts) + '\n', { mode: 0o600 });
37
+ } catch {}
38
+ }
39
+
40
+ /** @param {any} sessionName */
41
+ export function clearTaskState(sessionName) {
42
+ try {
43
+ fs.unlinkSync(taskStateFile(sessionName));
44
+ } catch {}
45
+ }
46
+
47
+ // 续跑提示:注入到消息历史,让模型先核对现状(已完成文件不重做)、再做未完成部分。
48
+ /** @param {any} ts */
49
+ export function resumePrompt(ts) {
50
+ const art = (Array.isArray(ts?.artifacts) && ts.artifacts.length ? ts.artifacts : []).join('、') || '(无)';
51
+ const progress = String(ts?.progress || '').slice(0, 2000) || '(无总结)';
52
+ const goal = String(ts?.goal || '').slice(0, 500) || '(未知)';
53
+ return (
54
+ `(系统提示)上一个任务「${goal}」因步数上限中断,尚未完成,现在续跑。\n` +
55
+ `已完成进度摘要:\n${progress}\n` +
56
+ `已交付文件(勿重复重做,先核对现状):${art}\n` +
57
+ `请从断点继续:先确认已完成部分的现状,再完成剩余工作。`
58
+ );
59
+ }
@@ -3,11 +3,12 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { MAX_FILE_READ_BYTES } from '../web/constants.js';
6
7
 
7
8
  const MAX_SCAN_FILES = 20000;
8
9
  const MAX_GLOB_RESULTS = 1000;
9
10
  const MAX_GREP_MATCHES = 250;
10
- const MAX_FILE_BYTES = 5 * 1024 * 1024;
11
+ const MAX_FILE_BYTES = MAX_FILE_READ_BYTES; // 单源化(v0.2.8 B2):read/edit 单文件读取上限
11
12
 
12
13
  /**
13
14
  * @param {any} cwd