@a9i5k4/dsh-auto-memory 3.1.3 → 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/lib/client.js +59 -8
- package/lib/index.js +106 -10
- package/lib/memory-mutation.js +7 -2
- package/lib/migrate-pack.js +33 -24
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. settings.section — 自动记忆设置页(存储位置、注入、反思风格)
|
|
8
8
|
* Data flows over /api/dsh-auto-memory/* (loopback-only host routes).
|
|
9
9
|
*/
|
|
10
|
-
console.log('[dsh-auto-memory] client v3.1.
|
|
10
|
+
console.log('[dsh-auto-memory] client v3.1.4 fingerprint: ci-restore-and-migrate-safe-import')
|
|
11
11
|
window.__ModuleLoader__.load({
|
|
12
12
|
id: '@a9i5k4/dsh-auto-memory',
|
|
13
13
|
factory: (require) => {
|
|
@@ -289,9 +289,9 @@ window.__ModuleLoader__.load({
|
|
|
289
289
|
kvMem: '记忆文件', kvMemUser: '用户', kvMemNotes: '笔记', kvMemLog: '日志', kvMemMissing: '缺失',
|
|
290
290
|
kvWs: '当前工作区', kvWsUnknown: '(未取到)', kvApi: 'API 连通', unknown: '(未知)',
|
|
291
291
|
// ★P0-2/P0-3(2026-09-22):把后端早已算好、前端零消费者的两组可观测性接到界面上。
|
|
292
|
-
dbgHubIo: '三层记忆落盘', dbgPrune: '事实淘汰(保留上限)',
|
|
293
|
-
dbgLogs: '诊断日志',
|
|
294
|
-
dbgLogsNone: '尚未生成(本次运行还没有诊断输出)',
|
|
292
|
+
dbgHubIo: '三层记忆落盘', dbgPrune: '事实淘汰(保留上限)',
|
|
293
|
+
dbgLogs: '诊断日志',
|
|
294
|
+
dbgLogsNone: '尚未生成(本次运行还没有诊断输出)',
|
|
295
295
|
dbgLogsHint: '崩溃/异常时请把下面路径的文件附给维护者(只读展示,不含日志正文)',
|
|
296
296
|
kvPyTitle: 'Python 语义侧车', kvPyOff: '未启用(JS 端语义不受影响)', kvPyAlive: '运行中',
|
|
297
297
|
kvPyWatchdog: '看门狗击杀 / 重生', kvPyStderr: '最近 stderr 尾部', kvPyStderrTrunc: '(已截断)',
|
|
@@ -529,9 +529,9 @@ window.__ModuleLoader__.load({
|
|
|
529
529
|
kvAvail: 'available', kvUnavail: 'unavailable', kvDup: 'Duplicate note headings', kvDupCount: '',
|
|
530
530
|
kvMem: 'Memory files', kvMemUser: 'user', kvMemNotes: 'notes', kvMemLog: 'log', kvMemMissing: 'missing',
|
|
531
531
|
kvWs: 'Current workspace', kvWsUnknown: '(unknown)', kvApi: 'API probes', unknown: '(unknown)',
|
|
532
|
-
dbgHubIo: 'Layer-3 memory persistence', dbgPrune: 'Fact eviction (retention cap)',
|
|
533
|
-
dbgLogs: 'Diagnostic log',
|
|
534
|
-
dbgLogsNone: 'not created yet (no diagnostic output this run)',
|
|
532
|
+
dbgHubIo: 'Layer-3 memory persistence', dbgPrune: 'Fact eviction (retention cap)',
|
|
533
|
+
dbgLogs: 'Diagnostic log',
|
|
534
|
+
dbgLogsNone: 'not created yet (no diagnostic output this run)',
|
|
535
535
|
dbgLogsHint: 'On a crash/exception, attach the file at the path below (read-only; log body not shown)',
|
|
536
536
|
kvPyTitle: 'Python sidecar', kvPyOff: 'disabled (JS-side semantics unaffected)', kvPyAlive: 'running',
|
|
537
537
|
kvPyWatchdog: 'Watchdog kills / respawns', kvPyStderr: 'Last stderr tail', kvPyStderrTrunc: ' (truncated)',
|
|
@@ -806,6 +806,19 @@ window.__ModuleLoader__.load({
|
|
|
806
806
|
|
|
807
807
|
// ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
|
|
808
808
|
var CHANGELOG = {
|
|
809
|
+
'3.1.4': { zh: [
|
|
810
|
+
'★ 修复:白板重写不再「每次都重试」。此前模型不知道锚点契约、注入快照又被截断,首轮必被拦一次;现在工具描述写明「先读磁盘原文抄回锚点」,被拦时还会把缺失卡片的原文块一并回吐,一轮即可补齐。',
|
|
811
|
+
'★ 变更:白板丢卡从「拒绝写入」改为「照写 + 显式警示」——放行但绝不静默,警示会说明丢了哪几张、从哪找回。用户手写备注区被覆盖、以及卡片 id 重复,仍然硬拒(这两类是真丢数据/结构错误)。',
|
|
812
|
+
'修复:补上白板重写时的「显式归档」入口(archivedIds)——此前报错里承诺的这条路根本没接通。',
|
|
813
|
+
'修复:引导末页的三个入口按钮此前是裸链接(只有属性没有样式),现在与页面风格统一:hover 有反馈、跟随主题色。',
|
|
814
|
+
'新增:引导末页新增「QQ 交流群」入口。',
|
|
815
|
+
], en: [
|
|
816
|
+
'★ Fix: rewriting the whiteboard no longer fails on the first try. The model was never told about the anchor contract, and the injected snapshot is truncated — so the first attempt was always rejected. The tool description now says "read the on-disk file first and copy every anchor back", and a rejection now returns the missing cards\' verbatim text so one retry suffices.',
|
|
817
|
+
'★ Change: a dropped whiteboard card is now written with an explicit warning instead of being rejected — never silent, and the warning names the cards lost and where to recover them. Overwriting your own notes region, and duplicate card ids, are still hard-rejected (real data loss / structural errors).',
|
|
818
|
+
'Fix: added the missing "explicit archive" entry (archivedIds) for whiteboard rewrites — the path promised by the error message was never actually wired.',
|
|
819
|
+
'Fix: the three entry buttons on the tour\'s last page were bare links (attribute without styles); they now match the page style, with hover feedback and theme colours.',
|
|
820
|
+
'New: a QQ community group entry on the tour\'s last page.',
|
|
821
|
+
] },
|
|
809
822
|
'3.1.3': { zh: [
|
|
810
823
|
'★ 修复:宿主下次改会话文件名时,五条功能链会集体静默降级 —— 已从根上拆掉;会话日志路径不再用固定候选表,改按结构识别(兼容未来 v4 命名)。',
|
|
811
824
|
'★ 修复:迁移搬包的范围扩到日历(CALENDAR.md);日历跨工作区共享,导入按合并不覆盖,本机已完成的条目不被打回,同包重导不重复。',
|
|
@@ -1851,6 +1864,20 @@ window.__ModuleLoader__.load({
|
|
|
1851
1864
|
'[data-dam-tour-sw][data-on="true"] { background: var(--dam-accent, #3a6df0); }',
|
|
1852
1865
|
'[data-dam-tour-sw][data-on="true"]::after { left: 19.5px; }',
|
|
1853
1866
|
'[data-dam-tour-chips] { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; margin-top: 12px; }',
|
|
1867
|
+
// ★v3.1.4 批 J(用户报「这个按钮实在是有点太难看了」):v3.1.3 只加了 data-dam-tour-links
|
|
1868
|
+
// 属性却没写 CSS ⇒ 浏览器按默认 a 渲染成蓝色下划线裸链接,与页面完全脱节。
|
|
1869
|
+
// 现补齐:与既有 chip 体系同源(--dam-accent 主题色 + 圆角胶囊 + hover + 深色底)。
|
|
1870
|
+
'[data-dam-tour-links] { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; }',
|
|
1871
|
+
'[data-dam-tour-link] { display: inline-flex; align-items: center; gap: 5px; padding: 6px 13px; border-radius: 99px;',
|
|
1872
|
+
' font-size: 12px; font-weight: 600; line-height: 1.4; text-decoration: none; cursor: pointer; white-space: nowrap;',
|
|
1873
|
+
' color: color-mix(in srgb, var(--dam-accent, #3a6df0) 78%, #fff);',
|
|
1874
|
+
' background: color-mix(in srgb, var(--dam-accent, #3a6df0) 12%, rgba(255,255,255,.05));',
|
|
1875
|
+
' border: 1px solid color-mix(in srgb, var(--dam-accent, #3a6df0) 34%, transparent);',
|
|
1876
|
+
' transition: background .18s ease, border-color .18s ease, transform .12s ease; }',
|
|
1877
|
+
'[data-dam-tour-link]:hover { background: color-mix(in srgb, var(--dam-accent, #3a6df0) 21%, rgba(255,255,255,.07));',
|
|
1878
|
+
' border-color: color-mix(in srgb, var(--dam-accent, #3a6df0) 52%, transparent); }',
|
|
1879
|
+
'[data-dam-tour-link]:active { transform: scale(.98); }',
|
|
1880
|
+
'[data-dam-tour-links-note] { flex-basis: 100%; text-align: center; font-size: 11px; opacity: .55; margin-top: 2px; line-height: 1.6; }',
|
|
1854
1881
|
'[data-dam-tour-where] { font-size: 11.5px; opacity: .72; line-height: 1.75; margin-top: 12px; text-align: left; max-width: 460px; margin-left: auto; margin-right: auto; }',
|
|
1855
1882
|
'[data-dam-tour-where] b { opacity: .96; font-weight: 650; }',
|
|
1856
1883
|
// ── CHANGELOG 开场序列:Logo 组装→展开→消散→内容浮现 ──
|
|
@@ -2060,6 +2087,26 @@ window.__ModuleLoader__.load({
|
|
|
2060
2087
|
return text
|
|
2061
2088
|
}
|
|
2062
2089
|
|
|
2090
|
+
/**
|
|
2091
|
+
* ★v3.1.4 修复(用户报障「白板看板点进去就白屏」):抽屉正文**单独组件**。
|
|
2092
|
+
*
|
|
2093
|
+
* 根因(栈追踪直指): 此前正文写成 `h('pre', …, (useCardFull(drawer) || …))`,
|
|
2094
|
+
* 而该处位于 `drawer ? kxPortal(...) : null` 的**条件分支内部** ⇒
|
|
2095
|
+
* **未开抽屉时不调这个 hook、打开抽屉才调** ⇒ 同一组件两次渲染 hook 数量不同
|
|
2096
|
+
* ⇒ React error #310(Rendered more hooks than during the previous render)
|
|
2097
|
+
* ⇒ slot 'conversation.view' 整块崩溃 ⇒ 白屏。**「点击」这个动作本身**改变了 hook 数。
|
|
2098
|
+
* (同一 hook 在 KanbanCard 里写法是对的:`useCardFull(expanded ? c : null)` —— 无条件调用、参数可空。)
|
|
2099
|
+
*
|
|
2100
|
+
* 修法: 把 hook 收进本组件;它**只在 drawer 存在时才被挂载**,
|
|
2101
|
+
* 挂载期间 hook 数量恒定 ⇒ #310 消除。原 L2545 的兜底链
|
|
2102
|
+
* `|| drawer.full || drawer.preview || ''` 一并保留(绝不显示空白)。
|
|
2103
|
+
*/
|
|
2104
|
+
function KanbanDrawerBody(props) {
|
|
2105
|
+
var d = props.drawer || {}
|
|
2106
|
+
var text = useCardFull(d) || d.full || d.preview || ''
|
|
2107
|
+
return h('pre', { 'data-dam-kx-full': d.id, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'inherit', margin: 0, fontSize: '12px' } }, text)
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2063
2110
|
/** 单张卡片: 标题 + 判据徽章 + 预览/全文 + 小节徽章 + 标签 + 来源。可点击展开。 */
|
|
2064
2111
|
function KanbanCard(props) {
|
|
2065
2112
|
var c = props.card || {}
|
|
@@ -2515,7 +2562,8 @@ window.__ModuleLoader__.load({
|
|
|
2515
2562
|
? h('div', { key: 'sg', style: { display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: '8px' } },
|
|
2516
2563
|
drawer.sectionTags.map(function (tg, i) { return h('span', { key: i, 'data-dam-chip': '', style: { fontSize: '10px', padding: '1px 5px', borderRadius: '3px', background: 'rgba(76,141,255,.18)' } }, tg) }))
|
|
2517
2564
|
: null,
|
|
2518
|
-
|
|
2565
|
+
// ★v3.1.4:改为挂载子组件(hook 收进去)——原写法在条件分支里调 hook ⇒ React #310 白屏
|
|
2566
|
+
h(KanbanDrawerBody, { key: 'body', drawer: drawer }),
|
|
2519
2567
|
].map(function (node, i) {
|
|
2520
2568
|
// 头部固定、正文滚动: 把内容各自包一层; 正文那层给 flex:1 + overflow:auto
|
|
2521
2569
|
if (i === 0) return h('div', { key: 'headwrap', style: { padding: '12px 14px 8px', flexShrink: 0 } }, node)
|
|
@@ -6126,6 +6174,9 @@ function WorkspaceTab() {
|
|
|
6126
6174
|
locale === 'zh' ? '贡献者与赞助' : 'Contributors & Sponsors'),
|
|
6127
6175
|
h('a', { href: 'https://api.dshapi.icu/register?aff=HJU27P7JL39N', target: '_blank', rel: 'noopener noreferrer', 'data-dam-tour-link': '' },
|
|
6128
6176
|
locale === 'zh' ? 'DSH API 中转站' : 'DSH API relay'),
|
|
6177
|
+
// ★v3.1.4 批 J:第三个按钮 = QQ 交流群(链接与 README / CONTRIBUTORS 同源,不新造)
|
|
6178
|
+
h('a', { href: 'https://qm.qq.com/q/v7Asxn6vPa', target: '_blank', rel: 'noopener noreferrer', 'data-dam-tour-link': '' },
|
|
6179
|
+
locale === 'zh' ? 'QQ 交流群' : 'QQ group'),
|
|
6129
6180
|
h('div', { 'data-dam-tour-links-note': '' },
|
|
6130
6181
|
locale === 'zh' ? '感谢为本项目出力的贡献者与基础设施赞助方。' : 'Thanks to our contributors and infrastructure sponsors.')) : null,
|
|
6131
6182
|
h('div', { 'data-dam-tour-dots': '' },
|
package/lib/index.js
CHANGED
|
@@ -2455,16 +2455,22 @@ class MemoryEngine {
|
|
|
2455
2455
|
} catch (e) { return '' }
|
|
2456
2456
|
}
|
|
2457
2457
|
|
|
2458
|
-
/** 最新一篇四段式交接账本(按 mtime
|
|
2458
|
+
/** 最新一篇四段式交接账本(按 mtime 取最新;mtime 相同用文件名时间戳 + 同秒后缀稳定决胜)。 */
|
|
2459
2459
|
async readLatestHandoff(handoffDir) {
|
|
2460
2460
|
try {
|
|
2461
2461
|
const names = (await readdir(handoffDir)).filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n))
|
|
2462
2462
|
if (!names.length) return ''
|
|
2463
|
-
let best = '', bestMt = -1
|
|
2463
|
+
let best = '', bestMt = -1, bestOrder = ''
|
|
2464
2464
|
for (const n of names) {
|
|
2465
2465
|
const st = await stat(path.join(handoffDir, n)).catch(() => null)
|
|
2466
2466
|
const mt = st ? Number(st.mtimeMs) : 0
|
|
2467
|
-
|
|
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
|
+
}
|
|
2468
2474
|
}
|
|
2469
2475
|
return best ? ((await this.readTextSafe(path.join(handoffDir, best))) || '') : ''
|
|
2470
2476
|
} catch (e) { return '' }
|
|
@@ -2556,8 +2562,12 @@ class MemoryEngine {
|
|
|
2556
2562
|
projectDir,
|
|
2557
2563
|
relPath: 'handoff/PLAN.md',
|
|
2558
2564
|
skipCriteria: !!(opts && opts.skipCriteria),
|
|
2565
|
+
// ★v3.1.4 批 J:显式归档 id 贯穿到保护门(此前这一层从不传 ⇒ 报错里那句是假出路)
|
|
2566
|
+
archivedIds: Array.isArray(opts && opts.archivedIds) ? opts.archivedIds : [],
|
|
2559
2567
|
})
|
|
2560
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 || '') : ''
|
|
2561
2571
|
let archived = ''
|
|
2562
2572
|
if (existing && existing.trim() && existing.trim() !== incoming.trim()) {
|
|
2563
2573
|
const archDir = path.join(dir, 'archive')
|
|
@@ -2619,7 +2629,7 @@ class MemoryEngine {
|
|
|
2619
2629
|
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2620
2630
|
await this.appendSidecarEventPre(projectDir, { actor: 'engine', event: 'written', target: 'PLAN.md', details: { chars: written.length, movedHistory: movedHistory || 0 } })
|
|
2621
2631
|
}
|
|
2622
|
-
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 }
|
|
2623
2633
|
} catch (e) {
|
|
2624
2634
|
return { ok: false, error: String((e && e.message) || e) }
|
|
2625
2635
|
}
|
|
@@ -2976,6 +2986,55 @@ class MemoryEngine {
|
|
|
2976
2986
|
return docs
|
|
2977
2987
|
}
|
|
2978
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
|
+
|
|
2979
3038
|
checkMutationPre(input = {}) {
|
|
2980
3039
|
const o = input && typeof input === 'object' ? input : {}
|
|
2981
3040
|
const target = String(o.target || 'other')
|
|
@@ -3023,10 +3082,36 @@ class MemoryEngine {
|
|
|
3023
3082
|
archivedIds: Array.isArray(o.archivedIds) ? o.archivedIds : [],
|
|
3024
3083
|
strict: this.config.criteriaGate !== false,
|
|
3025
3084
|
})
|
|
3026
|
-
|
|
3027
|
-
|
|
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
|
|
3028
3104
|
}
|
|
3029
|
-
|
|
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 || '' }
|
|
3030
3115
|
} catch (e) {
|
|
3031
3116
|
// fail-soft:保护门自身出错时**放行**会让保护形同虚设,但硬拒会把白板彻底锁死。
|
|
3032
3117
|
// 取安全侧 = **拒写并显式报错**(宁可让用户看到一次失败,也不静默丢卡)。
|
|
@@ -10951,7 +11036,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10951
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=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
|
|
10952
11037
|
content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
|
|
10953
11038
|
action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
|
|
10954
|
-
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
|
+
+ '缺锚点会被保护门拒绝(它会逐条比对前后卡片集合)。每张卡的原文与其用户备注区必须原样带回。' },
|
|
10955
11043
|
// ★G3(2026-09-19):结论层状态写入 —— **显式可选参数**,不传时行为与从前逐字节相同(零自动行为、零误判)。
|
|
10956
11044
|
// 设计依据:用户 2026-09-18 裁定「只认显式声明」(门槛 = 结构化参数 = 最强的显式)。
|
|
10957
11045
|
// 为什么不做"自动比对同主题旧条目":误判代价不对称(漏判=维持现状;误判=有效结论被标作废)。
|
|
@@ -10960,6 +11048,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10960
11048
|
retract: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 **retracted(撤回)** 的旧条目 memoryId 列表(mem_<32hex>)。**与 supersedes 的分工**:supersedes = 被**更新的结论取代**(有后继结论,可追 mem_id);**retract = 当时就做错了、直接撤回**(无后继,"错误本身"就是教训)。用户裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 检索仍会返回它并标 ⚠已撤回。**强烈建议同时传 retractReason 说明错在哪**。仅在你确知标错的是哪条时传。' },
|
|
10961
11049
|
retractReason: { type: 'string', description: '(可选,配合 retract)撤回原因,一行内说明**错在哪**,上限 120 字符。会写成 `reason="…"` 附在状态行上,供检索时显示。' },
|
|
10962
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>)。用于白板重写时有意移除某张卡:列进来即视为"合法消失(有留痕)",不会被丢卡检查拦下。**只在你确实要移除该卡时传**;顺手漏抄卡片不要用它掩盖——那属于内容丢失,应改回原样带回。' },
|
|
10963
11055
|
}, async (args, exec) => {
|
|
10964
11056
|
const p = await engine.resolvePaths(exec.agent)
|
|
10965
11057
|
const content = String(args.content || '').trim()
|
|
@@ -10969,8 +11061,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10969
11061
|
const gateH = sanitizeForWrite(content, { maxEntryChars: args.kind === 'plan' ? 200000 : 8000 })
|
|
10970
11062
|
if (!gateH.ok) return 'memory_note: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gateH.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
10971
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) : []
|
|
10972
11066
|
const r = args.kind === 'plan'
|
|
10973
|
-
? await engine.writePlanSnapshot(p.projectDir, writeH)
|
|
11067
|
+
? await engine.writePlanSnapshot(p.projectDir, writeH, { archivedIds: archIds })
|
|
10974
11068
|
: await engine.writeHandoffLedger(p.projectDir, writeH)
|
|
10975
11069
|
// P0(2026-09-14):写入被保护门/判据门拦下时,**原样把可执行改写指引交给模型**
|
|
10976
11070
|
// (不是"白板写入失败(未知)"——模型拿那四个字是修不回来的)。
|
|
@@ -10983,8 +11077,10 @@ engine._recallStats = createRecallStatsPre({
|
|
|
10983
11077
|
}
|
|
10984
11078
|
if (args.kind === 'plan') { engine.state.planText = (r && r.final) || writeH; engine.state.loadedAt = Date.now() }
|
|
10985
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) : ''
|
|
10986
11082
|
return (args.kind === 'plan'
|
|
10987
|
-
? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
|
|
11083
|
+
? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path + warnLine
|
|
10988
11084
|
: '交接账本已写入: ' + r.path)
|
|
10989
11085
|
// ★ 2026-09-21 bugfix(A-2):handoff 此前**静默截断** —— sanitizeForWrite 超限时是
|
|
10990
11086
|
// 「截断 + truncated:true」返回 ok(:8625-8628),而本分支接住了 gateH 却从不外显该标志
|
package/lib/memory-mutation.js
CHANGED
|
@@ -215,8 +215,13 @@ export function mutationRefusalTextPre(res) {
|
|
|
215
215
|
if (!failed.length) return ''
|
|
216
216
|
const lines = failed.map((h) => '· [' + h.id + '] ' + h.detail)
|
|
217
217
|
return '写入被记忆保护门拦截(' + failed.map((h) => h.id).join('/') + '),原文件未改动:\n' + lines.join('\n')
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
// ★v3.1.4 批 I:删掉一条**对模型不可达的出路**(原先承诺"显式归档即可合法消失")——
|
|
219
|
+
// 经三层取证该通道从未接通:底层 API 认 archivedIds,但中间层从不被喂、调用层只传 4 个字段、
|
|
220
|
+
// 工具参数表也没有该键。给不存在的出路会让模型反复试同一条路(用户报障"每次都重试")。
|
|
221
|
+
// 现改为**唯一真出路**:把消失卡片原样带回(文案上方会附其原文块)。
|
|
222
|
+
// ⚠️ 计数断言的坑(本轮踩到):替换文本自身若提到被删的那句,cnt() 就不为 0 ⇒ 注释里不再复述原句。
|
|
223
|
+
+ '\n请修正后重试:把上述卡片**原样带回**(标题 + 锚点行 + 正文,含其用户备注区,逐字节不变)——'
|
|
224
|
+
+ '这是唯一可通过的写法。白板重写前请先 read 磁盘 handoff/PLAN.md 原文抄全锚点(注入快照会被截断)。'
|
|
220
225
|
}
|
|
221
226
|
|
|
222
227
|
/**
|
package/lib/migrate-pack.js
CHANGED
|
@@ -59,39 +59,42 @@ export function pathVariantsPre(p) {
|
|
|
59
59
|
* 文本级路径重写(纯函数)。**这是导入时唯一会改动用户正文的地方**,故:
|
|
60
60
|
* - 逐形态替换,长形态优先
|
|
61
61
|
* - 返回 `hits`(改了多少处)供预览逐文件展示,用户能看见改在哪
|
|
62
|
-
* -
|
|
62
|
+
* - 只做**字面量**替换:路径全部转义,不解释为正则/模糊模式
|
|
63
63
|
*
|
|
64
64
|
* @param {string} text
|
|
65
65
|
* @param {{fromPath:string, toPath:string, fromSlug?:string, toSlug?:string}} opt
|
|
66
66
|
* @returns {{text:string, hits:number}}
|
|
67
67
|
*/
|
|
68
68
|
export function rewritePathsInTextPre(text, opt = {}) {
|
|
69
|
-
|
|
69
|
+
const s = String(text == null ? '' : text)
|
|
70
70
|
let hits = 0
|
|
71
|
+
const pairs = new Map()
|
|
72
|
+
const add = (from, to) => { if (from && !pairs.has(from)) pairs.set(from, to) }
|
|
71
73
|
const fromPath = String(opt.fromPath || '')
|
|
72
74
|
const toPath = String(opt.toPath || '')
|
|
73
75
|
if (fromPath && toPath && fromPath !== toPath) {
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (!fv || fv === tv) continue
|
|
82
|
-
const before = s
|
|
83
|
-
s = s.split(fv).join(tv)
|
|
84
|
-
if (s !== before) hits += countOccurrencesPre(before, fv)
|
|
85
|
-
}
|
|
76
|
+
const fromPosix = fromPath.replace(/\\/g, '/')
|
|
77
|
+
const toPosix = toPath.replace(/\\/g, '/')
|
|
78
|
+
// 按形态配对,不能分别按长度排序后用下标配对:路径深度会改变排序。
|
|
79
|
+
add(fromPath, toPath)
|
|
80
|
+
add(fromPosix, toPosix)
|
|
81
|
+
add('file:///' + fromPosix, 'file:///' + toPosix)
|
|
82
|
+
if (fromPath.includes('\\')) add(fromPath.replace(/\\/g, '\\\\'), toPath.replace(/\\/g, '\\\\'))
|
|
86
83
|
}
|
|
87
84
|
const fromSlug = String(opt.fromSlug || '')
|
|
88
85
|
const toSlug = String(opt.toSlug || '')
|
|
89
|
-
if (fromSlug && toSlug && fromSlug !== toSlug)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
86
|
+
if (fromSlug && toSlug && fromSlug !== toSlug) add(fromSlug, toSlug)
|
|
87
|
+
if (!pairs.size) return { text: s, hits }
|
|
88
|
+
// 只扫描原文一次;新路径可能包含旧路径/slug,不能再次替换刚插入的文本。
|
|
89
|
+
// 转义所有元字符,正则仅用于一次性匹配字面量;回调也保护目标中的 $&/$1。
|
|
90
|
+
const pattern = Array.from(pairs.keys()).sort((a, b) => b.length - a.length)
|
|
91
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
|
|
92
|
+
const rewritten = s.replace(new RegExp(pattern, 'g'), (match) => {
|
|
93
|
+
const replacement = pairs.get(match)
|
|
94
|
+
if (replacement !== match) hits++
|
|
95
|
+
return replacement
|
|
96
|
+
})
|
|
97
|
+
return { text: rewritten, hits }
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/** 字面量子串出现次数(不用正则,避免特殊字符)。 */
|
|
@@ -270,6 +273,8 @@ export function planImportPre(pack, opt = {}) {
|
|
|
270
273
|
const additions = []
|
|
271
274
|
const overwrites = []
|
|
272
275
|
const replaced = {} // 实际会写入的文件(含改名后的键)
|
|
276
|
+
// 预留本机文件与整包原始文件名,避免另存覆盖旧备份或尚未遍历的包内文件。
|
|
277
|
+
const reserved = new Set([...Object.keys(existing), ...Object.keys(files)])
|
|
273
278
|
for (const name of Object.keys(files)) {
|
|
274
279
|
const next = String(files[name])
|
|
275
280
|
const has = Object.prototype.hasOwnProperty.call(existing, name)
|
|
@@ -283,7 +288,10 @@ export function planImportPre(pack, opt = {}) {
|
|
|
283
288
|
continue
|
|
284
289
|
}
|
|
285
290
|
// rename:A 机的版本改名为 <名字>.from-pack,B 机原文件保持不动
|
|
286
|
-
|
|
291
|
+
let ordinal = 1
|
|
292
|
+
let alt = renameForConflictPre(name, ordinal)
|
|
293
|
+
while (reserved.has(alt)) alt = renameForConflictPre(name, ++ordinal)
|
|
294
|
+
reserved.add(alt)
|
|
287
295
|
additions.push({ path: alt, bytes: Buffer.byteLength(next, 'utf8'), renamedFrom: name })
|
|
288
296
|
replaced[alt] = next
|
|
289
297
|
}
|
|
@@ -310,12 +318,13 @@ export function planImportPre(pack, opt = {}) {
|
|
|
310
318
|
}
|
|
311
319
|
|
|
312
320
|
/** 冲突改名:`handoff/PLAN.md` → `handoff/PLAN.from-pack.md`(保留扩展名)。 */
|
|
313
|
-
export function renameForConflictPre(relPath) {
|
|
321
|
+
export function renameForConflictPre(relPath, ordinal = 1) {
|
|
314
322
|
const s = String(relPath)
|
|
323
|
+
const suffix = '.from-pack' + (ordinal > 1 ? '-' + ordinal : '')
|
|
315
324
|
const i = s.lastIndexOf('.')
|
|
316
325
|
const j = s.lastIndexOf('/')
|
|
317
|
-
if (i > j && i > 0) return s.slice(0, i) +
|
|
318
|
-
return s +
|
|
326
|
+
if (i > j && i > 0) return s.slice(0, i) + suffix + s.slice(i)
|
|
327
|
+
return s + suffix
|
|
319
328
|
}
|
|
320
329
|
|
|
321
330
|
// ───────────────────────── 全局记录合并(纯函数)─────────────────────────
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a9i5k4/dsh-auto-memory",
|
|
3
3
|
"description": "Proactive associative memory for DSH: zero-prompt recall injected before the model speaks, three-layer auto-consolidation, skill crystallization, and Astra-style context management - handoff ledgers, PLAN whiteboard, water-level sensing. Local-first, model-agnostic, zero deps. 主动联想记忆+Astra 式上下文管理:自动唤回/自动沉淀/技能固化/交接账本与白板跨窗口续命/水位感知。",
|
|
4
|
-
"version": "3.1.
|
|
4
|
+
"version": "3.1.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|