@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.
@@ -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
- + '\n请修正后重试:消失的卡片要么原样保留,要么显式写入归档集合(并在归档文件里留痕);'
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
  /**
@@ -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
- let s = String(text == null ? '' : text)
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 toVariants = pathVariantsPre(toPath)
75
- const fromVariants = pathVariantsPre(fromPath)
76
- // 建立「同形态 → 同形态」映射:Windows 原形对 Windows 原形、posix 对 posix …
77
- // 简化为按索引对齐(pathVariantsPre 的排序对两者一致,因为只是斜杠方向不同)
78
- for (let i = 0; i < fromVariants.length; i++) {
79
- const fv = fromVariants[i]
80
- const tv = toVariants[i] !== undefined ? toVariants[i] : toPath
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
- const before = s
91
- s = s.split(fromSlug).join(toSlug)
92
- if (s !== before) hits += countOccurrencesPre(before, fromSlug)
93
- }
94
- return { text: s, hits }
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
  /** 字面量子串出现次数(不用正则,避免特殊字符)。 */
@@ -151,6 +154,17 @@ export function buildPackPre(opt = {}) {
151
154
  if (bytes > MIGRATE_PACK_MAX_BYTES_PRE) return { ok: false, error: 'too-large:' + bytes, warnings }
152
155
  // 体积提示(不阻断):超过 16 MB 时提醒用户包会很大
153
156
  if (bytes > 16 * 1024 * 1024) warnings.push('pack-large:' + Math.round(bytes / 1048576) + 'MB')
157
+ // ★v3.1.3:用户级文件(如 `CALENDAR.md`)—— 与 `files` 的语义区别:
158
+ // files 相对「工作区 slug 目录」,userFiles 相对「用户级 memory 目录」,且**跨工作区共享**。
159
+ // 老包无此字段 ⇒ 视为 {},行为逐字节不变(向后兼容)。
160
+ const userFiles = (opt.userFiles && typeof opt.userFiles === 'object') ? opt.userFiles : {}
161
+ let userBytes = 0
162
+ for (const n of Object.keys(userFiles)) {
163
+ if (n.includes('..') || n.startsWith('/') || n.startsWith('\\')) {
164
+ return { ok: false, error: 'unsafe-user-relative-path:' + n, warnings }
165
+ }
166
+ userBytes += Buffer.byteLength(String(userFiles[n]), 'utf8')
167
+ }
154
168
  const pack = {
155
169
  format: MIGRATE_PACK_FORMAT_PRE,
156
170
  createdAt: typeof opt.now === 'number' ? opt.now : Date.now(),
@@ -158,7 +172,8 @@ export function buildPackPre(opt = {}) {
158
172
  runtime: { pluginVersion: String(opt.pluginVersion || '') },
159
173
  summaryRecord: opt.summaryRecord && typeof opt.summaryRecord === 'object' ? opt.summaryRecord : null,
160
174
  files,
161
- stats: { fileCount: names.length, bytes },
175
+ userFiles,
176
+ stats: { fileCount: names.length, bytes, userFileCount: Object.keys(userFiles).length, userBytes },
162
177
  checksum: { algo: MIGRATE_PACK_CHECKSUM_ALGO_PRE, value: packChecksumPre(files) },
163
178
  }
164
179
  return { ok: true, pack, warnings }
@@ -183,6 +198,13 @@ export function validatePackPre(pack) {
183
198
  if (k.includes('..') || k.startsWith('/') || k.startsWith('\\')) { errors.push('unsafe-relative-path:' + k); break }
184
199
  }
185
200
  }
201
+ // ★v3.1.3:userFiles 与 files 受**同等**安全检查(路径穿越);老包无该字段直接通过。
202
+ if (!errors.length && pack.userFiles && typeof pack.userFiles === 'object') {
203
+ for (const k of Object.keys(pack.userFiles)) {
204
+ if (k.includes('..') || k.startsWith('/') || k.startsWith('\\')) { errors.push('unsafe-user-relative-path:' + k); break }
205
+ if (typeof pack.userFiles[k] !== 'string') { errors.push('non-text-user-file:' + k); break }
206
+ }
207
+ }
186
208
  if (!errors.length) {
187
209
  const want = pack.checksum && pack.checksum.value
188
210
  const got = packChecksumPre(pack.files)
@@ -251,6 +273,8 @@ export function planImportPre(pack, opt = {}) {
251
273
  const additions = []
252
274
  const overwrites = []
253
275
  const replaced = {} // 实际会写入的文件(含改名后的键)
276
+ // 预留本机文件与整包原始文件名,避免另存覆盖旧备份或尚未遍历的包内文件。
277
+ const reserved = new Set([...Object.keys(existing), ...Object.keys(files)])
254
278
  for (const name of Object.keys(files)) {
255
279
  const next = String(files[name])
256
280
  const has = Object.prototype.hasOwnProperty.call(existing, name)
@@ -264,7 +288,10 @@ export function planImportPre(pack, opt = {}) {
264
288
  continue
265
289
  }
266
290
  // rename:A 机的版本改名为 <名字>.from-pack,B 机原文件保持不动
267
- const alt = renameForConflictPre(name)
291
+ let ordinal = 1
292
+ let alt = renameForConflictPre(name, ordinal)
293
+ while (reserved.has(alt)) alt = renameForConflictPre(name, ++ordinal)
294
+ reserved.add(alt)
268
295
  additions.push({ path: alt, bytes: Buffer.byteLength(next, 'utf8'), renamedFrom: name })
269
296
  replaced[alt] = next
270
297
  }
@@ -291,12 +318,13 @@ export function planImportPre(pack, opt = {}) {
291
318
  }
292
319
 
293
320
  /** 冲突改名:`handoff/PLAN.md` → `handoff/PLAN.from-pack.md`(保留扩展名)。 */
294
- export function renameForConflictPre(relPath) {
321
+ export function renameForConflictPre(relPath, ordinal = 1) {
295
322
  const s = String(relPath)
323
+ const suffix = '.from-pack' + (ordinal > 1 ? '-' + ordinal : '')
296
324
  const i = s.lastIndexOf('.')
297
325
  const j = s.lastIndexOf('/')
298
- if (i > j && i > 0) return s.slice(0, i) + '.from-pack' + s.slice(i)
299
- return s + '.from-pack'
326
+ if (i > j && i > 0) return s.slice(0, i) + suffix + s.slice(i)
327
+ return s + suffix
300
328
  }
301
329
 
302
330
  // ───────────────────────── 全局记录合并(纯函数)─────────────────────────
@@ -331,6 +359,67 @@ export function mergeSummaryRecordPre(summary, record, targetWs) {
331
359
  }
332
360
 
333
361
  /** 去重(保持顺序,字面量比对)。 */
362
+ /**
363
+ * ★v3.1.3:日历条目**合并**(纯函数,不覆盖)。
364
+ *
365
+ * 为什么不是整篇覆盖:CALENDAR.md 在 `~/.dsh/memory/CALENDAR.md` —— **用户级、跨工作区共享**,
366
+ * 且两台机器都可能往里写过。整篇覆盖会丢掉本机独有的条目
367
+ * (与 `workspaces-summary.json` 必须 merge 是同一条纪律)。
368
+ *
369
+ * 判据:以 `date | time | title` 三元组为身份。
370
+ * - 身份冲突 ⇒ **保留本机已有的**(与导入冲突默认 keep 一致;不覆盖用户已改过的状态)。
371
+ * - 本机没有的 ⇒ 追加进来。
372
+ * - 输出按 日期 → 时间 排序,保证确定性(同输入必得同输出,便于断言)。
373
+ *
374
+ * @param {string} localText 本机 CALENDAR.md 原文(空 ⇒ 视为空日历)
375
+ * @param {string} incomingText 包内 CALENDAR.md 原文
376
+ * @returns {{ok:boolean, text:string, added:number, kept:number}}
377
+ */
378
+ export function calendarMergePre(localText, incomingText) {
379
+ const parse = (t) => {
380
+ const out = []
381
+ let curDate = ''
382
+ for (const raw of String(t || '').split(/\r?\n/)) {
383
+ const line = raw.trim()
384
+ if (!line) continue
385
+ const dm = line.match(/^## (\d{4}-\d{2}-\d{2})/)
386
+ if (dm) { curDate = dm[1]; continue }
387
+ const m = line.match(/^- \[([ xX])\] (\d{1,2}:\d{2}) \| ([^|]+?) \| (.+?)(?: \| (.*))?$/)
388
+ if (m) out.push({ date: curDate, done: m[1] !== ' ', time: m[2], quadrant: m[3].trim(), title: m[4].trim(), note: (m[5] || '').trim() })
389
+ }
390
+ return out
391
+ }
392
+ const keyOf = (en) => en.date + '|' + en.time + '|' + en.title
393
+ const local = parse(localText).filter((en) => en.date)
394
+ const incoming = parse(incomingText).filter((en) => en.date)
395
+ const seen = new Set(local.map(keyOf))
396
+ const merged = local.slice()
397
+ let added = 0
398
+ for (const en of incoming) {
399
+ const k = keyOf(en)
400
+ if (seen.has(k)) continue
401
+ seen.add(k)
402
+ merged.push(en)
403
+ added++
404
+ }
405
+ const byDate = new Map()
406
+ for (const en of merged) {
407
+ if (!byDate.has(en.date)) byDate.set(en.date, [])
408
+ byDate.get(en.date).push(en)
409
+ }
410
+ const lines = ['# 日历与日程 (CALENDAR)', '', '> 由 dsh-auto-memory 维护;AI 可从对话中提取 deadline/约定写入,用户也可在 GUI 操作。', '']
411
+ for (const date of Array.from(byDate.keys()).sort()) {
412
+ lines.push('## ' + date)
413
+ for (const en of byDate.get(date).sort((a, b) => String(a.time || '').localeCompare(String(b.time || '')))) {
414
+ const mark = en.done ? 'x' : ' '
415
+ const note = en.note ? ' | ' + en.note : ''
416
+ lines.push('- [' + mark + '] ' + (en.time || '--:--') + ' | ' + (en.quadrant || '未分类') + ' | ' + en.title + note)
417
+ }
418
+ lines.push('')
419
+ }
420
+ return { ok: true, text: lines.join('\n'), added, kept: local.length }
421
+ }
422
+
334
423
  export function dedupeStringsPre(arr) {
335
424
  const seen = new Set()
336
425
  const out = []
@@ -201,7 +201,11 @@ export async function scanPluginSubagentSessions(opts = {}) {
201
201
  // 按 mtime 取最新存在的文件,兼容两代命名。
202
202
  let file = ''
203
203
  let st = null
204
- for (const name of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
204
+ // ★v3.1.3:不再固定候选名 —— 扫目录取任意 session.*\.jsonl[.zstd](兼容未来 vN 命名)。
205
+ let names = []
206
+ try { names = readdirSync(dir) } catch (_) { names = [] }
207
+ const hits = names.filter((n) => /^session[.-].*\.jsonl(\.zstd)?$/i.test(n) && !/backup|corrupt/i.test(n))
208
+ for (const name of (hits.length ? hits : ['session.v3.jsonl.zstd', 'session.jsonl.zstd'])) {
205
209
  const cand = path.join(dir, name)
206
210
  try {
207
211
  const s = await stat(cand)
package/lib/wb-sidecar.js CHANGED
@@ -668,7 +668,10 @@ export function buildKanbanPre(index, opts = {}) {
668
668
  source: String(c.source || ''),
669
669
  section: String(c.section || ''),
670
670
  preview: body.replace(/\s+/g, ' ').trim().slice(0, 400),
671
- full: body.slice(0, 6000), // P12: 卡上可展开看全文
671
+ // ★v3.1.2 性能:full 不再内联进看板载荷(实测每卡≤6000 字符、1178 卡合计 0.85 MB,
672
+ // 且 lanes+matrix 双份 ⇒ 1.7 MB 纯冗余;前端默认只渲染 preview,full 仅「展开全文」需要)。
673
+ // 改为只带长度标示,正文由 kanban-card 路由按 id 单取。
674
+ fullLen: body.length,
672
675
  tags: t.tags,
673
676
  sectionTags: t.sectionTags,
674
677
  cues: (Array.isArray(c.cues) ? c.cues : []).slice(0, 3),
@@ -698,7 +701,8 @@ export function buildKanbanPre(index, opts = {}) {
698
701
  source: String(e.source || ''),
699
702
  section: String(e.section || ''),
700
703
  preview: String(e.preview || '').slice(0, 400),
701
- full: String(e.preview || '').slice(0, 6000),
704
+ // ★v3.1.2 性能:同 sectionCards 分支 —— 不内联 full,仅带长度(见上)。
705
+ fullLen: String(e.preview || '').length,
702
706
  tags: t.tags,
703
707
  sectionTags: t.sectionTags,
704
708
  cues: (Array.isArray(e.cues) ? e.cues : []).slice(0, 3),
@@ -842,7 +846,8 @@ export function buildKanbanMatrixPre(cards, opts = {}) {
842
846
  docTitle: String(c.docTitle || ''),
843
847
  source: String(c.source || ''),
844
848
  preview: body.replace(/\s+/g, ' ').trim().slice(0, 220),
845
- full: body.slice(0, 6000),
849
+ // ★v3.1.2 性能:矩阵卡同样不内联 full(同 lanes 口径)——带长度标示,点开时按 id 单取。
850
+ fullLen: body.length,
846
851
  bullets: Number(c.bullets) || 0,
847
852
  criteria: String(c.criteria || 'unknown'),
848
853
  badge: criteriaBadgePre(c.criteria),
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.1",
4
+ "version": "3.1.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {