@a9i5k4/dsh-auto-memory 0.1.26 → 0.1.29

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 CHANGED
@@ -219,6 +219,19 @@ Defaults (JSON file `~/.dsh/dsh-auto-memory.json`):
219
219
 
220
220
  Adjustable in the GUI (Settings → Auto Memory), including the UI language (zh / en), the panel font size and the day boundary.
221
221
 
222
+ ### v0.1.27 hardening (memory hygiene gate — keeps external dirt out)
223
+
224
+ - **Write gate on the three write tools**: `memory_log_dev` / `memory_note_dev` / `memory_user_dev` now run content through `sanitizeForWrite` before writing — suspected mojibake (GBK round-trip artifacts), stutter degeneration (word/char loops, punctuation-separated included) and consecutive duplicate lines (≥3) are rejected with a reason; append entries cap at 8,000 chars, full rewrites (replace) at 200,000; appends are also deduped against the file's last ~60 lines (the `- HH:MM` log prefix does not affect matching).
225
+ - **Hygiene family fixes**: removed the `进行中` false positive from the mojibake pattern (added the real artifact `杩涜涓`), switched mojibake counting to global matching (it previously counted at most 1 hit, letting dirty long texts slip past the threshold), stutter detection now works across punctuation, and the duplicate-line check is reset by blank lines (no more false positives on short lines repeated across paragraphs).
226
+ - **External memory import is link-only**: `memory_external_dev` import now records only source file path pointers instead of copying content — keeping dirty content from other AI tools (WorkBuddy/CodeBuddy/Claude Code/Codex) out of local memory.
227
+ - **Injection-side scrub + voice discipline**: mojibake lines, code blocks and stutter lines are scrubbed before injection; the injected block adds a "how to read memory" note and voice discipline — write all memory entries as third-person objective statements, no first-person thinking narration.
228
+
229
+ ### v0.1.28 dirty-token checker (prion-scan integrated — stops the "model silently degrades" class)
230
+
231
+ - **Write gate now rejects raw JSON envelopes & base64 residue**: besides mojibake / stutter / duplicate lines, the write gate also rejects lines matching external-AI-profile JSON signatures (`memoryBlock` / `"uid":` / `updatedAt` / `"role":"... "`) and base64 residue lines — so an external tool profile can never be pasted into memory wholesale again.
232
+ - **Full mojibake table (34 features, verbatim from prion-scan.mjs)**: the GBK round-trip residue list is completed, catching the exact artifacts this class of "garbage token" shows up as.
233
+ - **New "Scan dirty tokens" in Settings → Debug Center**: one-click prion-style scan of user-level memory / project notes / today's log / reflections — returns a per-file report by line range (mojibake / raw JSON envelope / long lines >500 / base64 / duplicate lines, duplicate ## headings) with **locations only, no content**, so you can spot residual dirt without reading it.
234
+
222
235
  ### v0.1.9 hardening (budget / boundary / picker)
223
236
 
224
237
  - **Daily write budget with auto-compaction**: user memory ≤ 4000 chars/day, project notes ≤ 3000 chars/day (shared across sessions, reset at the day boundary). Going over the budget never rejects the write — the framework compacts the pre-today sections with an AI pass (merge duplicates, drop stale entries, keep hard facts) and then writes; if AI is unavailable, the oldest sections are archived to `archived-user.md` / `archive/notes-archived.md` (nothing is lost). Compaction is throttled to once per 10 minutes.
package/README.zh-CN.md CHANGED
@@ -217,6 +217,19 @@ pnpm up @a9i5k4/dsh-auto-memory # 或: npm install @a9i5k4/dsh-auto-memory@lat
217
217
 
218
218
  可在 GUI(设置 → 自动记忆)中调整,包括界面语言(zh / en)、界面字号与日界。
219
219
 
220
+ ### v0.1.27 基础加固(记忆卫生闸门 — 防外部脏内容污染)
221
+
222
+ - **三个写入工具接入写闸门**:`memory_log_dev` / `memory_note_dev` / `memory_user_dev` 写入前先过 `sanitizeForWrite`——疑似乱码(GBK 错误编码往返产物)、复读退化(单词/单字循环,标点分隔也识别)、连续重复行(≥3)一律拒绝并回执原因;追加(append)单条上限 8000 字、整篇重写(replace)上限 20 万字;追加时与文件尾部近 60 行做复读去重(日志行的 `- HH:MM` 前缀不影响判定)。
223
+ - **卫生函数族修复**:乱码特征表移除 `进行中` 假阳性(补真实乱码形态 `杩涜涓`)、乱码计数改为全局匹配(此前永远只计 1 处,长文本脏内容可绕过阈值)、复读检测支持标点分隔、重复行检测由空行打断(避免误伤隔段相同的短行)。
224
+ - **外部记忆纯链接导入**:`memory_external_dev` 的 import 只落源文件路径指针,不再整段写入内容——防止其他 AI 工具(WorkBuddy/CodeBuddy/Claude Code/Codex)记忆中的脏内容经导入混入本地记忆。
225
+ - **注入端清洗与语体纪律**:注入前自动剔除乱码行/代码块/复读行;注入块新增「记忆定位—读法」说明与语体纪律——写记忆一律第三人称客观陈述,禁止第一人称思考腔与过程复述。
226
+
227
+ ### v0.1.28 脏 token 检查器(prion-scan 整合 — 根治「模型无故降级」这一类问题)
228
+
229
+ - **写入闸门新增 raw JSON envelope 与 base64 残骸拒写**:在乱码/复读/重复行之外,闸门还拒绝命中外部 AI 工具画像 JSON 签名(`memoryBlock` / `"uid":` / `updatedAt` / `"role":"... "`)与 base64 残骸行——外部画像再也无法整段混入本地记忆。
230
+ - **乱码特征表补全至 34 项(与 prion-scan.mjs 逐字一致)**:覆盖 GBK 错误编码往返的全部真实残骸形态,能精确抓到这一类「垃圾 token」。
231
+ - **新增「扫描脏 token」(设置页 → 调试中心)**:一键 prion 式扫描用户级记忆 / 项目笔记 / 今日日志 / 反思——按文件、按行区间返回 mojibake / raw JSON envelope / 超长行 >500 / base64 / 重复内容行、重复 ## 标题报告;**只给位置不含正文**,发现残骸不用读它也能定位。
232
+
220
233
  ### v0.1.9 加固(预算 / 日界 / 目录选择器)
221
234
 
222
235
  - **每日写入预算 + 超限自动压缩**:用户级记忆 ≤4000 字/天、项目笔记 ≤3000 字/天(所有会话共享一天额度,日界重置)。超限不拒绝写入——框架先把「今天之前」的旧内容交给 AI 压缩(合并重复、删除过期、保留硬信息)腾出空间再写;AI 不可用时把最早段落归档到 `archived-user.md` / `archive/notes-archived.md`,信息不丢。压缩 10 分钟节流。
package/lib/client.js CHANGED
@@ -176,6 +176,7 @@ window.__ModuleLoader__.load({
176
176
  fExtBudget: '外部记忆注入预算(字符)', fExtBudgetHint: '外部记忆来源在上下文中的注入预算。默认 1400(路径模式下影响有限)。',
177
177
  fConsolidateMin: '自动沉淀内容门槛(字符)', fConsolidateMinHint: '本轮 user+assistant 总字符低于此值视为寒暄跳过。默认 240。',
178
178
  fAway: '暂离阈值(分钟)', fAwayHint: '距上次活动超过该值视为暂离,回归时自动弹出记忆窗口。默认 60。',
179
+ fAutoPopup: '自动弹出记忆窗口', fAutoPopupHint: '暂离/回归时自动弹出记忆窗口(corner)并欢迎;关闭后只能手动打开。默认开。',
179
180
  fAutoConsolidate: '自动沉淀(每轮对话结束AI评估)', fAutoConsolidateHint: '关闭后每轮对话结束不再自动调用 AI 评估与写入今日日志。',
180
181
  fConsolidate: '自动沉淀间隔(分钟)', fConsolidateHint: '两轮自动沉淀之间的最短间隔。默认 30;非工作时间(22:00-08:00)自动翻倍,避免短时间耗尽每日额度。',
181
182
  fConsolidateMax: '自动沉淀每日额度(次)', fConsolidateMaxHint: '每天最多触发自动沉淀的次数,到点后当天不再调用。默认 8。',
@@ -276,6 +277,7 @@ window.__ModuleLoader__.load({
276
277
  fExtBudget: 'External memory injection budget (chars)', fExtBudgetHint: 'Budget for external memory sources in context. Default 1400 (limited effect with path mode).',
277
278
  fConsolidateMin: 'Auto-consolidation content threshold (chars)', fConsolidateMinHint: 'Turns with fewer combined user+assistant chars are treated as chit-chat and skipped. Default 240.',
278
279
  fAway: 'Away threshold (minutes)', fAwayHint: 'Marked away when inactive longer than this; the memory panel auto-opens on return. Default 60.',
280
+ fAutoPopup: 'Auto-open memory panel', fAutoPopupHint: 'Auto-open the memory panel (corner) with a welcome when returning from away; disabled = open manually only. Default on.',
279
281
  fAutoConsolidate: 'Auto-consolidation (AI review each turn end)', fAutoConsolidateHint: 'When off, no automatic AI review or daily-log writes at turn end.',
280
282
  fConsolidate: 'Auto-consolidate interval (minutes)', fConsolidateHint: 'Minimum gap between auto-consolidations. Default 30; doubled automatically outside work hours (22:00-08:00).',
281
283
  fConsolidateMax: 'Daily auto-consolidate quota', fConsolidateMaxHint: 'Max auto-consolidation runs per day; no more runs after the quota is reached. Default 8.',
@@ -337,6 +339,7 @@ window.__ModuleLoader__.load({
337
339
  // 暂离检测:优先用 host 定时检测的 away 状态(阈值可配 awayMinutes),回退本地 lastActive 旧逻辑
338
340
  var hostAway = false
339
341
  var hostAwayReady = false
342
+ var autoPopupEnabled = true
340
343
  function isAway() {
341
344
  if (hostAwayReady) return hostAway
342
345
  var lastSeen = 0
@@ -346,6 +349,36 @@ window.__ModuleLoader__.load({
346
349
 
347
350
  // ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
348
351
  var CHANGELOG = {
352
+ '0.1.29': { zh: [
353
+ '修复:工作区总览一直显示「未发现带记忆的工作区」——DSH 新版会话持久化改为 session.jsonl.zstd 压缩帧,旧逻辑只认 .jsonl 扫描不到任何会话;现已用 zstd 解压读取首行提取 cwd,并让空的 summary 缓存短时失效,避免空结果被永久固化。',
354
+ '新增:设置页「自动化」新增「自动弹出记忆窗口」开关——关闭后暂离/回归不再自动弹出 corner 问候栏,只能手动打开。',
355
+ '提示:这是近期稳定版(0.1.x 维护线)的发布。实验版(下一大版本)预计未来几周内发布,在此之前稳定版仅做维护性更新。',
356
+ ], en: [
357
+ 'Fix: Workspace overview kept showing "No memory workspaces found" — DSH now stores sessions as session.jsonl.zstd compressed frames that were not recognized; the overview now decompresses them to read the first line and extract each workspace, and empty summary caches expire instead of being fixed forever.',
358
+ 'New: "Auto-open memory panel" toggle in Settings → Automation - turn it off to stop auto-opening the corner greeting on away/return; the panel stays manual-only.',
359
+ 'Note: this is a recent stable-line (0.1.x maintenance) release. The experimental next major version is expected within a few weeks; until then the stable line only receives maintenance updates.',
360
+ ] },
361
+ '0.1.28': { zh: [
362
+ '脏 token 检查器(prion-scan 四类启发式整合):mojibake GBK 残骸特征表补全至 34 项(与 prion-scan.mjs 逐字一致),写入闸门新增 raw JSON envelope(memoryBlock/"uid"/updatedAt/"role")与 base64 残骸行拒写——外部 AI 工具画像无法再整段混入。',
363
+ '新增「扫描脏 token」:设置页调试中心一键扫描用户级/项目笔记/每日日志/反思,按行区间返回 mojibake / raw JSON / 超长行 / base64 / 重复块报告(只给位置,不含正文)。',
364
+ ], en: [
365
+ 'Dirty-token checker (prion-scan heuristics integrated): the GBK mojibake residue table is completed to 34 features (verbatim identical to prion-scan.mjs); the write gate now also rejects raw JSON envelopes (memoryBlock/"uid"/updatedAt/"role") and base64 residue lines — external AI tool profiles can no longer be pasted in wholesale.',
366
+ 'New "Scan dirty tokens": one-click scan of user/notes/log/reflection files in Settings → Debug Center, reporting mojibake / raw JSON / long lines / base64 / duplicate blocks by line range (location only, no content).',
367
+ ] },
368
+ '0.1.27': { zh: [
369
+ '记忆卫生闸门(写端):memory_log/note/user 三个写入工具先过 sanitizeForWrite——疑似乱码(GBK 错误编码往返)/复读退化(词/字符循环,含跨标点)/连续重复行(≥3)拒绝并中文回执;append 单条上限 8000 字、replace 整篇 20 万字;追加前 tailHas 与文件尾部近 60 行做包含式复读去重。',
370
+ '全写入口审计:唯一漏网=API.note(概览页手动追加)已补同套闸门,回归 42 用例全过。',
371
+ '外部记忆接入只存路径指针,不再复制内容;注入端清洗乱码行/代码块/复读行;注入块加"记忆定位/读法"与语体纪律(条目一律第三人称客观陈述)。',
372
+ ], en: [
373
+ 'Memory hygiene write gate: the three write tools (log/note/user) run through sanitizeForWrite — suspected mojibake (GBK round-trip), stutter degeneration (including punctuation-separated) and consecutive duplicate lines (≥3) are rejected with a reason; appends cap at 8,000 chars, rewrites at 200,000; appends are deduped against the last ~60 lines (tailHas).',
374
+ 'Full write-entry audit: the only leak (API.note manual append) now goes through the same gate; 42 regression cases all pass.',
375
+ 'External memory import records only path pointers; injection scrubs mojibake/code-block/stutter lines; the injected block adds "how to read memory" and the voice discipline (entries must be third-person objective statements).',
376
+ ] },
377
+ '0.1.20': { zh: [
378
+ '修复:正式发布流程 cordis.patch.yml(loader 入口 id + 包名)转换事故——发布包与开发版 identity 完全隔离,不再互相撞车。',
379
+ ], en: [
380
+ 'Fix: cordis.patch.yml conversion mishap in the release pipeline (loader entry id + package name) — published package identity is now fully isolated from the dev build.',
381
+ ] },
349
382
  '0.1.26': { zh: [
350
383
  '文档:README 定位改为「技术先行 + 人性化」——intro 强调缓存友好三层记忆引擎,新增「底层工程」章节(前缀缓存友好/注入精简/AI限频/凭据过滤/跨工具),与「主动懂你的伙伴」章节互补。',
351
384
  ], en: [
@@ -504,6 +537,7 @@ window.__ModuleLoader__.load({
504
537
  smartRecall: '/api/dsh-auto-memory/smart-recall',
505
538
  workspaces: '/api/dsh-auto-memory/workspaces',
506
539
  debug: '/api/dsh-auto-memory/debug',
540
+ scanDirty: '/api/dsh-auto-memory/scan-dirty',
507
541
  browseDir: '/api/dsh-auto-memory/browse-dir',
508
542
  pickDir: '/api/dsh-auto-memory/pick-dir',
509
543
  updateCheck: '/api/dsh-auto-memory/update-check',
@@ -1692,6 +1726,25 @@ window.__ModuleLoader__.load({
1692
1726
  var busyPair = useState(false)
1693
1727
  var busy = busyPair[0]
1694
1728
  var setBusy = busyPair[1]
1729
+ var scanPair = useState(null)
1730
+ var scan = scanPair[0]
1731
+ var setScan = scanPair[1]
1732
+ var scanBusyPair = useState(false)
1733
+ var scanBusy = scanBusyPair[0]
1734
+ var setScanBusy = scanBusyPair[1]
1735
+ var scanFiles = scan && scan.files ? scan.files : null
1736
+ var filesTotal = scan && scan.files ? scan.files.length : 0
1737
+ var totalFindings = scan ? (scan.totalFindings || 0) : 0
1738
+ var scanError = scan && scan.error ? scan.error : ''
1739
+ function runScan() {
1740
+ if (scanBusy) return
1741
+ setScanBusy(true)
1742
+ setScan(null)
1743
+ fetch(API.scanDirty).then(function (r) { return r.json() }).then(function (d) {
1744
+ setScan(d || {})
1745
+ setScanBusy(false)
1746
+ }).catch(function (e) { setScan({ error: String(e && e.message || e) }); setScanBusy(false) })
1747
+ }
1695
1748
  function refresh() {
1696
1749
  if (busy) return
1697
1750
  setBusy(true)
@@ -1737,7 +1790,16 @@ window.__ModuleLoader__.load({
1737
1790
  kv(t('kvDup'), data.duplicateHeadings + t('kvDupCount'), data.duplicateHeadings > 0),
1738
1791
  kv(t('kvMem'), t('kvMemUser') + ' ' + (data.memoryFiles.user.exists ? fmtSize(data.memoryFiles.user.size) : t('kvMemMissing')) + ' · ' + t('kvMemNotes') + ' ' + (data.memoryFiles.notes.exists ? fmtSize(data.memoryFiles.notes.size) : t('kvMemMissing')) + ' · ' + t('kvMemLog') + ' ' + (data.memoryFiles.log.exists ? fmtSize(data.memoryFiles.log.size) : t('kvMemMissing')), !data.memoryFiles.log.exists),
1739
1792
  kv(t('kvWs'), currentWs() || t('kvWsUnknown'), false),
1740
- kv(t('kvApi'), probes ? Object.keys(probes).map(function (k) { return k + '=' + (probes[k].status !== undefined ? probes[k].status : probes[k].error) }).join(' ') : '…', apiBad))
1793
+ kv(t('kvApi'), probes ? Object.keys(probes).map(function (k) { return k + '=' + (probes[k].status !== undefined ? probes[k].status : probes[k].error) }).join(' ') : '…', apiBad),
1794
+ h('div', { 'data-dam-row': '', style: { marginTop: '6px', alignItems: 'flex-start', flexDirection: 'column' } },
1795
+ h('div', { 'data-dam-row': '' },
1796
+ h('button', { 'data-dam-btn': '', title: 'prion-scan 式四类启发式,只报位置不含正文', onClick: runScan, disabled: scanBusy }, scanBusy ? (locale === 'zh' ? '扫描中…' : 'Scanning…') : (locale === 'zh' ? '扫描脏 token' : 'Scan dirty tokens')),
1797
+ h('span', { 'data-dam-hint': '', style: { marginLeft: '8px' } }, scanError ? scanError : (scan === null ? (locale === 'zh' ? '检查用户级/笔记/日志/反思的 mojibake / raw JSON / 超长行 / base64 / 重复块' : 'Check user/notes/log/reflections: mojibake, raw JSON, long lines, base64, duplicates') : (scanFiles && scanFiles.length ? (locale === 'zh' ? '共 ' + filesTotal + ' 个文件,命中 ' + totalFindings + ' 处' : filesTotal + ' files, ' + totalFindings + ' findings') : (locale === 'zh' ? '✓ 未发现脏 token' : '✓ no dirty tokens'))))),
1798
+ scanFiles && scanFiles.length ? scanFiles.map(function (sf) {
1799
+ return h('div', { 'data-dam-row': '', style: { alignItems: 'flex-start', flexDirection: 'column', gap: '2px', marginTop: '4px' } },
1800
+ h('span', { style: { fontWeight: 600, fontSize: 'calc(11px * var(--dam-scale))' } }, sf.name + ' [' + sf.sizeKB + 'KB, ' + sf.lines + ' 行]'),
1801
+ sf.findings.map(function (fd) { return h('div', { 'data-dam-hint': '', style: { whiteSpace: 'pre-wrap' } }, '行 ' + fd.range + ' | ' + fd.type) }))
1802
+ }) : null))
1741
1803
  }
1742
1804
 
1743
1805
  // ───────────────────────── 更新弹窗 / 首次指导(毛玻璃) ─────────────────────────
@@ -2008,6 +2070,7 @@ window.__ModuleLoader__.load({
2008
2070
  field(t('fAutoConsolidate'), h('input', { type: 'checkbox', checked: cfg.autoConsolidate !== false, onChange: function (e) { set('autoConsolidate', e.target.checked) } }), t('fAutoConsolidateHint')),
2009
2071
  field(t('fConsolidate'), h('input', { 'data-dam-input': '', type: 'number', min: 5, value: cfg.autoConsolidateCooldownMinutes === undefined ? 30 : cfg.autoConsolidateCooldownMinutes, onChange: function (e) { set('autoConsolidateCooldownMinutes', Number(e.target.value) || 30) } }), t('fConsolidateHint')),
2010
2072
  field(t('fConsolidateMax'), h('input', { 'data-dam-input': '', type: 'number', min: 1, max: 50, value: cfg.autoConsolidateDailyMax === undefined ? 8 : cfg.autoConsolidateDailyMax, onChange: function (e) { set('autoConsolidateDailyMax', Number(e.target.value) || 8) } }), t('fConsolidateMaxHint')),
2073
+ field(t('fAutoPopup'), h('input', { type: 'checkbox', checked: cfg.autoPopupEnabled !== false, onChange: function (e) { set('autoPopupEnabled', e.target.checked) } }), t('fAutoPopupHint')),
2011
2074
  field(t('fAway'), h('input', { 'data-dam-input': '', type: 'number', min: 1, value: cfg.awayMinutes || 60, onChange: function (e) { set('awayMinutes', Number(e.target.value) || 60) } }), t('fAwayHint')),
2012
2075
  field(t('fAutoSum'), h('input', { 'data-dam-input': '', value: (cfg.autoSummaryTimes || []).join(','), onChange: function (e) { set('autoSummaryTimes', String(e.target.value || '').split(',').map(function (s) { return s.trim() }).filter(Boolean)) } }), t('fAutoSumHint')),
2013
2076
  field(t('fDayBoundary'), h('input', { 'data-dam-input': '', type: 'number', min: 0, max: 1439, value: (cfg.dayBoundaryMinutes === undefined ? 450 : cfg.dayBoundaryMinutes), onChange: function (e) { set('dayBoundaryMinutes', parseInt(e.target.value || '0', 10)) } }), t('fDayBoundaryHint')),
@@ -2059,6 +2122,7 @@ window.__ModuleLoader__.load({
2059
2122
  applyLocalePref('system')
2060
2123
  apiGet(API.config).then(function (d) {
2061
2124
  if (d && d.config && d.config.locale) applyLocalePref(d.config.locale)
2125
+ if (d && d.config && typeof d.config.autoPopupEnabled === 'boolean') autoPopupEnabled = d.config.autoPopupEnabled
2062
2126
  }).catch(function () {})
2063
2127
  // DSH 系统语言变化:跟随更新 + 重渲染 + 重注册入口 label
2064
2128
  ctx.on('locale/change', function (snap) {
@@ -2077,6 +2141,7 @@ window.__ModuleLoader__.load({
2077
2141
  var prevAwayState = null
2078
2142
  function autoOpenOnReturn() {
2079
2143
  try {
2144
+ if (autoPopupEnabled === false) { prevAwayState = hostAwayReady ? hostAway : prevAwayState; return }
2080
2145
  if (hostAwayReady && prevAwayState === true && hostAway === false) {
2081
2146
  // 暂离回归:打开窗口 + 欢迎弹窗(若有待展示总结一并显示)
2082
2147
  if (!controller.isOpen()) controller.open()
@@ -2143,6 +2208,7 @@ window.__ModuleLoader__.load({
2143
2208
  hostAway = d.away
2144
2209
  hostAwayReady = true
2145
2210
  }
2211
+ if (d && typeof d.autoPopupEnabled === 'boolean') autoPopupEnabled = d.autoPopupEnabled
2146
2212
  if (d && d.pendingSummary && d.pendingSummary.summary) {
2147
2213
  var seenKey = 'dsh-auto-memory.seenSummary.' + (d.pendingSummary.date || '') + '.' + (d.pendingSummary.time || '')
2148
2214
  try {
package/lib/index.js CHANGED
@@ -25,6 +25,10 @@ const execP = promisify(cpExec)
25
25
  import { homedir } from 'node:os'
26
26
  import path from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
+ import * as nodeZlib from 'node:zlib'
29
+
30
+ /** zstd 解压(DSH 新版会话持久化 session.jsonl.zstd);Node <22 无此能力时为空,自动回退明文读。 */
31
+ const zstdDec = typeof nodeZlib.zstdDecompressSync === 'function' ? nodeZlib.zstdDecompressSync : null
28
32
 
29
33
  /** Stable cordis plugin name. */
30
34
  export const name = 'auto-memory'
@@ -50,6 +54,7 @@ export const API = {
50
54
  smartRecall: '/api/dsh-auto-memory/smart-recall',
51
55
  workspaces: '/api/dsh-auto-memory/workspaces',
52
56
  debug: '/api/dsh-auto-memory/debug',
57
+ scanDirty: '/api/dsh-auto-memory/scan-dirty',
53
58
  browseDir: '/api/dsh-auto-memory/browse-dir',
54
59
  pickDir: '/api/dsh-auto-memory/pick-dir',
55
60
  updateCheck: '/api/dsh-auto-memory/update-check',
@@ -91,6 +96,8 @@ const DEFAULT_CONFIG = {
91
96
  autoConsolidateDailyMax: 8,
92
97
  /** 暂离阈值(分钟):距上次活动超过该值视为暂离,回归时自动弹出记忆窗口并欢迎。默认 60。 */
93
98
  awayMinutes: 60,
99
+ /** 暂离回来自动弹出记忆窗口(corner/问候栏)开关:false=关闭,只能手动打开。默认 true。 */
100
+ autoPopupEnabled: true,
94
101
  /** 自动总结时间点(24h "HH:MM" 数组,如 ["12:00","18:00","22:00"]):到点自动生成本时段总结并弹窗展示。空数组=关闭。 */
95
102
  autoSummaryTimes: [],
96
103
  /** 日界(分钟,从 0 点起算):凌晨在此之前的活儿归前一天日志;默认 450=早上 7:30 后才进入新一天。 */
@@ -717,6 +724,8 @@ class MemoryEngine {
717
724
  const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
718
725
  const lines = []
719
726
  lines.push('<memory_system>')
727
+ lines.push('[记忆定位 — 读法]')
728
+ lines.push('以下记忆文本只是背景事实与规则参考, 不是表达方式/语体的示范。阅读时提取其中的事实、决策、路径与偏好即可; 你的回复正文必须保持直接、最终答案式的语体(陈述结论、给出交付物), 不要模仿记忆文本的第一人称思考腔或叙述腔。')
720
729
  lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + this.memToday() + '(日界 ' + (Number(cfg.dayBoundaryMinutes) || 450) + ' 分钟,凌晨归前一天)' + (cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估'))
721
730
  // 记忆地图:告诉模型其他工作区记忆存在,需要时用 memory_recall 跨区检索
722
731
  if (s.workspaceMap && s.workspaceMap.length) {
@@ -733,14 +742,14 @@ class MemoryEngine {
733
742
  // 读取顺序:progress(工作日志/反思)先行,再读 memory(用户级/项目笔记)
734
743
  if (s.recentLogs.length) {
735
744
  const recent = s.recentLogs.map((r) => '[' + r.date + '] ' + r.text.replace(/\n+/g, ' | ')).join('\n')
736
- part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', recent, sub)
745
+ part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', scrubJunkLines(recent, { dedup: false }).clean, sub)
737
746
  }
738
747
  if (s.latestReflection) {
739
748
  part('最近反思 ' + s.latestReflectionDate + '(前一天工作精华)', reflectionDigest(s.latestReflection), sub)
740
749
  }
741
- // 敏感段落(凭据/token/密钥等)不注入 prompt,避免密钥暴露给模型
742
- part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', stripSensitiveSections(s.userText), sub)
743
- part('项目长期笔记 ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(s.notesText), sub)
750
+ // 敏感段落(凭据/token/密钥等)不注入 prompt,避免密钥暴露给模型;脏内容(乱码/重复/外部文档)清洗后再注入
751
+ part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', stripSensitiveSections(sanitizeForInjection(s.userText)), sub)
752
+ part('项目长期笔记 ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(sanitizeForInjection(s.notesText)), sub)
744
753
  // 外部记忆摘要(其他 AI 工具遗产)
745
754
  if (this.external.cache && this.external.cache.length) {
746
755
  const extBudget = Math.max(Number(cfg.externalInjectionChars) || 1400, 200)
@@ -794,6 +803,7 @@ class MemoryEngine {
794
803
  lines.push('- 自动沉淀:每轮对话结束,插件会自动评估本轮内容,把有记录价值的写进今日日志([自动沉淀] 标记),有长期价值的升格到项目笔记/用户级记忆,寒暄轮自动跳过。你仍须按上方纪律转述自己的显式记忆操作;也可调用 memory_consolidate 让 AI 读日志发散提炼长期要点。')
795
804
  lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
796
805
  lines.push('- 注入上下文只含精简记忆(最近1天日志/反思精华/路径索引);**需要某天完整日志、反思全文或记忆文件全文时,调用 memory_read 按需读取(kind=log/reflection/user/notes/calendar),不要要求用户粘贴**。')
806
+ lines.push('- **语体纪律**:写任何记忆条目(日志/笔记/用户级/反思)都用**客观陈述**——第三人称中性句式, 只留可复用的事实/决策/规则/路径; **禁止**第一人称思维叙述("我考虑/我排查/我想"), 禁止思考腔, 禁止过程复述(过程只落结论)。')
797
807
  return lines.join('\n')
798
808
  }
799
809
 
@@ -1042,8 +1052,18 @@ class MemoryEngine {
1042
1052
  }
1043
1053
 
1044
1054
  // ---------- 工作区总览(跨工作区全局总结) ----------
1045
- /** 读 jsonl 首行(session header)。 */
1055
+ /** 读 jsonl 首行(session header);.jsonl 明文流式读首行,.jsonl.zstd 为 zstd 压缩帧(整块解压后取首行,与 dsh 核心 dsh-session-persistence-jsonl 一致)。 */
1046
1056
  readFirstLine(p) {
1057
+ if (p.endsWith('.zstd')) {
1058
+ return readFile(p).then((buf) => {
1059
+ try {
1060
+ const dec = zstdDec ? zstdDec(buf) : null
1061
+ const text = (dec || buf).toString('utf8')
1062
+ const i = text.indexOf('\n')
1063
+ return i >= 0 ? text.slice(0, i) : text
1064
+ } catch (e) { return '' }
1065
+ }).catch(() => '')
1066
+ }
1047
1067
  return new Promise((resolve) => {
1048
1068
  const rs = createReadStream(p, { encoding: 'utf8' })
1049
1069
  let buf = ''
@@ -1068,7 +1088,7 @@ class MemoryEngine {
1068
1088
  for (const en of entries) {
1069
1089
  if (out.size >= 30) return
1070
1090
  if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
1071
- else if (en.isFile() && en.name.endsWith('.jsonl')) {
1091
+ else if (en.isFile() && (en.name.endsWith('.jsonl.zstd') || en.name.endsWith('.jsonl'))) {
1072
1092
  try {
1073
1093
  const first = await this.readFirstLine(path.join(dir, en.name))
1074
1094
  if (first) {
@@ -1108,7 +1128,11 @@ class MemoryEngine {
1108
1128
  const raw = await this.readTextSafe(cacheFile)
1109
1129
  if (raw) {
1110
1130
  const j = JSON.parse(raw)
1111
- if (j && Array.isArray(j.workspaces) && j.graph && Array.isArray(j.graph.topics) && Array.isArray(j.graph.links)) return { workspaces: j.workspaces, graph: j.graph, cached: true, generatedAt: j.generatedAt }
1131
+ // 空结果只短时信任(≤30 分钟),防止旧版 bug 产出的"空缓存"被永久复用导致永远显示"未发现带记忆的工作区"
1132
+ const ageMs = Date.now() - (Number(j && j.generatedAt) || 0)
1133
+ const list = j && Array.isArray(j.workspaces) ? j.workspaces : null
1134
+ const valid = !!list && (list.length > 0 || (ageMs >= 0 && ageMs <= 30 * 60 * 1000))
1135
+ if (valid && j.graph && Array.isArray(j.graph.topics) && Array.isArray(j.graph.links)) return { workspaces: list, graph: j.graph, cached: true, generatedAt: j.generatedAt }
1112
1136
  }
1113
1137
  } catch (e) {}
1114
1138
  }
@@ -1121,6 +1145,11 @@ class MemoryEngine {
1121
1145
  const fallbackItems = mem.logs.flatMap((l) => l.text.split('\n').filter((x) => x.trim().startsWith('- ')).map((x) => x.trim().replace(/^- /, '').slice(0, 120))).slice(-5)
1122
1146
  return { path: cwd, name, input: { name, logs: logText.slice(0, 5200) || '(无)', notes: mem.notes || '(无)' }, fallbackItems, logCount: fallbackItems.length, dateRange: mem.logs.length ? (mem.logs[mem.logs.length - 1].date + ' ~ ' + mem.logs[0].date) : '' }
1123
1147
  }))).filter(Boolean)
1148
+ if (!records.length) {
1149
+ const empty = { workspaces: [], graph: { topics: [], links: [] }, generatedAt: Date.now() }
1150
+ try { await mkdir(path.dirname(cacheFile), { recursive: true }); await writeFile(cacheFile, JSON.stringify(empty, null, 2), 'utf8') } catch (e) {}
1151
+ return { workspaces: [], graph: empty.graph, cached: false, generatedAt: empty.generatedAt }
1152
+ }
1124
1153
  const fallback = records.map((r) => ({ path: r.path, name: r.name, summary: '', items: r.fallbackItems, graphTopics: r.fallbackItems.slice(0, 4).map((label) => ({ label, detail: '' })), logCount: r.logCount, dateRange: r.dateRange }))
1125
1154
  let workspaces = fallback
1126
1155
  let graph = { topics: [], links: [] }
@@ -1632,6 +1661,7 @@ class MemoryEngine {
1632
1661
  '- 寒暄、闲聊、单纯问候、纯测试、无实质内容 → 只输出 (无)',
1633
1662
  '- 有实质内容(完成工作、修复问题、做出决策、约定规则、用户偏好、讨论结论)时,提炼 1-3 条要点',
1634
1663
  '- 每条一句话,具体明确,不要泛泛而谈,不要复述对话过程',
1664
+ '- 语体: 每条用第三人称客观陈述, 只写可复用的事实/决策/规则/路径; 禁止第一人称思维叙述("我考虑/我排查")与思考腔, 过程只落结论',
1635
1665
  '- 项目专属的进度 → [LOG];有跨会话长期价值的项目决策/架构/约定 → [NOTE];跨项目通用的用户硬性规则/偏好 → [USER]',
1636
1666
  '- [NOTE]/[USER] 只在真正长期有价值时用,宁缺毋滥',
1637
1667
  '输出格式(严格遵守):',
@@ -1751,6 +1781,7 @@ class MemoryEngine {
1751
1781
  '规则:',
1752
1782
  '- 只提炼有跨会话长期价值的:技术决策、架构约定、关键路径、用户偏好/习惯、踩过的坑及其规则',
1753
1783
  '- 不记临时信息(某次具体修 bug 的过程省略,但其背后的规则/约定值得记)',
1784
+ '- 语体: 条目用第三人称客观陈述, 只落可复用结论; 禁止第一人称思维叙述与思考腔',
1754
1785
  '- 项目专属 → [PROJECT];跨项目通用的用户硬性规则/偏好 → [USER]',
1755
1786
  '- 每条一句话,简短明确;已经在下方"已有记忆"里出现的不要重复',
1756
1787
  '输出格式(严格遵守):',
@@ -1868,6 +1899,7 @@ class MemoryEngine {
1868
1899
  '- 丢弃过程流水(某次具体修 bug 的过程、临时路径、搜索结果、报错细节)',
1869
1900
  '- 按主题组织,可用"### 主题"小标题,每条一句话,简短明确',
1870
1901
  '- 不要复述日志原文,只要蒸馏后的要点',
1902
+ '- 语体: 条目用第三人称客观陈述, 只落可复用结论; 禁止第一人称思维叙述与思考腔',
1871
1903
  '输出格式:markdown(### 主题 + - 要点)。若没有任何值得保留的内容,只输出一行:(无)',
1872
1904
  '',
1873
1905
  '待蒸馏日志:',
@@ -1949,6 +1981,7 @@ class MemoryEngine {
1949
1981
  // 时间检测:暂离状态 / 待展示的自动总结 / 相关配置
1950
1982
  away: !!this.state.away,
1951
1983
  awayMinutes: Math.max(Number(this.config.awayMinutes) || 60, 1),
1984
+ autoPopupEnabled: this.config.autoPopupEnabled !== false,
1952
1985
  autoSummaryTimes: Array.isArray(this.config.autoSummaryTimes) ? this.config.autoSummaryTimes : [],
1953
1986
  pendingSummary: this.state.pendingSummary || null,
1954
1987
  autoStats: this.autoStats,
@@ -2242,24 +2275,31 @@ class ExternalMemory {
2242
2275
  return out
2243
2276
  }
2244
2277
 
2245
- /** 把某个源的内容接入本地记忆(项目笔记或用户级记忆) */
2278
+ /** 把某个源"接入"本地记忆(纯链接模式 2026-08-18): 只在记忆文档里落一条指向源文件的路径指针, 不整段写入内容。
2279
+ * 内容留在原文件, 模型需要时用 memory_read / 直接读取路径按需获取。
2280
+ * 防止外部工具的脏内容(乱码/整篇文档/复读块)被导入进本地记忆——语义朊病毒的传播路径之一。
2281
+ * (本机模型为远程调用, 不需要 AI 蒸馏要点; 用户级层已有画像/偏好, 无需为导入再跑 subagent。) */
2246
2282
  async importInto(sourceId, target, engine, agent) {
2247
2283
  const srcs = await this.discover(false)
2248
2284
  const src = srcs.find((s) => s.id === sourceId)
2249
2285
  if (!src) return '外部源不存在: ' + sourceId
2250
2286
  if (src.kind === 'sessions') return '会话类源不支持整体接入,请用 memory_recall 按需检索(' + src.files.length + ' 个会话文件)。'
2251
- const stamp = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + engine.memToday() // ExternalMemory 类,this 无 memToday,必须用 engine
2287
+ const fileRefs = (src.files && src.files.length)
2288
+ ? src.files.map((f) => ' - ' + f.path).join('\n')
2289
+ : ' - (未知路径)'
2290
+ const linkBlock = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + engine.memToday() + ' [链接模式]\n'
2291
+ + fileRefs + '\n- 用法: 需要时用 memory_read 或直接读取上述路径按需获取, 不整段写入。'
2252
2292
  if (target === 'user') {
2253
2293
  const p = await engine.resolvePaths(agent)
2254
- const body = await engine.appendText(p.userFile, '\n' + stamp + '\n' + src.content.slice(0, 120000))
2294
+ const body = await engine.appendText(p.userFile, '\n' + linkBlock)
2255
2295
  engine.state.userText = body
2256
- return '已接入用户级记忆(' + src.name + ', ' + src.content.length + ' 字符)'
2296
+ return '已接入用户级记忆(' + src.name + ', 链接模式 ' + src.files.length + ' 个源路径, 未写入内容)'
2257
2297
  }
2258
2298
  const p = await engine.resolvePaths(agent)
2259
- const body = await engine.appendText(p.notesPath, '\n' + stamp + '\n' + src.content.slice(0, 120000))
2299
+ const body = await engine.appendText(p.notesPath, '\n' + linkBlock)
2260
2300
  engine.state.notesText = body
2261
2301
  engine.state.loadedAt = Date.now()
2262
- return '已接入项目笔记(' + src.name + ', ' + src.content.length + ' 字符)'
2302
+ return '已接入项目笔记(' + src.name + ', 链接模式 ' + src.files.length + ' 个源路径, 未写入内容)'
2263
2303
  }
2264
2304
 
2265
2305
  /** 检查某源是否已接入用户级/项目笔记。 */
@@ -2364,6 +2404,149 @@ function stripSensitiveSections(text) {
2364
2404
  return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
2365
2405
  }
2366
2406
 
2407
+
2408
+ /** 记忆卫生守卫族(0.1.27 基础加固, 2026-08-18): 在读取/写入/导入/注入四端拦截乱码、重复与外部整篇文档, 防止外部 AI 工具记忆的脏内容混入。 */
2409
+ /** 常见 GBK 残骸特征(UTF-8 被按 GBK 解码再存回)——命中即判定疑似乱码(0.1.28 补全为与 prion-scan.mjs 一致的 34 项)。 */
2410
+ var MOJIBAKE_RE = /涓婁紶|涓嬭浇|鏉ユ簮|鈥|鈶|鈮|鐨勪|鐨勫|瀹夎|鍙戦|鐢ㄦ埛|鎴戠殑|鏁版嵁|鎸佷箙|璁$畻|婧愪簬|鏂囦欢|瀹樻柟|娴嬭瘯|鍥剧墖|杩涜涓|鎵撳紑|杈撳嚭|鏌ヨ|鑾峰彇|閰嶇疆|缂撳瓨|瀛樺偍|鍒濆|璇曟嵎|璋冭瘯|鍥剧墖|瀛︿範|鎬ц兘/
2411
+ /** 外部 AI 工具画像 raw JSON envelope 特征(整段混入的签名: memoryBlock/"uid"/updatedAt/"role")。 */
2412
+ var RAW_JSON_MARK = /memoryBlock|"uid"\s*:|updatedAt|"role"\s*:\s*"(?:user|assistant|system)"/i
2413
+ /** base64 残骸行特征(≥200 字符纯 base64 字母表)。 */
2414
+ var BASE64_LINE = /^[A-Za-z0-9+\/]{200,}={0,2}$/
2415
+ /** 检测一段文本的疑似乱码密度(命中特征字符占比)。 */
2416
+ function mojibakeDensity(text) {
2417
+ var t = String(text || '')
2418
+ if (!t) return 0
2419
+ var hits = (t.match(new RegExp(MOJIBAKE_RE.source, 'g')) || []).length
2420
+ // 按命中段长度粗算占比, 而非按字符, 避免长文本误报
2421
+ var covered = t.length
2422
+ var density = (hits * 8) / Math.max(1, covered)
2423
+ return density
2424
+ }
2425
+ /** 剔除文本中的疑似乱码/外部文档行: 返回 { clean, dropped }。保守阈值 0.003 约=长文本中若干处命中。 */
2426
+ function scrubJunkLines(text, opts) {
2427
+ var o = opts || {}
2428
+ var lines = String(text || '').split('\n')
2429
+ var out = []
2430
+ var dropped = 0
2431
+ var seen = Object.create(null)
2432
+ var inCode = false
2433
+ var maxLines = o.maxLines || 4000
2434
+ for (var i = 0; i < lines.length; i++) {
2435
+ var line = lines[i]
2436
+ if (line.length > 5000) { dropped++; continue } // 单行超长(疑似 base64/二进制)直接丢
2437
+ if (/^```/.test(line)) { inCode = !inCode; continue } // 代码块整体不进记忆注入
2438
+ if (inCode) { dropped++; continue }
2439
+ // 连续重复行去重(保首次)
2440
+ var key = line.slice(0, 60)
2441
+ if (o.dedup && seen[key]) { dropped++; continue }
2442
+ if (o.dedup) seen[key] = true
2443
+ // 乱码行丢弃
2444
+ if (mojibakeDensity(line) > 0.01) { dropped++; continue }
2445
+ // 复读行丢弃(语义朊病毒: 念诗/单字重复)
2446
+ if (hasStutter(line)) { dropped++; continue }
2447
+ out.push(line)
2448
+ if (out.length >= maxLines) break
2449
+ }
2450
+ return { clean: out.join('\n').replace(/\n{3,}/g, '\n\n').trim(), dropped }
2451
+ }
2452
+ /** 注入端: 清洗后再截断(头部), 供 renderMemoryDynamic 使用。 */
2453
+ function sanitizeForInjection(text, maxChars) {
2454
+ var s = scrubJunkLines(text, { dedup: false })
2455
+ return truncateHead(s.clean, maxChars || 2000)
2456
+ }
2457
+ /** 写入端: 单条上限 + 乱码拒写 + 连续重复拒写。返回 { ok, reason, clean }。 */
2458
+ function sanitizeForWrite(text, opts) {
2459
+ var o = opts || {}
2460
+ var maxEntry = o.maxEntryChars || 8000
2461
+ var raw = String(text || '')
2462
+ if (raw.length === 0) return { ok: false, reason: 'empty' }
2463
+ if (mojibakeDensity(raw) > 0.001) return { ok: false, reason: 'mojibake', clean: '' }
2464
+ if (hasStutter(raw)) return { ok: false, reason: 'stutter', clean: '' }
2465
+ // 0.1.28: raw JSON envelope / base64 残骸拒写(防外部 AI 工具画像整段混入)
2466
+ if (RAW_JSON_MARK.test(raw)) return { ok: false, reason: 'raw-json', clean: '' }
2467
+ var b64line = raw.split('\n').some(function (l) { var k = l.trim(); return k.length > 100 && BASE64_LINE.test(k) })
2468
+ if (b64line) return { ok: false, reason: 'base64', clean: '' }
2469
+ if (raw.length > maxEntry) {
2470
+ // 超长: 截断并标记(防止整篇文档吸入; 正常条目极少超过)
2471
+ return { ok: true, clean: raw.slice(0, maxEntry), truncated: true }
2472
+ }
2473
+ // 连续行重复(同一段一模一样的行连续 ≥3 次 → 疑似退化 writer 循环; 空行打断连续, 避免把不同段落里相同的短行误判为循环)
2474
+ var seq = 0, prev = '', repeated = false
2475
+ for (var l of raw.split('\n')) {
2476
+ var t = l.trim()
2477
+ if (!t) { seq = 0; prev = ''; continue }
2478
+ if (t === prev) { seq++; if (seq >= 3) { repeated = true; break } } else { prev = t; seq = 1 }
2479
+ }
2480
+ if (repeated) return { ok: false, reason: 'duplicate-lines' }
2481
+ return { ok: true, clean: raw }
2482
+ }
2483
+ /** 写闸门拦截原因 → 中文说明(供三个写入工具返回信息)。 */
2484
+ var WRITE_GATE_REASON = { empty: '内容为空', mojibake: '疑似乱码/错误编码往返', stutter: '疑似复读退化', 'duplicate-lines': '疑似重复内容块', 'raw-json': '疑似外部画像 raw JSON envelope', base64: '疑似 base64 编码残骸行' }
2485
+ /** 追加去重复读守卫: 检查 incoming 首行是否已出现在现有内容尾部(近 60 行, 包含式匹配——日志行的 "- HH:MM " 前缀不影响判定)。 */
2486
+ function tailHas(existing, incoming) {
2487
+ if (!existing || !incoming) return false
2488
+ var first = String(incoming).trim().split('\n')[0].trim().slice(0, 60)
2489
+ if (!first) return false
2490
+ var tail = String(existing).trim().split('\n').slice(-60)
2491
+ for (var i = 0; i < tail.length; i++) {
2492
+ if (tail[i].indexOf(first) !== -1) return true
2493
+ }
2494
+ return false
2495
+ }
2496
+ /** 脏 token 检查器(prion-scan 式只读, 0.1.28 集成): 对给定文件跑四类启发式(编码异常/重复块/超长行/raw JSON), 返回 文件|行区间|类型 报告(不含正文)。 */
2497
+ async function dirtyScanForFiles(targets) {
2498
+ var MAX_PER_FILE = 25
2499
+ var out = []
2500
+ for (var ti = 0; ti < (targets || []).length; ti++) {
2501
+ var t = targets[ti]
2502
+ var file = t && t.path
2503
+ var label = (t && t.name) || file
2504
+ if (!file) continue
2505
+ var buf, sizeKB
2506
+ try {
2507
+ buf = await readFile(file)
2508
+ if (buf.includes(0)) continue // 二进制跳过
2509
+ sizeKB = Math.round((buf.length / 1024) * 10) / 10
2510
+ } catch (e) { continue }
2511
+ var part = buf.toString('utf8')
2512
+ var lines = part.split('\n')
2513
+ var findings = []
2514
+ var addFind = function (range, type) { if (findings.length < MAX_PER_FILE) findings.push({ range: String(range), type: type }) }
2515
+ var moji = [], jsonLines = [], longLines = [], b64 = []
2516
+ var lineC = Object.create(null), secC = Object.create(null)
2517
+ for (var i = 0; i < lines.length; i++) {
2518
+ var ln = lines[i].replace(/\r$/, '')
2519
+ var k = ln.trim()
2520
+ if (k.length >= 8 && !/^[=\-_#*·•|\s]+$/.test(k) && !/^#{1,6}\s/.test(k)) lineC[k] = (lineC[k] || 0) + 1
2521
+ var m = k.match(/^##\s+(.+)$/)
2522
+ if (m) { var tt = m[1].replace(/\s+/g, ' ').slice(0, 80); secC[tt] = (secC[tt] || 0) + 1 }
2523
+ if (mojibakeDensity(ln) > 0.001) moji.push(i + 1)
2524
+ if (RAW_JSON_MARK.test(ln)) jsonLines.push(i + 1)
2525
+ if (ln.length > 500) longLines.push(i + 1)
2526
+ if (ln.length > 100 && BASE64_LINE.test(k)) b64.push(i + 1)
2527
+ }
2528
+ if (moji.length) addFind(moji.slice(0, 8).join(',') + (moji.length > 8 ? '…(' + moji.length + ' 行)' : ''), '编码异常 mojibake ×' + moji.length + ' 行')
2529
+ if (jsonLines.length) addFind(jsonLines.slice(0, 8).join(',') + (jsonLines.length > 8 ? '…(' + jsonLines.length + ' 行)' : ''), '"raw JSON envelope(外部画像)" ×' + jsonLines.length + ' 行')
2530
+ if (longLines.length) addFind(longLines.slice(0, 5).join(',') + (longLines.length > 5 ? '…' : ''), '超长行 >500 ×' + longLines.length)
2531
+ if (b64.length) addFind(b64.slice(0, 5).join(',') + (b64.length > 5 ? '…' : ''), 'base64 残骸行 ×' + b64.length)
2532
+ var dupKeys = Object.keys(lineC).filter(function (k2) { return lineC[k2] >= 3 }).sort(function (a, b) { return lineC[b] - lineC[a] }).slice(0, 5)
2533
+ if (dupKeys.length) addFind('—', '重复内容行(文件内 ≥3 次) ×' + dupKeys.length + ' 组')
2534
+ var repSec = Object.keys(secC).filter(function (t2) { return secC[t2] >= 2 }).slice(0, 5)
2535
+ if (repSec.length) addFind('—', '重复 ## 标题 ×' + repSec.length + ' 组')
2536
+ if (findings.length) out.push({ name: label, file: file, sizeKB: sizeKB, lines: lines.length, findings: findings })
2537
+ }
2538
+ return out
2539
+ }
2540
+ /** 语义朊病毒守卫: 检测词/字符级复读退化(念诗/单字重复/垃圾 token 循环)。保守、双保险。 */
2541
+ function hasStutter(text) {
2542
+ var t = String(text || '')
2543
+ if (!t) return false
2544
+ // 英文/ASCII 二字符以上词连续复读 ≥4 次(空白或标点分隔都算, 覆盖 "Run. Run. Run. Run.")
2545
+ if (/(?:^|[^\w])(\w{2,})(?:[^\w]+\1){3,}(?:[^\w]|$)/.test(t)) return true
2546
+ // CJK/日文单字连读 ≥5 次(相邻最多隔 2 个非 CJK 字符, 覆盖念诗式 "风。风。风。风。风。")
2547
+ if (/([\u4e00-\u9fff\u3040-\u30ff])(?:[^\u4e00-\u9fff\u3040-\u30ff]{0,2}\1){4,}/.test(t)) return true
2548
+ return false
2549
+ }
2367
2550
  /** 从 session 提取消息。surface 不是完整可靠的 user 来源,因此失败时回退完整事件日志。 */
2368
2551
  function messageOfEvent(ev) {
2369
2552
  if (!ev) return null
@@ -2556,10 +2739,16 @@ export function apply(ctx, config) {
2556
2739
  const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
2557
2740
  const p = await engine.resolvePaths(exec.agent)
2558
2741
  const logPath = path.join(p.projectDir, date + '.md')
2559
- const entry = '- ' + nowHm() + ' ' + String(args.note).trim()
2742
+ const note = String(args.note || '').trim()
2743
+ // 写闸门: 乱码/复读/重复行拦截 + 单条 2000 字上限(日志条目应为一句话概括)
2744
+ const gate = sanitizeForWrite(note, { maxEntryChars: 2000 })
2745
+ if (!gate.ok) return 'memory_log: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
2746
+ const existing = engine.state.logText || (await engine.readTextSafe(logPath)) || ''
2747
+ if (tailHas(existing, note)) return 'memory_log: 该条目与日志尾部已有内容重复,拒绝写入(复读防护)。'
2748
+ const entry = '- ' + nowHm() + ' ' + (gate.clean || note).trim()
2560
2749
  const body = await engine.appendText(logPath, entry)
2561
2750
  if (date === engine.memToday()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
2562
- return '已更新记忆文档: ' + logPath + '\n' + entry
2751
+ return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
2563
2752
  }),
2564
2753
 
2565
2754
  defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。每日预算 3000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
@@ -2569,17 +2758,26 @@ export function apply(ctx, config) {
2569
2758
  const p = await engine.resolvePaths(exec.agent)
2570
2759
  const content = String(args.content || '').trim()
2571
2760
  if (!content) return 'memory_note: content 为空,未写入。'
2572
- const acct = await engine.ensureBudget(exec.agent, 'note', content)
2761
+ const replace = args.action === 'replace'
2762
+ // 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
2763
+ const gate = sanitizeForWrite(content, { maxEntryChars: replace ? 200000 : 8000 })
2764
+ if (!gate.ok) return 'memory_note: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
2765
+ const write = gate.clean || content
2766
+ if (!replace) {
2767
+ const existing = engine.state.notesText || (await engine.readTextSafe(p.notesPath)) || ''
2768
+ if (tailHas(existing, write)) return 'memory_note: 与笔记尾部已有内容重复,拒绝写入(复读防护)。'
2769
+ }
2770
+ const acct = await engine.ensureBudget(exec.agent, 'note', write)
2573
2771
  if (!acct.ok) return 'memory_note: 项目笔记今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
2574
2772
  let body
2575
- if (args.action === 'replace') {
2576
- body = content
2773
+ if (replace) {
2774
+ body = write
2577
2775
  await engine.writeFull(p.notesPath, body)
2578
2776
  } else {
2579
- body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + content)
2777
+ body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + write)
2580
2778
  }
2581
2779
  engine.state.notesText = body; engine.state.loadedAt = Date.now()
2582
- return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
2780
+ return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
2583
2781
  }),
2584
2782
 
2585
2783
  defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
@@ -2589,17 +2787,26 @@ export function apply(ctx, config) {
2589
2787
  const p = await engine.resolvePaths(exec.agent)
2590
2788
  const content = String(args.content || '').trim()
2591
2789
  if (!content) return 'memory_user: content 为空,未写入。'
2592
- const acct = await engine.ensureBudget(exec.agent, 'user', content)
2790
+ const replace = args.action === 'replace'
2791
+ // 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
2792
+ const gate = sanitizeForWrite(content, { maxEntryChars: replace ? 200000 : 8000 })
2793
+ if (!gate.ok) return 'memory_user: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
2794
+ const write = gate.clean || content
2795
+ if (!replace) {
2796
+ const existing = engine.state.userText || (await engine.readTextSafe(p.userFile)) || ''
2797
+ if (tailHas(existing, write)) return 'memory_user: 与用户级记忆尾部已有内容重复,拒绝写入(复读防护)。'
2798
+ }
2799
+ const acct = await engine.ensureBudget(exec.agent, 'user', write)
2593
2800
  if (!acct.ok) return 'memory_user: 用户级记忆今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
2594
2801
  let body
2595
- if (args.action === 'replace') {
2596
- body = content
2802
+ if (replace) {
2803
+ body = write
2597
2804
  await engine.writeFull(p.userFile, body)
2598
2805
  } else {
2599
- body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' + content)
2806
+ body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' + write)
2600
2807
  }
2601
2808
  engine.state.userText = body; engine.state.loadedAt = Date.now()
2602
- return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + content + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
2809
+ return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
2603
2810
  }),
2604
2811
 
2605
2812
  defineTool('memory_read', '按需读取记忆文件完整内容(某日日志/反思全文、用户级记忆、项目笔记、日历),注入上下文只含精简摘要,需要细节时用本工具,不要要求用户粘贴。', {
@@ -2651,7 +2858,7 @@ export function apply(ctx, config) {
2651
2858
  text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
2652
2859
  }, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
2653
2860
 
2654
- defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 把某源内容整体接入本地记忆(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,自动标注来源)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
2861
+ defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 以纯链接模式接入(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,只记录源文件路径指针、不写入内容,需要时按需读取;防止外部脏内容混入本地记忆)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
2655
2862
  action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
2656
2863
  source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
2657
2864
  target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
@@ -2818,6 +3025,27 @@ export function apply(ctx, config) {
2818
3025
  try { writeJson(res, 200, await engine.debugInfo()) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
2819
3026
  },
2820
3027
  },
3028
+ {
3029
+ kind: 'exact',
3030
+ path: API.scanDirty,
3031
+ handler: async (req, res) => {
3032
+ if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
3033
+ if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
3034
+ try {
3035
+ const p = await engine.resolvePaths(undefined)
3036
+ const targets = [
3037
+ { name: '用户级 MEMORY.md', path: p.userFile },
3038
+ { name: '项目笔记 MEMORY.md', path: p.notesPath },
3039
+ { name: '今日日志 ' + engine.memToday() + '.md', path: p.logPath },
3040
+ ]
3041
+ const refl = await engine.listReflections(p.reflectDir, 50)
3042
+ for (const r of refl || []) targets.push({ name: '反思 ' + (r.name || ''), path: path.join(p.reflectDir, r.name) })
3043
+ const files = await dirtyScanForFiles(targets)
3044
+ const totalFindings = files.reduce((n, x) => n + (x.findings ? x.findings.length : 0), 0)
3045
+ writeJson(res, 200, { files, totalFindings, scannedAt: new Date().toISOString() })
3046
+ } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
3047
+ },
3048
+ },
2821
3049
  {
2822
3050
  kind: 'exact',
2823
3051
  path: API.browseDir,
@@ -2937,7 +3165,12 @@ export function apply(ctx, config) {
2937
3165
  if (!body || typeof body.content !== 'string' || !body.content.trim()) return writeJson(res, 400, { error: 'invalid body' })
2938
3166
  try {
2939
3167
  const p = await engine.resolvePaths(undefined)
2940
- const text = '\n## ' + engine.memToday() + '\n' + body.content.trim()
3168
+ // 写闸门: 概览页手动追加与三个写入工具同规则(乱码/复读/重复行拒绝, 单条 8000 字, 复读去重)
3169
+ const gate = sanitizeForWrite(body.content.trim())
3170
+ if (!gate.ok) return writeJson(res, 400, { error: '写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。' })
3171
+ const existing = engine.state.notesText || (await engine.readTextSafe(p.notesPath)) || ''
3172
+ if (tailHas(existing, gate.clean)) return writeJson(res, 400, { error: '与笔记尾部已有内容重复,未写入(复读防护)。' })
3173
+ const text = '\n## ' + engine.memToday() + '\n' + gate.clean
2941
3174
  const updated = await engine.appendText(p.notesPath, text)
2942
3175
  engine.state.notesText = updated
2943
3176
  engine.state.loadedAt = Date.now()
@@ -3137,3 +3370,6 @@ export function apply(ctx, config) {
3137
3370
 
3138
3371
  console.log('[dsh-auto-memory] ready: engine + ' + tools.length + ' tools + injection + ' + routes.length + ' routes (external memory: ' + Object.keys(DEFAULT_CONFIG.externalSources).length + ' sources)')
3139
3372
  }
3373
+
3374
+ /** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
3375
+ export { sanitizeForWrite, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@a9i5k4/dsh-auto-memory",
3
3
  "description": "DSH 自动记忆插件:三层记忆(用户级/项目笔记/每日日志)自动注入与检索、每轮对话自动沉淀、每日反思、可视化面板与设置页,支持继承其他 AI 工具的记忆。",
4
- "version": "0.1.26",
4
+ "version": "0.1.29",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {