@a9i5k4/dsh-auto-memory 3.1.1 → 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/CONTRIBUTORS.html +2 -2
- package/docs/HANDBOOK.md +5 -5
- package/lib/client.js +155 -26
- package/lib/index.js +368 -24
- package/lib/memory-mutation.js +7 -2
- package/lib/migrate-pack.js +114 -25
- package/lib/subagent-gc.js +5 -1
- package/lib/wb-sidecar.js +8 -3
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { createReadStream, existsSync, readdirSync, readFileSync, writeFileSync,
|
|
|
22
22
|
import { exec as cpExec } from 'node:child_process'
|
|
23
23
|
import { promisify } from 'node:util'
|
|
24
24
|
const execP = promisify(cpExec)
|
|
25
|
-
import { homedir } from 'node:os'
|
|
25
|
+
import { homedir, hostname as hostnameOf } from 'node:os'
|
|
26
26
|
import { randomUUID } from 'node:crypto'
|
|
27
27
|
import path from 'node:path'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
@@ -59,7 +59,7 @@ import { pickConsolidationTextPre } from './intent-clean.js'
|
|
|
59
59
|
// 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
|
|
60
60
|
import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
|
|
61
61
|
import { createStorageManagerPre } from './storage-manage.js'
|
|
62
|
-
import { workspaceSlugPre as migrateSlugPre, buildPackPre, validatePackPre, planImportPre, mergeSummaryRecordPre, MIGRATE_PACK_FORMAT_PRE } from './migrate-pack.js'
|
|
62
|
+
import { workspaceSlugPre as migrateSlugPre, buildPackPre, validatePackPre, planImportPre, mergeSummaryRecordPre, calendarMergePre, MIGRATE_PACK_FORMAT_PRE } from './migrate-pack.js'
|
|
63
63
|
import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
|
|
64
64
|
import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
|
|
65
65
|
import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre, reusableWindowCachePre, shouldArmAutoContinuePre } from './water-window.js'
|
|
@@ -254,6 +254,8 @@ export const API = {
|
|
|
254
254
|
'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
|
|
255
255
|
'handoff-state': '/api/dsh-auto-memory/handoff-state',
|
|
256
256
|
'kanban-board': '/api/dsh-auto-memory/kanban-board',
|
|
257
|
+
// ★v3.1.2:卡片全文按需取(看板载荷不再内联 full ⇒ 展开全文时单取一张)。
|
|
258
|
+
'kanban-card': '/api/dsh-auto-memory/kanban-card',
|
|
257
259
|
'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
|
|
258
260
|
'handoff-permission': '/api/dsh-auto-memory/handoff-permission',
|
|
259
261
|
'auto-continue-state': '/api/dsh-auto-memory/auto-continue-state',
|
|
@@ -601,6 +603,8 @@ const DEFAULT_CONFIG = {
|
|
|
601
603
|
*/
|
|
602
604
|
l0IndexEnabled: true,
|
|
603
605
|
/** pre-step 软注入。 */
|
|
606
|
+
/** ★v3.1.3 审计标注:本键当前**无消费者**(全 lib/ 零读取点、设置页零控件)——
|
|
607
|
+
* 属保留字段(不删,避免历史配置反序列化告警)。若将来启用,请同时补设置页控件与守卫。 */
|
|
604
608
|
softInjectionEnabled: false,
|
|
605
609
|
/** M7.5:分支/子代理会话是否也纳入上下文观测(shadow 观测零注入风险;2026-08-26 裁定默认开:开源模型为主,思维链/分支是主要观测面)。 */
|
|
606
610
|
contextBridgeObserveChildSessions: true,
|
|
@@ -2451,16 +2455,22 @@ class MemoryEngine {
|
|
|
2451
2455
|
} catch (e) { return '' }
|
|
2452
2456
|
}
|
|
2453
2457
|
|
|
2454
|
-
/** 最新一篇四段式交接账本(按 mtime
|
|
2458
|
+
/** 最新一篇四段式交接账本(按 mtime 取最新;mtime 相同用文件名时间戳 + 同秒后缀稳定决胜)。 */
|
|
2455
2459
|
async readLatestHandoff(handoffDir) {
|
|
2456
2460
|
try {
|
|
2457
2461
|
const names = (await readdir(handoffDir)).filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n))
|
|
2458
2462
|
if (!names.length) return ''
|
|
2459
|
-
let best = '', bestMt = -1
|
|
2463
|
+
let best = '', bestMt = -1, bestOrder = ''
|
|
2460
2464
|
for (const n of names) {
|
|
2461
2465
|
const st = await stat(path.join(handoffDir, n)).catch(() => null)
|
|
2462
2466
|
const mt = st ? Number(st.mtimeMs) : 0
|
|
2463
|
-
|
|
2467
|
+
const m = n.match(/^handoff-(\d{8}-\d{6})(?:-([a-z]))?\.md$/)
|
|
2468
|
+
const order = m ? (m[1] + '-' + (m[2] || 'a')) : n
|
|
2469
|
+
if (mt > bestMt || (mt === bestMt && order > bestOrder)) {
|
|
2470
|
+
bestMt = mt
|
|
2471
|
+
bestOrder = order
|
|
2472
|
+
best = n
|
|
2473
|
+
}
|
|
2464
2474
|
}
|
|
2465
2475
|
return best ? ((await this.readTextSafe(path.join(handoffDir, best))) || '') : ''
|
|
2466
2476
|
} catch (e) { return '' }
|
|
@@ -2552,8 +2562,12 @@ class MemoryEngine {
|
|
|
2552
2562
|
projectDir,
|
|
2553
2563
|
relPath: 'handoff/PLAN.md',
|
|
2554
2564
|
skipCriteria: !!(opts && opts.skipCriteria),
|
|
2565
|
+
// ★v3.1.4 批 J:显式归档 id 贯穿到保护门(此前这一层从不传 ⇒ 报错里那句是假出路)
|
|
2566
|
+
archivedIds: Array.isArray(opts && opts.archivedIds) ? opts.archivedIds : [],
|
|
2555
2567
|
})
|
|
2556
2568
|
if (!gate.ok) return { ok: false, error: gate.error, gate: gate.gate, report: gate.report }
|
|
2569
|
+
// ★v3.1.4 批 J:M1 降级警示(见 checkMutationPre)——一路透传到工具层
|
|
2570
|
+
const m1Warn = (gate && gate.m1Downgraded) ? String(gate.error || '') : ''
|
|
2557
2571
|
let archived = ''
|
|
2558
2572
|
if (existing && existing.trim() && existing.trim() !== incoming.trim()) {
|
|
2559
2573
|
const archDir = path.join(dir, 'archive')
|
|
@@ -2615,7 +2629,7 @@ class MemoryEngine {
|
|
|
2615
2629
|
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2616
2630
|
await this.appendSidecarEventPre(projectDir, { actor: 'engine', event: 'written', target: 'PLAN.md', details: { chars: written.length, movedHistory: movedHistory || 0 } })
|
|
2617
2631
|
}
|
|
2618
|
-
return { ok: true, path: planPath, archived, movedHistory: movedHistory || undefined, historyPath: historyPath || undefined, final: written }
|
|
2632
|
+
return { ok: true, path: planPath, archived, movedHistory: movedHistory || undefined, historyPath: historyPath || undefined, final: written, warn: m1Warn }
|
|
2619
2633
|
} catch (e) {
|
|
2620
2634
|
return { ok: false, error: String((e && e.message) || e) }
|
|
2621
2635
|
}
|
|
@@ -2869,6 +2883,39 @@ class MemoryEngine {
|
|
|
2869
2883
|
* (见 buildContinueCarry 头注释: 关时既不写、也不读) ⇒ 它管写入/取材; **看板是渲染**, 不该被它拦。
|
|
2870
2884
|
* 保持者: boardMode 门(下方那条) —— 那是渲染形态开关, 且 legacy 须与旧行为逐字节相同(:3673 纪律)。
|
|
2871
2885
|
*/
|
|
2886
|
+
/**
|
|
2887
|
+
* ★v3.1.2:单卡全文(看板「展开全文」按需取)。
|
|
2888
|
+
* 为什么单独开:看板载荷不再内联 full(1178 卡 × ≤6000 字符 ≈ 0.85 MB,且 lanes+matrix 双份),
|
|
2889
|
+
* 前端默认只渲染 preview,全文仅点开时才需要 ⇒ 按 id 单取更省。
|
|
2890
|
+
* 复用与看板同一读盘口径(_collectWhiteboardDocsPre + buildSectionCardsPre),保证 id 可命中。
|
|
2891
|
+
*/
|
|
2892
|
+
async kanbanCardBody(sessionId, cardId, opts = {}) {
|
|
2893
|
+
const want = String(cardId || '').trim()
|
|
2894
|
+
if (!want) return { ok: false, error: 'missing-card-id' }
|
|
2895
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2896
|
+
try {
|
|
2897
|
+
let p, wsBound = true
|
|
2898
|
+
if (sessionId) {
|
|
2899
|
+
p = await this.resolvePathsForSession(sessionId)
|
|
2900
|
+
wsBound = p.wsBound !== false
|
|
2901
|
+
} else {
|
|
2902
|
+
p = (this.state.handoffDir || this.state.planPath)
|
|
2903
|
+
? { handoffDir: this.state.handoffDir || path.dirname(this.state.planPath), planPath: this.state.planPath, ws: this.state.ws, projectDir: this.state.projectDir }
|
|
2904
|
+
: await this.resolvePaths(undefined)
|
|
2905
|
+
}
|
|
2906
|
+
if (!wsBound) return { ok: false, error: 'unbound-ws' }
|
|
2907
|
+
const projectDir = p.projectDir || path.dirname(p.handoffDir)
|
|
2908
|
+
// 展开全文要能找到**任意**一张卡(含归档),故这里 includeArchive: true。
|
|
2909
|
+
const docs = await this._collectWhiteboardDocsPre(projectDir, { includeArchive: true })
|
|
2910
|
+
const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
|
|
2911
|
+
const hit = cards.find((c) => String(c.id || '') === want)
|
|
2912
|
+
if (!hit) return { ok: false, error: 'card-not-found' }
|
|
2913
|
+
return { ok: true, id: want, full: String(hit.body || hit.preview || '').slice(0, 6000) }
|
|
2914
|
+
} catch (e) {
|
|
2915
|
+
return { ok: false, error: String(e && e.message ? e.message : e) }
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2872
2919
|
async kanbanBoardData(sessionId, opts = {}) {
|
|
2873
2920
|
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2874
2921
|
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
@@ -2890,7 +2937,8 @@ class MemoryEngine {
|
|
|
2890
2937
|
// v1 用文件级条目 ⇒ 一篇账本压成一根卡(实测 92 文件仅 92 条, 且 sectionOfPre 无 break
|
|
2891
2938
|
// 导致整篇归一到最后一个 ## 标题, 三条泳道恒空)。改为小节级后实测 92 → 417 条,
|
|
2892
2939
|
// 目标/进行中/失败与弯路/进度 四条泳道全部有内容。
|
|
2893
|
-
|
|
2940
|
+
// ★v3.1.2 性能:看板默认不含 archive(用户拍板 C)——归档只做历史留档,不进看板投影。
|
|
2941
|
+
const docs = await this._collectWhiteboardDocsPre(projectDir, { includeArchive: false })
|
|
2894
2942
|
const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
|
|
2895
2943
|
const index = await this._loadSidecarIndexPre(projectDir)
|
|
2896
2944
|
const kb = buildKanbanPre(index, { now: new Date().toISOString(), cards, perLaneCap: Number(opts.perLaneCap) || 0 })
|
|
@@ -2909,7 +2957,10 @@ class MemoryEngine {
|
|
|
2909
2957
|
* 读取白板/账本文档清单(看板与 sidecar 重建的**唯一读盘口径**)。
|
|
2910
2958
|
* 返回 [{relPath, text, title, kind, mtime, ts}] —— 供小节切分与文件级重建共用。
|
|
2911
2959
|
*/
|
|
2912
|
-
|
|
2960
|
+
// ★v3.1.2 性能(用户拍板 C):归档默认不进**看板**投影。
|
|
2961
|
+
// 实测 handoff/ 活跃账本 157 个 / archive 99 个,每次 /kanban-board 全量读盘+切分;
|
|
2962
|
+
// 归档属于历史留档,看板默认只看 活跃账本 + PLAN(白板/账本本身仍全量可读,不受影响)。
|
|
2963
|
+
async _collectWhiteboardDocsPre(projectDir, opts = {}) {
|
|
2913
2964
|
const sideDir = path.join(projectDir, 'handoff')
|
|
2914
2965
|
const docs = []
|
|
2915
2966
|
const planText = await this.readTextSafe(path.join(sideDir, 'PLAN.md'))
|
|
@@ -2924,7 +2975,7 @@ class MemoryEngine {
|
|
|
2924
2975
|
kind: 'ledger', mtime: st ? Number(st.mtimeMs) : 0,
|
|
2925
2976
|
})
|
|
2926
2977
|
}
|
|
2927
|
-
const archNames = await readdir(path.join(sideDir, 'archive')).catch(() => [])
|
|
2978
|
+
const archNames = (opts && opts.includeArchive) ? await readdir(path.join(sideDir, 'archive')).catch(() => []) : []
|
|
2928
2979
|
for (const f of archNames.filter((x) => /\.md$/.test(x)).sort()) {
|
|
2929
2980
|
const st = await stat(path.join(sideDir, 'archive', f)).catch(() => null)
|
|
2930
2981
|
docs.push({
|
|
@@ -2935,6 +2986,55 @@ class MemoryEngine {
|
|
|
2935
2986
|
return docs
|
|
2936
2987
|
}
|
|
2937
2988
|
|
|
2989
|
+
/**
|
|
2990
|
+
* ★v3.1.4 批 I:从**写入前原文**里切出「即将消失的卡片」原文块,供拒绝文案回吐。
|
|
2991
|
+
*
|
|
2992
|
+
* 为什么需要它:M1 保护门拒绝时若只回吐 mem_id,模型只知道"少了哪张"、不知道内容是什么,
|
|
2993
|
+
* 只能再去 read 一次整份白板原文(本轮 v3.1.3 实测:22KB / 130 行)——在"改过 lib 就要重写白板"
|
|
2994
|
+
* 的收尾纪律下,这个代价每个批次都要付一次。把原文块直接放在错误里即可一轮补齐。
|
|
2995
|
+
*
|
|
2996
|
+
* 切块口径:卡片标题行 → 下一张卡片标题行之前(含锚点行与用户备注区)。
|
|
2997
|
+
* 上限:单卡 1200 字符、合计 6000 字符(尾部标「…(已截断)」),防上下文膨胀。
|
|
2998
|
+
* fail-soft:任何解析异常返回空串,绝不阻塞拒绝路径本身。
|
|
2999
|
+
*
|
|
3000
|
+
* @param {string} beforeText 写入前的 PLAN.md 原文
|
|
3001
|
+
* @param {{hard?:Array}} res validateMutationBoundaryPre 的报告
|
|
3002
|
+
* @returns {string} 形如「\n\n为便于一轮补齐,以下是消失卡片的原文块:…」;无缺失返回 ''
|
|
3003
|
+
*/
|
|
3004
|
+
missingCardsExcerptPre(beforeText, res) {
|
|
3005
|
+
const missing = []
|
|
3006
|
+
for (const h of (Array.isArray(res && res.hard) ? res.hard : [])) {
|
|
3007
|
+
if (h && h.pass === false && Array.isArray(h.missing)) for (const id of h.missing) if (typeof id === 'string' && id) missing.push(id)
|
|
3008
|
+
}
|
|
3009
|
+
if (!missing.length) return ''
|
|
3010
|
+
const lines = String(beforeText == null ? '' : beforeText).split(/\r?\n/)
|
|
3011
|
+
// 卡片边界:**与解析器同口径**(wb-contract.js 的 isCardTitle = /^#{3,}\s+\S/,即 ### 起步;
|
|
3012
|
+
// ⚠️ 本轮实测教训:首版按直觉写成 \s{0,3}###,而真实白板的「## 节 + ### 卡」结构里
|
|
3013
|
+
// 卡片是 ### 、## 只是节标题 ⇒ 取样口径必须回抄解析器,不得自造。)
|
|
3014
|
+
const starts = []
|
|
3015
|
+
for (let i = 0; i < lines.length; i++) if (/^#{3,}\s+\S/.test(lines[i].replace(/\r$/, ''))) starts.push(i)
|
|
3016
|
+
if (!starts.length) return ''
|
|
3017
|
+
let total = 0
|
|
3018
|
+
const parts = []
|
|
3019
|
+
for (const id of missing) {
|
|
3020
|
+
const si = starts.findIndex((ln, k) => {
|
|
3021
|
+
const end = k + 1 < starts.length ? starts[k + 1] : lines.length
|
|
3022
|
+
for (let j = ln; j < end; j++) if (lines[j].includes(id)) return true
|
|
3023
|
+
return false
|
|
3024
|
+
})
|
|
3025
|
+
if (si < 0) continue
|
|
3026
|
+
const from = starts[si]
|
|
3027
|
+
const to = si + 1 < starts.length ? starts[si + 1] : lines.length
|
|
3028
|
+
let block = lines.slice(from, to).join('\n').replace(/\s+$/, '')
|
|
3029
|
+
if (block.length > 1200) block = block.slice(0, 1200) + '\n…(本卡已截断)'
|
|
3030
|
+
if (total + block.length > 6000) { parts.push('…(其余卡片略:合计超上限)'); break }
|
|
3031
|
+
parts.push(block)
|
|
3032
|
+
total += block.length
|
|
3033
|
+
}
|
|
3034
|
+
if (!parts.length) return ''
|
|
3035
|
+
return '\n\n为便于一轮补齐,以下是消失卡片的原文块(请原样带回,含其用户备注区):\n\n' + parts.join('\n\n')
|
|
3036
|
+
}
|
|
3037
|
+
|
|
2938
3038
|
checkMutationPre(input = {}) {
|
|
2939
3039
|
const o = input && typeof input === 'object' ? input : {}
|
|
2940
3040
|
const target = String(o.target || 'other')
|
|
@@ -2982,10 +3082,36 @@ class MemoryEngine {
|
|
|
2982
3082
|
archivedIds: Array.isArray(o.archivedIds) ? o.archivedIds : [],
|
|
2983
3083
|
strict: this.config.criteriaGate !== false,
|
|
2984
3084
|
})
|
|
2985
|
-
|
|
2986
|
-
|
|
3085
|
+
// ★v3.1.4 批 J(用户裁定 · 折中档 A):**白板 M1 丢卡保护降级为警示**。
|
|
3086
|
+
// 用户原话:「protection 的范围收窄……改成在动语义模型的时候需要保护一下」。
|
|
3087
|
+
// 为何降级而非删除:M1 拦过真实事故(白板被整篇覆盖成骨架),完全放开会重开事故面;
|
|
3088
|
+
// 折中 = 照写 + 显式警示行(同项目既有先例:硬判据失败照写 + 警示行)。
|
|
3089
|
+
// ⚠️ 三处边界:(a) 纯函数 validateMutationBoundaryPre 语义不动(t0-8 守卫仍钉它);
|
|
3090
|
+
// (b) 仅 target==='plan';(c) 只有"仅 M1 不合格"才降级 —— M2/M3 任一不合格仍硬拒。
|
|
3091
|
+
// 取证:语义模型资产(~/.dsh/models/js-semantic)写入**不经过本门**(全仓仅此处与账本调用),
|
|
3092
|
+
// 它另由下载器 sha256 + 加载自检保护 ⇒ 「动语义模型才保护」与本门天然独立,无需额外接线。
|
|
3093
|
+
let planDowngradeWarn = ''
|
|
3094
|
+
if (!res.ok && target === 'plan' && res.hard.every((h) => h.pass || h.id === 'M1')) {
|
|
3095
|
+
const unarch = Array.isArray(res.report && res.report.unarchived) ? res.report.unarchived : []
|
|
3096
|
+
let ex = ''
|
|
3097
|
+
try { ex = missingCardsExcerptPre(beforeText, res) } catch (_) { ex = '' }
|
|
3098
|
+
planDowngradeWarn = '⚠ 【白板丢卡警示 · 已照写】本次重写丢了 ' + unarch.length + ' 张卡片且未显式归档:'
|
|
3099
|
+
+ unarch.slice(0, 8).join('、') + (unarch.length > 8 ? ' 等' : '')
|
|
3100
|
+
+ '。写入已放行(v3.1.4 起白板丢卡降级为警示),但这些内容已从白板消失——'
|
|
3101
|
+
+ '如需找回,请从 handoff/archive/ 的历史版本恢复并原样带回。' + ex
|
|
3102
|
+
res.ok = true
|
|
3103
|
+
res.m1Downgraded = true
|
|
2987
3104
|
}
|
|
2988
|
-
|
|
3105
|
+
if (!res.ok) {
|
|
3106
|
+
// ★v3.1.4 批 I(用户报障"每次都重试"):拒绝时把**缺失卡片的原文块**一并回吐,
|
|
3107
|
+
// 而不是只给 mem_id —— 模型据此可直接补回,不必再拉一次 22KB 白板原文。
|
|
3108
|
+
// 切块来源是本函数**已经解析好**的 projB(含行号),零额外 IO;按上限截断防上下文膨胀。
|
|
3109
|
+
let excerpt = ''
|
|
3110
|
+
try { excerpt = missingCardsExcerptPre(beforeText, res) } catch (_) { excerpt = '' }
|
|
3111
|
+
return { ok: false, gate: 'mutation', error: mutationRefusalTextPre(res) + excerpt, report: res }
|
|
3112
|
+
}
|
|
3113
|
+
// ★v3.1.4 批 J:把降级信息带出(error 字段复用为警示文案;ok=true 表示已放行)
|
|
3114
|
+
return { ok: true, report: res, m1Downgraded: !!res.m1Downgraded, error: planDowngradeWarn || '' }
|
|
2989
3115
|
} catch (e) {
|
|
2990
3116
|
// fail-soft:保护门自身出错时**放行**会让保护形同虚设,但硬拒会把白板彻底锁死。
|
|
2991
3117
|
// 取安全侧 = **拒写并显式报错**(宁可让用户看到一次失败,也不静默丢卡)。
|
|
@@ -3187,10 +3313,58 @@ class MemoryEngine {
|
|
|
3187
3313
|
return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
|
|
3188
3314
|
}
|
|
3189
3315
|
|
|
3316
|
+
/**
|
|
3317
|
+
* ★v3.1.3:从**目录列出的文件名**里挑出会话日志文件(纯函数,便于单测)。
|
|
3318
|
+
*
|
|
3319
|
+
* 为什么不再用固定候选表:实盘同时存在 `session.jsonl.zstd` 与 `session.v3.jsonl.zstd` 两代,
|
|
3320
|
+
* 代码注释自述该名已随宿主改过一次、靠人工追加候选名跟上。若宿主改名(如 v4),
|
|
3321
|
+
* 固定表会**返回空**并让五条功能链同时静默降级。这里改成按**结构**识别:
|
|
3322
|
+
* · 前缀 `session.`(或 `session-`)
|
|
3323
|
+
* · 含 `.jsonl`(可带 `.zstd`)
|
|
3324
|
+
* · 排除明显的备份/调试产物(`.broken-backup-` / `.dup-backup-` / 以 `_` 开头)
|
|
3325
|
+
* 排序优先级:**版本号高者优先**(v4 > v3 > 无版本)> 非压缩优先于压缩 > mtime 新者优先。
|
|
3326
|
+
* 这样既兼容未来命名,又不会把备份文件误当主文件。
|
|
3327
|
+
*
|
|
3328
|
+
* @param {string[]} names 目录里的文件名
|
|
3329
|
+
* @param {Record<string, number>} mtimes name → mtimeMs(缺省视为 0)
|
|
3330
|
+
* @returns {string} 选中的文件名;无可选返回 ''
|
|
3331
|
+
*/
|
|
3332
|
+
pickSessionFileNamePre(names, mtimes) {
|
|
3333
|
+
const list = Array.isArray(names) ? names : []
|
|
3334
|
+
const mt = mtimes && typeof mtimes === 'object' ? mtimes : {}
|
|
3335
|
+
const cands = []
|
|
3336
|
+
for (const n of list) {
|
|
3337
|
+
if (typeof n !== 'string' || !n) continue
|
|
3338
|
+
if (n.startsWith('_')) continue // _debug_child.jsonl 之类
|
|
3339
|
+
if (/backup|corrupt|\.tmp$|\.dam-tmp$/i.test(n)) continue
|
|
3340
|
+
if (!/^session[.-]/.test(n)) continue // session. / session-
|
|
3341
|
+
if (!/\.jsonl(\.zstd)?$/i.test(n)) continue // 只认 jsonl[.zstd]
|
|
3342
|
+
const vm = n.match(/session\.v(\d+)\./i)
|
|
3343
|
+
cands.push({ name: n, ver: vm ? Number(vm[1]) : 0, zstd: /\.zstd$/i.test(n) ? 1 : 0, m: Number(mt[n]) || 0 })
|
|
3344
|
+
}
|
|
3345
|
+
if (!cands.length) return ''
|
|
3346
|
+
cands.sort((a, b) => (b.ver - a.ver) || (a.zstd - b.zstd) || (b.m - a.m) || a.name.localeCompare(b.name))
|
|
3347
|
+
return cands[0].name
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3190
3350
|
/** 2.3.1:解析会话日志文件路径 —— DSH 0.1.5 起会话格式迁移到 `session.v3.jsonl.zstd`
|
|
3191
3351
|
* (旧 `session.jsonl.zstd` 保留但停止更新);按 mtime 取最新存在的文件,兼容两代与未来命名。 */
|
|
3192
3352
|
async resolveSessionFile(dir) {
|
|
3193
3353
|
try {
|
|
3354
|
+
// ★v3.1.3:先扫目录(兼容任意 session.vN / 未来命名),目录读不到才回退固定表。
|
|
3355
|
+
let names = []
|
|
3356
|
+
try { names = await readdir(dir) } catch (_) { names = [] }
|
|
3357
|
+
let picked = ''
|
|
3358
|
+
if (names.length) {
|
|
3359
|
+
const mtimes = {}
|
|
3360
|
+
for (const n of names) {
|
|
3361
|
+
if (!/^session[.-].*\.jsonl(\.zstd)?$/i.test(n)) continue
|
|
3362
|
+
try { const st = await stat(path.join(dir, n)); if (st.isFile()) mtimes[n] = st.mtimeMs } catch (_) {}
|
|
3363
|
+
}
|
|
3364
|
+
picked = this.pickSessionFileNamePre(Object.keys(mtimes), mtimes)
|
|
3365
|
+
}
|
|
3366
|
+
if (picked) return path.join(dir, picked)
|
|
3367
|
+
// 回退(目录不可读时):旧的固定候选表,行为与历史一致
|
|
3194
3368
|
const cands = ['session.v3.jsonl.zstd', 'session.jsonl.zstd', 'session.v3.jsonl', 'session.jsonl']
|
|
3195
3369
|
let best = null
|
|
3196
3370
|
for (const name of cands) {
|
|
@@ -3969,9 +4143,18 @@ class MemoryEngine {
|
|
|
3969
4143
|
for (const ent of entries) {
|
|
3970
4144
|
if (!ent.isDirectory()) continue
|
|
3971
4145
|
if (!/^session-/.test(ent.name)) continue
|
|
4146
|
+
// ★v3.1.3:遍历该会话目录里**全部** session.* 文件(不再固定表),取最大 mtime。
|
|
3972
4147
|
let ms = 0
|
|
3973
|
-
|
|
3974
|
-
|
|
4148
|
+
try {
|
|
4149
|
+
for (const f of readdirSync(path.join(wsPath, ent.name))) {
|
|
4150
|
+
if (!/^session[.-].*\.jsonl(\.zstd)?$/i.test(f)) continue
|
|
4151
|
+
if (/backup|corrupt/i.test(f)) continue
|
|
4152
|
+
try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
|
|
4153
|
+
}
|
|
4154
|
+
} catch (_) {
|
|
4155
|
+
for (const f of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
|
|
4156
|
+
try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
|
|
4157
|
+
}
|
|
3975
4158
|
}
|
|
3976
4159
|
if (ms > bestMs) { bestMs = ms; best = ent.name }
|
|
3977
4160
|
}
|
|
@@ -4679,7 +4862,10 @@ class MemoryEngine {
|
|
|
4679
4862
|
/** 对比本地版本与 npm registry 最新版;结果缓存 12 小时(启动/设置页打开都走缓存,不重复查网)。 */
|
|
4680
4863
|
async checkUpdate(force) {
|
|
4681
4864
|
const cacheFile = path.join(dshHome(), 'memory', 'update-check.json')
|
|
4682
|
-
|
|
4865
|
+
// ★v3.1.2:dev-link(profile 以 link: 挂载开发树)时,current 读的是**开发树自己的版本**,
|
|
4866
|
+
// 与 registry latest 恒等 ⇒ upToDate 永远 true,用户感知不到线上新版。故把开发树版本
|
|
4867
|
+
// 单独标成 devVersion,让前端能呈现「开发树 vX ↔ 线上 vY」而不是假装已是最新。
|
|
4868
|
+
const base = { current: '', installKind: 'unknown', devVersion: '' }
|
|
4683
4869
|
try {
|
|
4684
4870
|
const indexPath = fileURLToPath(import.meta.url)
|
|
4685
4871
|
const pkgPath = path.join(path.dirname(indexPath), '..', 'package.json')
|
|
@@ -4688,6 +4874,7 @@ class MemoryEngine {
|
|
|
4688
4874
|
} catch (e) {}
|
|
4689
4875
|
const prof = await this.findProfileDir()
|
|
4690
4876
|
if (prof) base.installKind = prof.installKind
|
|
4877
|
+
if (base.installKind === 'dev-link') base.devVersion = base.current
|
|
4691
4878
|
if (!force) {
|
|
4692
4879
|
try {
|
|
4693
4880
|
const cached = await this.readTextSafe(cacheFile)
|
|
@@ -7006,11 +7193,19 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
|
|
|
7006
7193
|
}
|
|
7007
7194
|
}
|
|
7008
7195
|
await walk(dir, '')
|
|
7196
|
+
// ★v3.1.3:用户级文件(日历)也进包 —— 用户要求「把同步的范围扩大到日历」。
|
|
7197
|
+
// CALENDAR.md 在 userDir(跨工作区共享),故键用「相对 userDir 的路径」;
|
|
7198
|
+
// 导入侧按 merge 处理(见 _applyUserFilesPre / calendarMergePre),绝不整篇覆盖。
|
|
7199
|
+
const userFiles = {}
|
|
7200
|
+
try {
|
|
7201
|
+
const calRaw = await this.readTextSafe(path.join(this.userDirOf(), 'CALENDAR.md'))
|
|
7202
|
+
if (calRaw && String(calRaw).trim()) userFiles['CALENDAR.md'] = calRaw
|
|
7203
|
+
} catch (_) {}
|
|
7009
7204
|
const built = buildPackPre({
|
|
7010
|
-
ws, files,
|
|
7205
|
+
ws, files, userFiles,
|
|
7011
7206
|
summaryRecord: await this._wsSummaryRecord(ws),
|
|
7012
7207
|
pluginVersion: (this.manifest && this.manifest.version) || '',
|
|
7013
|
-
sourceHost:
|
|
7208
|
+
sourceHost: hostnameOf(),
|
|
7014
7209
|
})
|
|
7015
7210
|
if (!built.ok) return { ok: false, error: built.error, skipped }
|
|
7016
7211
|
const json = JSON.stringify(built.pack)
|
|
@@ -7031,6 +7226,32 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
|
|
|
7031
7226
|
}
|
|
7032
7227
|
}
|
|
7033
7228
|
|
|
7229
|
+
/**
|
|
7230
|
+
* ★v3.1.3:把包内的用户级文件落地(当前只有 CALENDAR.md)。**合并而非覆盖**。
|
|
7231
|
+
* 为什么单独一个方法:日历在用户级目录、跨工作区共享,落盘口径与 files 那支完全不同。
|
|
7232
|
+
* 返回 { applied:[], skipped:[] } 供调用方汇总 —— 绝不静默。
|
|
7233
|
+
*/
|
|
7234
|
+
async _applyUserFilesPre(pack) {
|
|
7235
|
+
const applied = []
|
|
7236
|
+
const skipped = []
|
|
7237
|
+
const uf = (pack && pack.userFiles && typeof pack.userFiles === 'object') ? pack.userFiles : {}
|
|
7238
|
+
for (const rel of Object.keys(uf)) {
|
|
7239
|
+
if (rel !== 'CALENDAR.md') { skipped.push('user:' + rel + '(unsupported)'); continue }
|
|
7240
|
+
try {
|
|
7241
|
+
const target = path.join(this.userDirOf(), rel)
|
|
7242
|
+
const existing = await this.readTextSafe(target)
|
|
7243
|
+
const merged = calendarMergePre(existing, uf[rel])
|
|
7244
|
+
if (!merged.ok) { skipped.push('user:' + rel + '(merge-failed)'); continue }
|
|
7245
|
+
// 无新增且本机已有内容 ⇒ 不写盘(避免无谓 mtime 变动)
|
|
7246
|
+
if (merged.added === 0 && String(existing || '').trim()) { applied.push({ rel, added: 0, action: 'noop' }); continue }
|
|
7247
|
+
await this.writeFullRaw(target, merged.text)
|
|
7248
|
+
this.state.calendarText = merged.text; this.state.loadedAt = Date.now()
|
|
7249
|
+
applied.push({ rel, added: merged.added, action: 'merged' })
|
|
7250
|
+
} catch (e) { skipped.push('user:' + rel + '(error:' + String(e && e.message ? e.message : e) + ')') }
|
|
7251
|
+
}
|
|
7252
|
+
return { applied, skipped }
|
|
7253
|
+
}
|
|
7254
|
+
|
|
7034
7255
|
/** 读包(自动识别 gzip:gzip 魔数 1f 8b)。★坏包在 validate 阶段即拒,不写任何文件。 */
|
|
7035
7256
|
async _readPack(packPath) {
|
|
7036
7257
|
const buf = await readFile(packPath).catch(() => null)
|
|
@@ -7071,7 +7292,7 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
|
|
|
7071
7292
|
}
|
|
7072
7293
|
}
|
|
7073
7294
|
|
|
7074
|
-
/** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary
|
|
7295
|
+
/** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary → 合并用户级文件(任一步失败即中止并回报)。 */
|
|
7075
7296
|
async migrateImport(opts) {
|
|
7076
7297
|
const o = opts || {}
|
|
7077
7298
|
const r = await this._readPack(String(o.packPath || ''))
|
|
@@ -7121,6 +7342,12 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
|
|
|
7121
7342
|
}
|
|
7122
7343
|
summaryAction = merged.action
|
|
7123
7344
|
} catch (e) { summaryError = String(e && e.message || e) }
|
|
7345
|
+
// ★v3.1.3:用户级文件(日历)落地 —— **merge 不覆盖**,与 summary 同一条纪律。
|
|
7346
|
+
// 放在 summary 之后、refresh 之前:日历改动要进 state.calendarText(_applyUserFilesPre 内已同步),
|
|
7347
|
+
// 失败不阻断导入主流程(计入 userFilesError,绝不静默)。
|
|
7348
|
+
let userFilesResult = { applied: [], skipped: [] }
|
|
7349
|
+
let userFilesError = null
|
|
7350
|
+
try { userFilesResult = await this._applyUserFilesPre(r.pack) } catch (e) { userFilesError = String(e && e.message || e) }
|
|
7124
7351
|
// 索引失效:语义索引按工作区缓存,路径/内容变了必须让它重建(S5:索引本身不随包走)
|
|
7125
7352
|
try { this._semanticIndexStaleByWorkspace = this._semanticIndexStaleByWorkspace || new Set(); this._semanticIndexStaleByWorkspace.add(targetWs) } catch (_) {}
|
|
7126
7353
|
try { if (this.state) this.state.loadedAt = 0; await this.refresh(undefined) } catch (_) {}
|
|
@@ -7131,6 +7358,7 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
|
|
|
7131
7358
|
rewrite: { totalHits: plan.rewrite.totalHits, fileCount: plan.rewrite.fileCount, files: plan.rewrite.files.slice(0, 50) },
|
|
7132
7359
|
additions: plan.additions.length, overwrites: plan.overwrites.length,
|
|
7133
7360
|
summaryAction, summaryError,
|
|
7361
|
+
userFiles: userFilesResult.applied, userFilesSkipped: userFilesResult.skipped, userFilesError,
|
|
7134
7362
|
}
|
|
7135
7363
|
}
|
|
7136
7364
|
/** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
|
|
@@ -7565,6 +7793,44 @@ _wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.jso
|
|
|
7565
7793
|
return '已保存反思 ' + file
|
|
7566
7794
|
}
|
|
7567
7795
|
|
|
7796
|
+
/**
|
|
7797
|
+
* ★v3.1.3:**同步目录(云盘/远端)探测** —— 用户要求「增加一个在云盘或者远端同步的接口,
|
|
7798
|
+
* 尝试看看能不能做好兼容性」。
|
|
7799
|
+
*
|
|
7800
|
+
* 定位:**不发明私有同步协议、不做后台自动同步**(延续用户既有拍板)。本方法只做两件事:
|
|
7801
|
+
* ① 判断这个目录现在能不能用(存在 / 可写);
|
|
7802
|
+
* ② 给出人话诊断,帮用户把「导出目录指向云盘」这一步走对。
|
|
7803
|
+
*
|
|
7804
|
+
* 兼容性做法:**只认为它是个普通目录** —— OneDrive / 坚果云 / Dropbox / iCloud / 网络盘 / U 盘 /
|
|
7805
|
+
* 本地路径一律同等对待:不嗅探客户端、不依赖专有 API、不假设路径形态、不要求特定盘符。
|
|
7806
|
+
*
|
|
7807
|
+
* @param {string} dir 待检测目录(空 ⇒ 用配置里的 syncDir)
|
|
7808
|
+
* @returns {{ok:boolean, dir:string, exists:boolean, writable:boolean, looksSynced:boolean, hint:string}}
|
|
7809
|
+
*/
|
|
7810
|
+
async syncDirProbe(dir) {
|
|
7811
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
7812
|
+
const want = String(dir || (this.config && this.config.syncDir) || '').trim()
|
|
7813
|
+
if (!want) {
|
|
7814
|
+
return { ok: false, dir: '', exists: false, writable: false, looksSynced: false,
|
|
7815
|
+
hint: '未配置同步目录。填写你的云盘本地目录(如 OneDrive/坚果云 的同步文件夹)即可把导出包自动带过去;也可留空走默认导出目录。' }
|
|
7816
|
+
}
|
|
7817
|
+
const expanded = this.expandUserPath(want) || want
|
|
7818
|
+
let exists = false, writable = false
|
|
7819
|
+
try { const st = await stat(expanded); exists = !!st.isDirectory() } catch (_) { exists = false }
|
|
7820
|
+
if (exists) {
|
|
7821
|
+
const probe = path.join(expanded, '.dam-sync-probe-' + Date.now())
|
|
7822
|
+
try { await writeFile(probe, 'ok'); await unlink(probe); writable = true } catch (_) { writable = false }
|
|
7823
|
+
}
|
|
7824
|
+
// 「像同步盘」只是**提示**,不作门槛:命中常见客户端名就给一句更贴心的说明。
|
|
7825
|
+
const looksSynced = /onedrive|dropbox|nutstore|坚果云|icloud|googledrive|google drive|baidunetdisk|百度网盘/i.test(expanded)
|
|
7826
|
+
let hint
|
|
7827
|
+
if (!exists) hint = '该目录不存在。请确认路径拼写;若是网络盘,先在文件管理器里连上再试。'
|
|
7828
|
+
else if (!writable) hint = '目录存在但**不可写**。若是云盘,可能还在首次同步或被占用;换个位置或稍后重试。'
|
|
7829
|
+
else if (looksSynced) hint = '目录可用,且看起来是同步盘。导出到这里的包会被云盘自动同步到另一台机器;到那边用「导入包」显式导入即可(靠包内校验和对齐,不做后台自动同步)。'
|
|
7830
|
+
else hint = '目录可用。若不是同步盘,这个包不会自动到另一台机器——请手动拷贝(网盘上传/邮件/U 盘均可)。'
|
|
7831
|
+
return { ok: exists && writable, dir: expanded, exists, writable, looksSynced, hint }
|
|
7832
|
+
}
|
|
7833
|
+
|
|
7568
7834
|
// ---------- 日历/日程(用户级 CALENDAR.md) ----------
|
|
7569
7835
|
/** 解析 CALENDAR.md 为条目数组。 */
|
|
7570
7836
|
parseCalendar(text) {
|
|
@@ -10592,6 +10858,14 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10592
10858
|
try {
|
|
10593
10859
|
const agent = context && context.agent
|
|
10594
10860
|
if (!agent) return ''
|
|
10861
|
+
// ★v3.1.3(用户拍板「接线」):`injectEnabled` 此前是**死开关** —— 键白名单内有、
|
|
10862
|
+
// UI 能翻面能落盘,但全 lib/ 零读取点 ⇒ 用户关掉后记忆照旧每轮注入(界面骗人)。
|
|
10863
|
+
// 现在接入本回调最前面,作为**动态快照总闸**:关 = 真不注入。
|
|
10864
|
+
// ⚠️解耦(用户硬性要求「单一开关不得顺带改变其他功能」):只关这**一条**通路,不动
|
|
10865
|
+
// · 静态规则段 `renderMemoryStatic()`(systemPrompt.section,下方 :~10900,缓存锚 + 属另一功能)
|
|
10866
|
+
// · M6 tail surface(下方 :~10910)
|
|
10867
|
+
// · 记忆写入(memory_log / 自动沉淀)与检索(memory_recall)—— 关注入 ≠ 关记忆
|
|
10868
|
+
if (engine.config && engine.config.injectEnabled === false) return ''
|
|
10595
10869
|
// M0/M1: 绑定到该 agent 的 runtime 再读取(state 是 per-session getter,避免落到 default runtime 读到空)
|
|
10596
10870
|
return engine.withAgent(agent, () => {
|
|
10597
10871
|
const st = engine.stateFor(agent)
|
|
@@ -10762,7 +11036,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10762
11036
|
defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命;**账本/白板内容要落进面板看板泳道,须在标题或正文写 tag**:type:goal / type:state / type:dead-end / type:progress。kind=plan:整体写入/重写白板 PLAN.md(人能读的项目全貌规划图)。**首建亦可**——工作区若还没有白板,宿主已自动落了一版骨架,你理解全貌后直接整体重写成真内容即可;对项目全貌的理解发生实质变化时同样重写。旧版自动归档。**别把骨架留在那里当白板**。**结论失效时的两个通道(不要混用)**:supersedes=被更新结论取代(有后继);retract=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
|
|
10763
11037
|
content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
|
|
10764
11038
|
action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
|
|
10765
|
-
kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。'
|
|
11039
|
+
kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。'
|
|
11040
|
+
+ ' ★plan 重写前必须先 read 磁盘 handoff/PLAN.md 的**原文**并逐条抄回全部 <!--memory:mem_*--> 锚点:'
|
|
11041
|
+
+ '注入快照会被截断(显示「…(截断,全文见 handoff/ 白板与账本)」),不足以据此重写;'
|
|
11042
|
+
+ '缺锚点会被保护门拒绝(它会逐条比对前后卡片集合)。每张卡的原文与其用户备注区必须原样带回。' },
|
|
10766
11043
|
// ★G3(2026-09-19):结论层状态写入 —— **显式可选参数**,不传时行为与从前逐字节相同(零自动行为、零误判)。
|
|
10767
11044
|
// 设计依据:用户 2026-09-18 裁定「只认显式声明」(门槛 = 结构化参数 = 最强的显式)。
|
|
10768
11045
|
// 为什么不做"自动比对同主题旧条目":误判代价不对称(漏判=维持现状;误判=有效结论被标作废)。
|
|
@@ -10771,6 +11048,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10771
11048
|
retract: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 **retracted(撤回)** 的旧条目 memoryId 列表(mem_<32hex>)。**与 supersedes 的分工**:supersedes = 被**更新的结论取代**(有后继结论,可追 mem_id);**retract = 当时就做错了、直接撤回**(无后继,"错误本身"就是教训)。用户裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 检索仍会返回它并标 ⚠已撤回。**强烈建议同时传 retractReason 说明错在哪**。仅在你确知标错的是哪条时传。' },
|
|
10772
11049
|
retractReason: { type: 'string', description: '(可选,配合 retract)撤回原因,一行内说明**错在哪**,上限 120 字符。会写成 `reason="…"` 附在状态行上,供检索时显示。' },
|
|
10773
11050
|
restore: { type: 'array', items: { type: 'string' }, description: '(可选)**撤销通道**:把指定 memoryId 的状态改回 current(移除状态行)。用于纠正标错的 superseded/retracted。' },
|
|
11051
|
+
// ★v3.1.4 批 J(用户拍板「补 archivedIds 真入口」):此前底层 API 认这个键、
|
|
11052
|
+
// 但工具参数表没暴露 ⇒ 报错里那句「显式写入归档集合」对模型不可达(假出路)。
|
|
11053
|
+
// 现补齐:白板重写时可显式声明"这些卡我有意归档",丢卡检查据此放行并留痕。
|
|
11054
|
+
archivedIds: { type: 'array', items: { type: 'string' }, description: '(可选,仅 kind=plan 有意义)要**显式归档**的卡片 id 列表(mem_<32hex>)。用于白板重写时有意移除某张卡:列进来即视为"合法消失(有留痕)",不会被丢卡检查拦下。**只在你确实要移除该卡时传**;顺手漏抄卡片不要用它掩盖——那属于内容丢失,应改回原样带回。' },
|
|
10774
11055
|
}, async (args, exec) => {
|
|
10775
11056
|
const p = await engine.resolvePaths(exec.agent)
|
|
10776
11057
|
const content = String(args.content || '').trim()
|
|
@@ -10780,8 +11061,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10780
11061
|
const gateH = sanitizeForWrite(content, { maxEntryChars: args.kind === 'plan' ? 200000 : 8000 })
|
|
10781
11062
|
if (!gateH.ok) return 'memory_note: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gateH.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
10782
11063
|
const writeH = gateH.clean || content
|
|
11064
|
+
// ★v3.1.4 批 J:把 archivedIds 真的喂进写盘口(此前链路断开:参数表没有 → 门不被喂 → writePlanSnapshot 只传 4 字段)
|
|
11065
|
+
const archIds = Array.isArray(args.archivedIds) ? args.archivedIds.filter((x) => typeof x === 'string' && x) : []
|
|
10783
11066
|
const r = args.kind === 'plan'
|
|
10784
|
-
? await engine.writePlanSnapshot(p.projectDir, writeH)
|
|
11067
|
+
? await engine.writePlanSnapshot(p.projectDir, writeH, { archivedIds: archIds })
|
|
10785
11068
|
: await engine.writeHandoffLedger(p.projectDir, writeH)
|
|
10786
11069
|
// P0(2026-09-14):写入被保护门/判据门拦下时,**原样把可执行改写指引交给模型**
|
|
10787
11070
|
// (不是"白板写入失败(未知)"——模型拿那四个字是修不回来的)。
|
|
@@ -10794,8 +11077,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10794
11077
|
}
|
|
10795
11078
|
if (args.kind === 'plan') { engine.state.planText = (r && r.final) || writeH; engine.state.loadedAt = Date.now() }
|
|
10796
11079
|
else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
|
|
11080
|
+
// ★v3.1.4 批 J:M1 降级警示随成功回执一起给模型(放行但不静默)
|
|
11081
|
+
const warnLine = (r && r.warn) ? ('\n' + r.warn) : ''
|
|
10797
11082
|
return (args.kind === 'plan'
|
|
10798
|
-
? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
|
|
11083
|
+
? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path + warnLine
|
|
10799
11084
|
: '交接账本已写入: ' + r.path)
|
|
10800
11085
|
// ★ 2026-09-21 bugfix(A-2):handoff 此前**静默截断** —— sanitizeForWrite 超限时是
|
|
10801
11086
|
// 「截断 + truncated:true」返回 ok(:8625-8628),而本分支接住了 gateH 却从不外显该标志
|
|
@@ -11270,6 +11555,18 @@ engine._recallStats = createRecallStatsPre({
|
|
|
11270
11555
|
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11271
11556
|
},
|
|
11272
11557
|
},
|
|
11558
|
+
{
|
|
11559
|
+
kind: 'exact',
|
|
11560
|
+
path: API['kanban-card'],
|
|
11561
|
+
handler: async (req, res) => {
|
|
11562
|
+
// ★v3.1.2:单卡全文(GET 只读;loopback)。看板载荷不再内联 full,展开时按 id 单取。
|
|
11563
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11564
|
+
try {
|
|
11565
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
11566
|
+
return writeJson(res, 200, await engine.kanbanCardBody(url.searchParams.get('sessionId'), url.searchParams.get('id')))
|
|
11567
|
+
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11568
|
+
},
|
|
11569
|
+
},
|
|
11273
11570
|
{
|
|
11274
11571
|
kind: 'exact',
|
|
11275
11572
|
path: API['kanban-board'],
|
|
@@ -11832,7 +12129,12 @@ engine._recallStats = createRecallStatsPre({
|
|
|
11832
12129
|
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11833
12130
|
const body = await readJsonBody(req).catch(() => ({}))
|
|
11834
12131
|
try {
|
|
11835
|
-
|
|
12132
|
+
// ★v3.1.2 修:此前回退段用的是 `_lastAgent.cwd`,而该字段不存在 ——
|
|
12133
|
+
// 全库权威口径是 session.header.cwd(见 :2324 / :5781 / :10628),本文件 :2799 注释亦明写
|
|
12134
|
+
// 「不再用不存在的 agent.cwd」⇒ 回退段恒空,前端不传 ws 即 missing-ws(用户实测)。
|
|
12135
|
+
const _la = engine._lastAgent
|
|
12136
|
+
const _lacwd = (_la && _la.session && _la.session.header && _la.session.header.cwd) || ''
|
|
12137
|
+
const ws = String((body && body.ws) || _lacwd || '')
|
|
11836
12138
|
writeJson(res, 200, await engine.migrateExport({ ws, outPath: body && body.outPath, compress: !(body && body.compress === false) }))
|
|
11837
12139
|
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11838
12140
|
},
|
|
@@ -11922,8 +12224,22 @@ engine._recallStats = createRecallStatsPre({
|
|
|
11922
12224
|
handler: async (req, res) => {
|
|
11923
12225
|
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11924
12226
|
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11925
|
-
//
|
|
12227
|
+
// 调用系统的文件夹选择器。★v3.1.2 修:此前只试 `ctx.directoryPicker.capability()` 一种形态,
|
|
12228
|
+
// 与 DSH 内核标准契约不符 ⇒ 恒返回 native:false(用户实测「选择器不可用,请手动填写路径」)。
|
|
12229
|
+
// 内核形态(@linxin666/dsh-web-all / dsh-client-ui-skin-center 两处同源):
|
|
12230
|
+
// const r = await ctx.remote.directoryPicker.pick(); if (!r.ok) throw ...; return r.value
|
|
12231
|
+
// 这里做**双路径兼容**:内核契约优先 → 旧 capability 形态回退 → 都不可用才 native:false。
|
|
11926
12232
|
try {
|
|
12233
|
+
// 路径①:内核标准契约(远程/桌面形态下挂在 ctx.remote 上)
|
|
12234
|
+
let remotePicker = undefined
|
|
12235
|
+
try { remotePicker = ctx.remote && ctx.remote.directoryPicker } catch (e) {}
|
|
12236
|
+
if (remotePicker && typeof remotePicker.pick === 'function') {
|
|
12237
|
+
const r = await remotePicker.pick()
|
|
12238
|
+
if (r && r.ok === false) return writeJson(res, 200, { native: true, dir: null, error: String((r.error && r.error.message) || r.error || 'picker failed') })
|
|
12239
|
+
const v = r && (r.value !== undefined ? r.value : r)
|
|
12240
|
+
return writeJson(res, 200, { native: true, dir: (typeof v === 'string' ? v : (v && v.path) || null) })
|
|
12241
|
+
}
|
|
12242
|
+
// 路径②:本机既有形态(native 后端由 directory-picker-auto 按主机情况挂载)
|
|
11927
12243
|
let picker = undefined
|
|
11928
12244
|
try { picker = ctx.directoryPicker } catch (e) {}
|
|
11929
12245
|
if (!picker || typeof picker.capability !== 'function') return writeJson(res, 200, { native: false })
|
|
@@ -11985,12 +12301,40 @@ engine._recallStats = createRecallStatsPre({
|
|
|
11985
12301
|
if (!prof) return writeJson(res, 200, { ok: false, message: '未找到 dsh profile 目录(未安装插件或已删除)。' })
|
|
11986
12302
|
if (prof.installKind === 'dev-link') return writeJson(res, 200, { ok: false, message: '当前为本地开发链接(link:)安装,不适用 npm 更新,请直接同步开发源码。' })
|
|
11987
12303
|
if (prof.installKind !== 'registry') return writeJson(res, 200, { ok: false, message: '未检测到 registry 安装方式,无法自动更新。' })
|
|
11988
|
-
|
|
12304
|
+
// ★v3.1.2 修:pnpm 11 默认开启「1 天 Minimum Release Age」,对发布不足 24h 的版本**静默跳过**
|
|
12305
|
+
// (3.1.1 当天发布即中招)⇒ 旧命令 exit 0 但什么都没装,路由还报「成功」。
|
|
12306
|
+
// 改为:钉死目标版本(latest 也解析成具体版本号)+ 显式关闸门 + 装后复核(见下)。
|
|
12307
|
+
const target = String((await engine.checkUpdate(true)).latest || '').trim()
|
|
12308
|
+
const spec = '@a9i5k4/dsh-auto-memory' + (target ? '@' + target : '@latest')
|
|
12309
|
+
const cmd = prof.usesPnpm
|
|
12310
|
+
? 'pnpm add ' + spec + ' --config.minimumReleaseAge=0'
|
|
12311
|
+
: 'npm install ' + spec + ' --min-release-age=0'
|
|
11989
12312
|
const out = await execP(cmd, { cwd: prof.dir, timeout: 120000, windowsHide: true })
|
|
11990
12313
|
const tail = ((out.stdout || '') + (out.stderr || '')).trim().slice(-1200)
|
|
12314
|
+
// ★v3.1.2:装后复核 —— 旧实现无条件报成功,供应链闸门静默跳过时用户被谎报「安装成功」。
|
|
12315
|
+
// 读 profile 里该包的真实 version;与目标不符即 ok:false(并把命令输出带回去供排查)。
|
|
12316
|
+
let landed = ''
|
|
12317
|
+
try {
|
|
12318
|
+
const pj = await this.readTextSafe(path.join(prof.dir, 'node_modules', '@a9i5k4', 'dsh-auto-memory', 'package.json'))
|
|
12319
|
+
if (pj) { try { landed = String(JSON.parse(pj).version || '') } catch (_) {} }
|
|
12320
|
+
} catch (_) {}
|
|
12321
|
+
const want = target || landed
|
|
12322
|
+
if (want && landed !== want) {
|
|
12323
|
+
return writeJson(res, 200, {
|
|
12324
|
+
ok: false,
|
|
12325
|
+
message: (landed ? ('安装后版本仍为 ' + landed + ',目标 ' + want) : '未检测到已安装的包版本')
|
|
12326
|
+
+ '(若为 pnpm 11 供应链闸门所致,命令已带 --config.minimumReleaseAge=0,请把下方输出反馈给作者)',
|
|
12327
|
+
output: tail || '(无输出)',
|
|
12328
|
+
landedVersion: landed,
|
|
12329
|
+
targetVersion: want,
|
|
12330
|
+
installKind: prof.installKind,
|
|
12331
|
+
})
|
|
12332
|
+
}
|
|
11991
12333
|
writeJson(res, 200, {
|
|
11992
12334
|
ok: true,
|
|
11993
12335
|
output: tail || '(无输出)',
|
|
12336
|
+
landedVersion: landed,
|
|
12337
|
+
targetVersion: want,
|
|
11994
12338
|
restartHint: '更新完成,请重启 dsh web 生效。',
|
|
11995
12339
|
installKind: prof.installKind,
|
|
11996
12340
|
})
|