@a9i5k4/dsh-auto-memory 0.1.26 → 0.1.28
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 +13 -0
- package/README.zh-CN.md +13 -0
- package/lib/client.js +51 -1
- package/lib/index.js +234 -24
- package/package.json +1 -1
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
|
@@ -346,6 +346,27 @@ window.__ModuleLoader__.load({
|
|
|
346
346
|
|
|
347
347
|
// ───────────────────────── 更新弹窗 / 首次指导 ─────────────────────────
|
|
348
348
|
var CHANGELOG = {
|
|
349
|
+
'0.1.28': { zh: [
|
|
350
|
+
'脏 token 检查器(prion-scan 四类启发式整合):mojibake GBK 残骸特征表补全至 34 项(与 prion-scan.mjs 逐字一致),写入闸门新增 raw JSON envelope(memoryBlock/"uid"/updatedAt/"role")与 base64 残骸行拒写——外部 AI 工具画像无法再整段混入。',
|
|
351
|
+
'新增「扫描脏 token」:设置页调试中心一键扫描用户级/项目笔记/每日日志/反思,按行区间返回 mojibake / raw JSON / 超长行 / base64 / 重复块报告(只给位置,不含正文)。',
|
|
352
|
+
], en: [
|
|
353
|
+
'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.',
|
|
354
|
+
'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).',
|
|
355
|
+
] },
|
|
356
|
+
'0.1.27': { zh: [
|
|
357
|
+
'记忆卫生闸门(写端):memory_log/note/user 三个写入工具先过 sanitizeForWrite——疑似乱码(GBK 错误编码往返)/复读退化(词/字符循环,含跨标点)/连续重复行(≥3)拒绝并中文回执;append 单条上限 8000 字、replace 整篇 20 万字;追加前 tailHas 与文件尾部近 60 行做包含式复读去重。',
|
|
358
|
+
'全写入口审计:唯一漏网=API.note(概览页手动追加)已补同套闸门,回归 42 用例全过。',
|
|
359
|
+
'外部记忆接入只存路径指针,不再复制内容;注入端清洗乱码行/代码块/复读行;注入块加"记忆定位/读法"与语体纪律(条目一律第三人称客观陈述)。',
|
|
360
|
+
], en: [
|
|
361
|
+
'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).',
|
|
362
|
+
'Full write-entry audit: the only leak (API.note manual append) now goes through the same gate; 42 regression cases all pass.',
|
|
363
|
+
'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).',
|
|
364
|
+
] },
|
|
365
|
+
'0.1.20': { zh: [
|
|
366
|
+
'修复:正式发布流程 cordis.patch.yml(loader 入口 id + 包名)转换事故——发布包与开发版 identity 完全隔离,不再互相撞车。',
|
|
367
|
+
], en: [
|
|
368
|
+
'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.',
|
|
369
|
+
] },
|
|
349
370
|
'0.1.26': { zh: [
|
|
350
371
|
'文档:README 定位改为「技术先行 + 人性化」——intro 强调缓存友好三层记忆引擎,新增「底层工程」章节(前缀缓存友好/注入精简/AI限频/凭据过滤/跨工具),与「主动懂你的伙伴」章节互补。',
|
|
351
372
|
], en: [
|
|
@@ -504,6 +525,7 @@ window.__ModuleLoader__.load({
|
|
|
504
525
|
smartRecall: '/api/dsh-auto-memory/smart-recall',
|
|
505
526
|
workspaces: '/api/dsh-auto-memory/workspaces',
|
|
506
527
|
debug: '/api/dsh-auto-memory/debug',
|
|
528
|
+
scanDirty: '/api/dsh-auto-memory/scan-dirty',
|
|
507
529
|
browseDir: '/api/dsh-auto-memory/browse-dir',
|
|
508
530
|
pickDir: '/api/dsh-auto-memory/pick-dir',
|
|
509
531
|
updateCheck: '/api/dsh-auto-memory/update-check',
|
|
@@ -1692,6 +1714,25 @@ window.__ModuleLoader__.load({
|
|
|
1692
1714
|
var busyPair = useState(false)
|
|
1693
1715
|
var busy = busyPair[0]
|
|
1694
1716
|
var setBusy = busyPair[1]
|
|
1717
|
+
var scanPair = useState(null)
|
|
1718
|
+
var scan = scanPair[0]
|
|
1719
|
+
var setScan = scanPair[1]
|
|
1720
|
+
var scanBusyPair = useState(false)
|
|
1721
|
+
var scanBusy = scanBusyPair[0]
|
|
1722
|
+
var setScanBusy = scanBusyPair[1]
|
|
1723
|
+
var scanFiles = scan && scan.files ? scan.files : null
|
|
1724
|
+
var filesTotal = scan && scan.files ? scan.files.length : 0
|
|
1725
|
+
var totalFindings = scan ? (scan.totalFindings || 0) : 0
|
|
1726
|
+
var scanError = scan && scan.error ? scan.error : ''
|
|
1727
|
+
function runScan() {
|
|
1728
|
+
if (scanBusy) return
|
|
1729
|
+
setScanBusy(true)
|
|
1730
|
+
setScan(null)
|
|
1731
|
+
fetch(API.scanDirty).then(function (r) { return r.json() }).then(function (d) {
|
|
1732
|
+
setScan(d || {})
|
|
1733
|
+
setScanBusy(false)
|
|
1734
|
+
}).catch(function (e) { setScan({ error: String(e && e.message || e) }); setScanBusy(false) })
|
|
1735
|
+
}
|
|
1695
1736
|
function refresh() {
|
|
1696
1737
|
if (busy) return
|
|
1697
1738
|
setBusy(true)
|
|
@@ -1737,7 +1778,16 @@ window.__ModuleLoader__.load({
|
|
|
1737
1778
|
kv(t('kvDup'), data.duplicateHeadings + t('kvDupCount'), data.duplicateHeadings > 0),
|
|
1738
1779
|
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
1780
|
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)
|
|
1781
|
+
kv(t('kvApi'), probes ? Object.keys(probes).map(function (k) { return k + '=' + (probes[k].status !== undefined ? probes[k].status : probes[k].error) }).join(' ') : '…', apiBad),
|
|
1782
|
+
h('div', { 'data-dam-row': '', style: { marginTop: '6px', alignItems: 'flex-start', flexDirection: 'column' } },
|
|
1783
|
+
h('div', { 'data-dam-row': '' },
|
|
1784
|
+
h('button', { 'data-dam-btn': '', title: 'prion-scan 式四类启发式,只报位置不含正文', onClick: runScan, disabled: scanBusy }, scanBusy ? (locale === 'zh' ? '扫描中…' : 'Scanning…') : (locale === 'zh' ? '扫描脏 token' : 'Scan dirty tokens')),
|
|
1785
|
+
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'))))),
|
|
1786
|
+
scanFiles && scanFiles.length ? scanFiles.map(function (sf) {
|
|
1787
|
+
return h('div', { 'data-dam-row': '', style: { alignItems: 'flex-start', flexDirection: 'column', gap: '2px', marginTop: '4px' } },
|
|
1788
|
+
h('span', { style: { fontWeight: 600, fontSize: 'calc(11px * var(--dam-scale))' } }, sf.name + ' [' + sf.sizeKB + 'KB, ' + sf.lines + ' 行]'),
|
|
1789
|
+
sf.findings.map(function (fd) { return h('div', { 'data-dam-hint': '', style: { whiteSpace: 'pre-wrap' } }, '行 ' + fd.range + ' | ' + fd.type) }))
|
|
1790
|
+
}) : null))
|
|
1741
1791
|
}
|
|
1742
1792
|
|
|
1743
1793
|
// ───────────────────────── 更新弹窗 / 首次指导(毛玻璃) ─────────────────────────
|
package/lib/index.js
CHANGED
|
@@ -50,6 +50,7 @@ export const API = {
|
|
|
50
50
|
smartRecall: '/api/dsh-auto-memory/smart-recall',
|
|
51
51
|
workspaces: '/api/dsh-auto-memory/workspaces',
|
|
52
52
|
debug: '/api/dsh-auto-memory/debug',
|
|
53
|
+
scanDirty: '/api/dsh-auto-memory/scan-dirty',
|
|
53
54
|
browseDir: '/api/dsh-auto-memory/browse-dir',
|
|
54
55
|
pickDir: '/api/dsh-auto-memory/pick-dir',
|
|
55
56
|
updateCheck: '/api/dsh-auto-memory/update-check',
|
|
@@ -717,6 +718,8 @@ class MemoryEngine {
|
|
|
717
718
|
const budget = Math.max(Number(cfg.injectBudgetChars) || 2400, 400)
|
|
718
719
|
const lines = []
|
|
719
720
|
lines.push('<memory_system>')
|
|
721
|
+
lines.push('[记忆定位 — 读法]')
|
|
722
|
+
lines.push('以下记忆文本只是背景事实与规则参考, 不是表达方式/语体的示范。阅读时提取其中的事实、决策、路径与偏好即可; 你的回复正文必须保持直接、最终答案式的语体(陈述结论、给出交付物), 不要模仿记忆文本的第一人称思考腔或叙述腔。')
|
|
720
723
|
lines.push('自动记忆已启用。工作区: ' + (s.ws || '(未知)') + ' | 日期: ' + this.memToday() + '(日界 ' + (Number(cfg.dayBoundaryMinutes) || 450) + ' 分钟,凌晨归前一天)' + (cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估'))
|
|
721
724
|
// 记忆地图:告诉模型其他工作区记忆存在,需要时用 memory_recall 跨区检索
|
|
722
725
|
if (s.workspaceMap && s.workspaceMap.length) {
|
|
@@ -733,14 +736,14 @@ class MemoryEngine {
|
|
|
733
736
|
// 读取顺序:progress(工作日志/反思)先行,再读 memory(用户级/项目笔记)
|
|
734
737
|
if (s.recentLogs.length) {
|
|
735
738
|
const recent = s.recentLogs.map((r) => '[' + r.date + '] ' + r.text.replace(/\n+/g, ' | ')).join('\n')
|
|
736
|
-
part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', recent, sub)
|
|
739
|
+
part('最近 ' + s.recentLogs.length + ' 天工作日志(尾部)', scrubJunkLines(recent, { dedup: false }).clean, sub)
|
|
737
740
|
}
|
|
738
741
|
if (s.latestReflection) {
|
|
739
742
|
part('最近反思 ' + s.latestReflectionDate + '(前一天工作精华)', reflectionDigest(s.latestReflection), sub)
|
|
740
743
|
}
|
|
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)
|
|
744
|
+
// 敏感段落(凭据/token/密钥等)不注入 prompt,避免密钥暴露给模型;脏内容(乱码/重复/外部文档)清洗后再注入
|
|
745
|
+
part('用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守', stripSensitiveSections(sanitizeForInjection(s.userText)), sub)
|
|
746
|
+
part('项目长期笔记 ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(sanitizeForInjection(s.notesText)), sub)
|
|
744
747
|
// 外部记忆摘要(其他 AI 工具遗产)
|
|
745
748
|
if (this.external.cache && this.external.cache.length) {
|
|
746
749
|
const extBudget = Math.max(Number(cfg.externalInjectionChars) || 1400, 200)
|
|
@@ -794,6 +797,7 @@ class MemoryEngine {
|
|
|
794
797
|
lines.push('- 自动沉淀:每轮对话结束,插件会自动评估本轮内容,把有记录价值的写进今日日志([自动沉淀] 标记),有长期价值的升格到项目笔记/用户级记忆,寒暄轮自动跳过。你仍须按上方纪律转述自己的显式记忆操作;也可调用 memory_consolidate 让 AI 读日志发散提炼长期要点。')
|
|
795
798
|
lines.push('- 记忆仅作补充,不替代正常回复与交付物。')
|
|
796
799
|
lines.push('- 注入上下文只含精简记忆(最近1天日志/反思精华/路径索引);**需要某天完整日志、反思全文或记忆文件全文时,调用 memory_read 按需读取(kind=log/reflection/user/notes/calendar),不要要求用户粘贴**。')
|
|
800
|
+
lines.push('- **语体纪律**:写任何记忆条目(日志/笔记/用户级/反思)都用**客观陈述**——第三人称中性句式, 只留可复用的事实/决策/规则/路径; **禁止**第一人称思维叙述("我考虑/我排查/我想"), 禁止思考腔, 禁止过程复述(过程只落结论)。')
|
|
797
801
|
return lines.join('\n')
|
|
798
802
|
}
|
|
799
803
|
|
|
@@ -1632,6 +1636,7 @@ class MemoryEngine {
|
|
|
1632
1636
|
'- 寒暄、闲聊、单纯问候、纯测试、无实质内容 → 只输出 (无)',
|
|
1633
1637
|
'- 有实质内容(完成工作、修复问题、做出决策、约定规则、用户偏好、讨论结论)时,提炼 1-3 条要点',
|
|
1634
1638
|
'- 每条一句话,具体明确,不要泛泛而谈,不要复述对话过程',
|
|
1639
|
+
'- 语体: 每条用第三人称客观陈述, 只写可复用的事实/决策/规则/路径; 禁止第一人称思维叙述("我考虑/我排查")与思考腔, 过程只落结论',
|
|
1635
1640
|
'- 项目专属的进度 → [LOG];有跨会话长期价值的项目决策/架构/约定 → [NOTE];跨项目通用的用户硬性规则/偏好 → [USER]',
|
|
1636
1641
|
'- [NOTE]/[USER] 只在真正长期有价值时用,宁缺毋滥',
|
|
1637
1642
|
'输出格式(严格遵守):',
|
|
@@ -1751,6 +1756,7 @@ class MemoryEngine {
|
|
|
1751
1756
|
'规则:',
|
|
1752
1757
|
'- 只提炼有跨会话长期价值的:技术决策、架构约定、关键路径、用户偏好/习惯、踩过的坑及其规则',
|
|
1753
1758
|
'- 不记临时信息(某次具体修 bug 的过程省略,但其背后的规则/约定值得记)',
|
|
1759
|
+
'- 语体: 条目用第三人称客观陈述, 只落可复用结论; 禁止第一人称思维叙述与思考腔',
|
|
1754
1760
|
'- 项目专属 → [PROJECT];跨项目通用的用户硬性规则/偏好 → [USER]',
|
|
1755
1761
|
'- 每条一句话,简短明确;已经在下方"已有记忆"里出现的不要重复',
|
|
1756
1762
|
'输出格式(严格遵守):',
|
|
@@ -1868,6 +1874,7 @@ class MemoryEngine {
|
|
|
1868
1874
|
'- 丢弃过程流水(某次具体修 bug 的过程、临时路径、搜索结果、报错细节)',
|
|
1869
1875
|
'- 按主题组织,可用"### 主题"小标题,每条一句话,简短明确',
|
|
1870
1876
|
'- 不要复述日志原文,只要蒸馏后的要点',
|
|
1877
|
+
'- 语体: 条目用第三人称客观陈述, 只落可复用结论; 禁止第一人称思维叙述与思考腔',
|
|
1871
1878
|
'输出格式:markdown(### 主题 + - 要点)。若没有任何值得保留的内容,只输出一行:(无)',
|
|
1872
1879
|
'',
|
|
1873
1880
|
'待蒸馏日志:',
|
|
@@ -2242,24 +2249,31 @@ class ExternalMemory {
|
|
|
2242
2249
|
return out
|
|
2243
2250
|
}
|
|
2244
2251
|
|
|
2245
|
-
/**
|
|
2252
|
+
/** 把某个源"接入"本地记忆(纯链接模式 2026-08-18): 只在记忆文档里落一条指向源文件的路径指针, 不整段写入内容。
|
|
2253
|
+
* 内容留在原文件, 模型需要时用 memory_read / 直接读取路径按需获取。
|
|
2254
|
+
* 防止外部工具的脏内容(乱码/整篇文档/复读块)被导入进本地记忆——语义朊病毒的传播路径之一。
|
|
2255
|
+
* (本机模型为远程调用, 不需要 AI 蒸馏要点; 用户级层已有画像/偏好, 无需为导入再跑 subagent。) */
|
|
2246
2256
|
async importInto(sourceId, target, engine, agent) {
|
|
2247
2257
|
const srcs = await this.discover(false)
|
|
2248
2258
|
const src = srcs.find((s) => s.id === sourceId)
|
|
2249
2259
|
if (!src) return '外部源不存在: ' + sourceId
|
|
2250
2260
|
if (src.kind === 'sessions') return '会话类源不支持整体接入,请用 memory_recall 按需检索(' + src.files.length + ' 个会话文件)。'
|
|
2251
|
-
const
|
|
2261
|
+
const fileRefs = (src.files && src.files.length)
|
|
2262
|
+
? src.files.map((f) => ' - ' + f.path).join('\n')
|
|
2263
|
+
: ' - (未知路径)'
|
|
2264
|
+
const linkBlock = '## 来自 ' + src.tool + '(' + src.name + ') — 接入于 ' + engine.memToday() + ' [链接模式]\n'
|
|
2265
|
+
+ fileRefs + '\n- 用法: 需要时用 memory_read 或直接读取上述路径按需获取, 不整段写入。'
|
|
2252
2266
|
if (target === 'user') {
|
|
2253
2267
|
const p = await engine.resolvePaths(agent)
|
|
2254
|
-
const body = await engine.appendText(p.userFile, '\n' +
|
|
2268
|
+
const body = await engine.appendText(p.userFile, '\n' + linkBlock)
|
|
2255
2269
|
engine.state.userText = body
|
|
2256
|
-
return '已接入用户级记忆(' + src.name + ', ' + src.
|
|
2270
|
+
return '已接入用户级记忆(' + src.name + ', 链接模式 ' + src.files.length + ' 个源路径, 未写入内容)'
|
|
2257
2271
|
}
|
|
2258
2272
|
const p = await engine.resolvePaths(agent)
|
|
2259
|
-
const body = await engine.appendText(p.notesPath, '\n' +
|
|
2273
|
+
const body = await engine.appendText(p.notesPath, '\n' + linkBlock)
|
|
2260
2274
|
engine.state.notesText = body
|
|
2261
2275
|
engine.state.loadedAt = Date.now()
|
|
2262
|
-
return '已接入项目笔记(' + src.name + ', ' + src.
|
|
2276
|
+
return '已接入项目笔记(' + src.name + ', 链接模式 ' + src.files.length + ' 个源路径, 未写入内容)'
|
|
2263
2277
|
}
|
|
2264
2278
|
|
|
2265
2279
|
/** 检查某源是否已接入用户级/项目笔记。 */
|
|
@@ -2364,6 +2378,149 @@ function stripSensitiveSections(text) {
|
|
|
2364
2378
|
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
2365
2379
|
}
|
|
2366
2380
|
|
|
2381
|
+
|
|
2382
|
+
/** 记忆卫生守卫族(0.1.27 基础加固, 2026-08-18): 在读取/写入/导入/注入四端拦截乱码、重复与外部整篇文档, 防止外部 AI 工具记忆的脏内容混入。 */
|
|
2383
|
+
/** 常见 GBK 残骸特征(UTF-8 被按 GBK 解码再存回)——命中即判定疑似乱码(0.1.28 补全为与 prion-scan.mjs 一致的 34 项)。 */
|
|
2384
|
+
var MOJIBAKE_RE = /涓婁紶|涓嬭浇|鏉ユ簮|鈥|鈶|鈮|鐨勪|鐨勫|瀹夎|鍙戦|鐢ㄦ埛|鎴戠殑|鏁版嵁|鎸佷箙|璁$畻|婧愪簬|鏂囦欢|瀹樻柟|娴嬭瘯|鍥剧墖|杩涜涓|鎵撳紑|杈撳嚭|鏌ヨ|鑾峰彇|閰嶇疆|缂撳瓨|瀛樺偍|鍒濆|璇曟嵎|璋冭瘯|鍥剧墖|瀛︿範|鎬ц兘/
|
|
2385
|
+
/** 外部 AI 工具画像 raw JSON envelope 特征(整段混入的签名: memoryBlock/"uid"/updatedAt/"role")。 */
|
|
2386
|
+
var RAW_JSON_MARK = /memoryBlock|"uid"\s*:|updatedAt|"role"\s*:\s*"(?:user|assistant|system)"/i
|
|
2387
|
+
/** base64 残骸行特征(≥200 字符纯 base64 字母表)。 */
|
|
2388
|
+
var BASE64_LINE = /^[A-Za-z0-9+\/]{200,}={0,2}$/
|
|
2389
|
+
/** 检测一段文本的疑似乱码密度(命中特征字符占比)。 */
|
|
2390
|
+
function mojibakeDensity(text) {
|
|
2391
|
+
var t = String(text || '')
|
|
2392
|
+
if (!t) return 0
|
|
2393
|
+
var hits = (t.match(new RegExp(MOJIBAKE_RE.source, 'g')) || []).length
|
|
2394
|
+
// 按命中段长度粗算占比, 而非按字符, 避免长文本误报
|
|
2395
|
+
var covered = t.length
|
|
2396
|
+
var density = (hits * 8) / Math.max(1, covered)
|
|
2397
|
+
return density
|
|
2398
|
+
}
|
|
2399
|
+
/** 剔除文本中的疑似乱码/外部文档行: 返回 { clean, dropped }。保守阈值 0.003 约=长文本中若干处命中。 */
|
|
2400
|
+
function scrubJunkLines(text, opts) {
|
|
2401
|
+
var o = opts || {}
|
|
2402
|
+
var lines = String(text || '').split('\n')
|
|
2403
|
+
var out = []
|
|
2404
|
+
var dropped = 0
|
|
2405
|
+
var seen = Object.create(null)
|
|
2406
|
+
var inCode = false
|
|
2407
|
+
var maxLines = o.maxLines || 4000
|
|
2408
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2409
|
+
var line = lines[i]
|
|
2410
|
+
if (line.length > 5000) { dropped++; continue } // 单行超长(疑似 base64/二进制)直接丢
|
|
2411
|
+
if (/^```/.test(line)) { inCode = !inCode; continue } // 代码块整体不进记忆注入
|
|
2412
|
+
if (inCode) { dropped++; continue }
|
|
2413
|
+
// 连续重复行去重(保首次)
|
|
2414
|
+
var key = line.slice(0, 60)
|
|
2415
|
+
if (o.dedup && seen[key]) { dropped++; continue }
|
|
2416
|
+
if (o.dedup) seen[key] = true
|
|
2417
|
+
// 乱码行丢弃
|
|
2418
|
+
if (mojibakeDensity(line) > 0.01) { dropped++; continue }
|
|
2419
|
+
// 复读行丢弃(语义朊病毒: 念诗/单字重复)
|
|
2420
|
+
if (hasStutter(line)) { dropped++; continue }
|
|
2421
|
+
out.push(line)
|
|
2422
|
+
if (out.length >= maxLines) break
|
|
2423
|
+
}
|
|
2424
|
+
return { clean: out.join('\n').replace(/\n{3,}/g, '\n\n').trim(), dropped }
|
|
2425
|
+
}
|
|
2426
|
+
/** 注入端: 清洗后再截断(头部), 供 renderMemoryDynamic 使用。 */
|
|
2427
|
+
function sanitizeForInjection(text, maxChars) {
|
|
2428
|
+
var s = scrubJunkLines(text, { dedup: false })
|
|
2429
|
+
return truncateHead(s.clean, maxChars || 2000)
|
|
2430
|
+
}
|
|
2431
|
+
/** 写入端: 单条上限 + 乱码拒写 + 连续重复拒写。返回 { ok, reason, clean }。 */
|
|
2432
|
+
function sanitizeForWrite(text, opts) {
|
|
2433
|
+
var o = opts || {}
|
|
2434
|
+
var maxEntry = o.maxEntryChars || 8000
|
|
2435
|
+
var raw = String(text || '')
|
|
2436
|
+
if (raw.length === 0) return { ok: false, reason: 'empty' }
|
|
2437
|
+
if (mojibakeDensity(raw) > 0.001) return { ok: false, reason: 'mojibake', clean: '' }
|
|
2438
|
+
if (hasStutter(raw)) return { ok: false, reason: 'stutter', clean: '' }
|
|
2439
|
+
// 0.1.28: raw JSON envelope / base64 残骸拒写(防外部 AI 工具画像整段混入)
|
|
2440
|
+
if (RAW_JSON_MARK.test(raw)) return { ok: false, reason: 'raw-json', clean: '' }
|
|
2441
|
+
var b64line = raw.split('\n').some(function (l) { var k = l.trim(); return k.length > 100 && BASE64_LINE.test(k) })
|
|
2442
|
+
if (b64line) return { ok: false, reason: 'base64', clean: '' }
|
|
2443
|
+
if (raw.length > maxEntry) {
|
|
2444
|
+
// 超长: 截断并标记(防止整篇文档吸入; 正常条目极少超过)
|
|
2445
|
+
return { ok: true, clean: raw.slice(0, maxEntry), truncated: true }
|
|
2446
|
+
}
|
|
2447
|
+
// 连续行重复(同一段一模一样的行连续 ≥3 次 → 疑似退化 writer 循环; 空行打断连续, 避免把不同段落里相同的短行误判为循环)
|
|
2448
|
+
var seq = 0, prev = '', repeated = false
|
|
2449
|
+
for (var l of raw.split('\n')) {
|
|
2450
|
+
var t = l.trim()
|
|
2451
|
+
if (!t) { seq = 0; prev = ''; continue }
|
|
2452
|
+
if (t === prev) { seq++; if (seq >= 3) { repeated = true; break } } else { prev = t; seq = 1 }
|
|
2453
|
+
}
|
|
2454
|
+
if (repeated) return { ok: false, reason: 'duplicate-lines' }
|
|
2455
|
+
return { ok: true, clean: raw }
|
|
2456
|
+
}
|
|
2457
|
+
/** 写闸门拦截原因 → 中文说明(供三个写入工具返回信息)。 */
|
|
2458
|
+
var WRITE_GATE_REASON = { empty: '内容为空', mojibake: '疑似乱码/错误编码往返', stutter: '疑似复读退化', 'duplicate-lines': '疑似重复内容块', 'raw-json': '疑似外部画像 raw JSON envelope', base64: '疑似 base64 编码残骸行' }
|
|
2459
|
+
/** 追加去重复读守卫: 检查 incoming 首行是否已出现在现有内容尾部(近 60 行, 包含式匹配——日志行的 "- HH:MM " 前缀不影响判定)。 */
|
|
2460
|
+
function tailHas(existing, incoming) {
|
|
2461
|
+
if (!existing || !incoming) return false
|
|
2462
|
+
var first = String(incoming).trim().split('\n')[0].trim().slice(0, 60)
|
|
2463
|
+
if (!first) return false
|
|
2464
|
+
var tail = String(existing).trim().split('\n').slice(-60)
|
|
2465
|
+
for (var i = 0; i < tail.length; i++) {
|
|
2466
|
+
if (tail[i].indexOf(first) !== -1) return true
|
|
2467
|
+
}
|
|
2468
|
+
return false
|
|
2469
|
+
}
|
|
2470
|
+
/** 脏 token 检查器(prion-scan 式只读, 0.1.28 集成): 对给定文件跑四类启发式(编码异常/重复块/超长行/raw JSON), 返回 文件|行区间|类型 报告(不含正文)。 */
|
|
2471
|
+
async function dirtyScanForFiles(targets) {
|
|
2472
|
+
var MAX_PER_FILE = 25
|
|
2473
|
+
var out = []
|
|
2474
|
+
for (var ti = 0; ti < (targets || []).length; ti++) {
|
|
2475
|
+
var t = targets[ti]
|
|
2476
|
+
var file = t && t.path
|
|
2477
|
+
var label = (t && t.name) || file
|
|
2478
|
+
if (!file) continue
|
|
2479
|
+
var buf, sizeKB
|
|
2480
|
+
try {
|
|
2481
|
+
buf = await readFile(file)
|
|
2482
|
+
if (buf.includes(0)) continue // 二进制跳过
|
|
2483
|
+
sizeKB = Math.round((buf.length / 1024) * 10) / 10
|
|
2484
|
+
} catch (e) { continue }
|
|
2485
|
+
var part = buf.toString('utf8')
|
|
2486
|
+
var lines = part.split('\n')
|
|
2487
|
+
var findings = []
|
|
2488
|
+
var addFind = function (range, type) { if (findings.length < MAX_PER_FILE) findings.push({ range: String(range), type: type }) }
|
|
2489
|
+
var moji = [], jsonLines = [], longLines = [], b64 = []
|
|
2490
|
+
var lineC = Object.create(null), secC = Object.create(null)
|
|
2491
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2492
|
+
var ln = lines[i].replace(/\r$/, '')
|
|
2493
|
+
var k = ln.trim()
|
|
2494
|
+
if (k.length >= 8 && !/^[=\-_#*·•|\s]+$/.test(k) && !/^#{1,6}\s/.test(k)) lineC[k] = (lineC[k] || 0) + 1
|
|
2495
|
+
var m = k.match(/^##\s+(.+)$/)
|
|
2496
|
+
if (m) { var tt = m[1].replace(/\s+/g, ' ').slice(0, 80); secC[tt] = (secC[tt] || 0) + 1 }
|
|
2497
|
+
if (mojibakeDensity(ln) > 0.001) moji.push(i + 1)
|
|
2498
|
+
if (RAW_JSON_MARK.test(ln)) jsonLines.push(i + 1)
|
|
2499
|
+
if (ln.length > 500) longLines.push(i + 1)
|
|
2500
|
+
if (ln.length > 100 && BASE64_LINE.test(k)) b64.push(i + 1)
|
|
2501
|
+
}
|
|
2502
|
+
if (moji.length) addFind(moji.slice(0, 8).join(',') + (moji.length > 8 ? '…(' + moji.length + ' 行)' : ''), '编码异常 mojibake ×' + moji.length + ' 行')
|
|
2503
|
+
if (jsonLines.length) addFind(jsonLines.slice(0, 8).join(',') + (jsonLines.length > 8 ? '…(' + jsonLines.length + ' 行)' : ''), '"raw JSON envelope(外部画像)" ×' + jsonLines.length + ' 行')
|
|
2504
|
+
if (longLines.length) addFind(longLines.slice(0, 5).join(',') + (longLines.length > 5 ? '…' : ''), '超长行 >500 ×' + longLines.length)
|
|
2505
|
+
if (b64.length) addFind(b64.slice(0, 5).join(',') + (b64.length > 5 ? '…' : ''), 'base64 残骸行 ×' + b64.length)
|
|
2506
|
+
var dupKeys = Object.keys(lineC).filter(function (k2) { return lineC[k2] >= 3 }).sort(function (a, b) { return lineC[b] - lineC[a] }).slice(0, 5)
|
|
2507
|
+
if (dupKeys.length) addFind('—', '重复内容行(文件内 ≥3 次) ×' + dupKeys.length + ' 组')
|
|
2508
|
+
var repSec = Object.keys(secC).filter(function (t2) { return secC[t2] >= 2 }).slice(0, 5)
|
|
2509
|
+
if (repSec.length) addFind('—', '重复 ## 标题 ×' + repSec.length + ' 组')
|
|
2510
|
+
if (findings.length) out.push({ name: label, file: file, sizeKB: sizeKB, lines: lines.length, findings: findings })
|
|
2511
|
+
}
|
|
2512
|
+
return out
|
|
2513
|
+
}
|
|
2514
|
+
/** 语义朊病毒守卫: 检测词/字符级复读退化(念诗/单字重复/垃圾 token 循环)。保守、双保险。 */
|
|
2515
|
+
function hasStutter(text) {
|
|
2516
|
+
var t = String(text || '')
|
|
2517
|
+
if (!t) return false
|
|
2518
|
+
// 英文/ASCII 二字符以上词连续复读 ≥4 次(空白或标点分隔都算, 覆盖 "Run. Run. Run. Run.")
|
|
2519
|
+
if (/(?:^|[^\w])(\w{2,})(?:[^\w]+\1){3,}(?:[^\w]|$)/.test(t)) return true
|
|
2520
|
+
// CJK/日文单字连读 ≥5 次(相邻最多隔 2 个非 CJK 字符, 覆盖念诗式 "风。风。风。风。风。")
|
|
2521
|
+
if (/([\u4e00-\u9fff\u3040-\u30ff])(?:[^\u4e00-\u9fff\u3040-\u30ff]{0,2}\1){4,}/.test(t)) return true
|
|
2522
|
+
return false
|
|
2523
|
+
}
|
|
2367
2524
|
/** 从 session 提取消息。surface 不是完整可靠的 user 来源,因此失败时回退完整事件日志。 */
|
|
2368
2525
|
function messageOfEvent(ev) {
|
|
2369
2526
|
if (!ev) return null
|
|
@@ -2556,10 +2713,16 @@ export function apply(ctx, config) {
|
|
|
2556
2713
|
const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
|
|
2557
2714
|
const p = await engine.resolvePaths(exec.agent)
|
|
2558
2715
|
const logPath = path.join(p.projectDir, date + '.md')
|
|
2559
|
-
const
|
|
2716
|
+
const note = String(args.note || '').trim()
|
|
2717
|
+
// 写闸门: 乱码/复读/重复行拦截 + 单条 2000 字上限(日志条目应为一句话概括)
|
|
2718
|
+
const gate = sanitizeForWrite(note, { maxEntryChars: 2000 })
|
|
2719
|
+
if (!gate.ok) return 'memory_log: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
2720
|
+
const existing = engine.state.logText || (await engine.readTextSafe(logPath)) || ''
|
|
2721
|
+
if (tailHas(existing, note)) return 'memory_log: 该条目与日志尾部已有内容重复,拒绝写入(复读防护)。'
|
|
2722
|
+
const entry = '- ' + nowHm() + ' ' + (gate.clean || note).trim()
|
|
2560
2723
|
const body = await engine.appendText(logPath, entry)
|
|
2561
2724
|
if (date === engine.memToday()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
|
|
2562
|
-
return '已更新记忆文档: ' + logPath + '\n' + entry
|
|
2725
|
+
return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
|
|
2563
2726
|
}),
|
|
2564
2727
|
|
|
2565
2728
|
defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点)。action=append 追加一段(自动带日期标题);action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容)。每日预算 3000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:更新了项目笔记、加入什么要点**。', {
|
|
@@ -2569,17 +2732,26 @@ export function apply(ctx, config) {
|
|
|
2569
2732
|
const p = await engine.resolvePaths(exec.agent)
|
|
2570
2733
|
const content = String(args.content || '').trim()
|
|
2571
2734
|
if (!content) return 'memory_note: content 为空,未写入。'
|
|
2572
|
-
const
|
|
2735
|
+
const replace = args.action === 'replace'
|
|
2736
|
+
// 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
|
|
2737
|
+
const gate = sanitizeForWrite(content, { maxEntryChars: replace ? 200000 : 8000 })
|
|
2738
|
+
if (!gate.ok) return 'memory_note: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
2739
|
+
const write = gate.clean || content
|
|
2740
|
+
if (!replace) {
|
|
2741
|
+
const existing = engine.state.notesText || (await engine.readTextSafe(p.notesPath)) || ''
|
|
2742
|
+
if (tailHas(existing, write)) return 'memory_note: 与笔记尾部已有内容重复,拒绝写入(复读防护)。'
|
|
2743
|
+
}
|
|
2744
|
+
const acct = await engine.ensureBudget(exec.agent, 'note', write)
|
|
2573
2745
|
if (!acct.ok) return 'memory_note: 项目笔记今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
|
|
2574
2746
|
let body
|
|
2575
|
-
if (
|
|
2576
|
-
body =
|
|
2747
|
+
if (replace) {
|
|
2748
|
+
body = write
|
|
2577
2749
|
await engine.writeFull(p.notesPath, body)
|
|
2578
2750
|
} else {
|
|
2579
|
-
body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' +
|
|
2751
|
+
body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + write)
|
|
2580
2752
|
}
|
|
2581
2753
|
engine.state.notesText = body; engine.state.loadedAt = Date.now()
|
|
2582
|
-
return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' +
|
|
2754
|
+
return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
|
|
2583
2755
|
}),
|
|
2584
2756
|
|
|
2585
2757
|
defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。每日预算 4000 字/天,超限自动压缩旧内容腾空间(不拒绝写入)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
|
|
@@ -2589,17 +2761,26 @@ export function apply(ctx, config) {
|
|
|
2589
2761
|
const p = await engine.resolvePaths(exec.agent)
|
|
2590
2762
|
const content = String(args.content || '').trim()
|
|
2591
2763
|
if (!content) return 'memory_user: content 为空,未写入。'
|
|
2592
|
-
const
|
|
2764
|
+
const replace = args.action === 'replace'
|
|
2765
|
+
// 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
|
|
2766
|
+
const gate = sanitizeForWrite(content, { maxEntryChars: replace ? 200000 : 8000 })
|
|
2767
|
+
if (!gate.ok) return 'memory_user: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
2768
|
+
const write = gate.clean || content
|
|
2769
|
+
if (!replace) {
|
|
2770
|
+
const existing = engine.state.userText || (await engine.readTextSafe(p.userFile)) || ''
|
|
2771
|
+
if (tailHas(existing, write)) return 'memory_user: 与用户级记忆尾部已有内容重复,拒绝写入(复读防护)。'
|
|
2772
|
+
}
|
|
2773
|
+
const acct = await engine.ensureBudget(exec.agent, 'user', write)
|
|
2593
2774
|
if (!acct.ok) return 'memory_user: 用户级记忆今日预算已用尽(上限 ' + acct.acct.limit + ' 字/天)且自动压缩不可用(刚压缩过或 AI 不可用),本次未写入。可稍后再试或调用 memory_maintain 整理。'
|
|
2594
2775
|
let body
|
|
2595
|
-
if (
|
|
2596
|
-
body =
|
|
2776
|
+
if (replace) {
|
|
2777
|
+
body = write
|
|
2597
2778
|
await engine.writeFull(p.userFile, body)
|
|
2598
2779
|
} else {
|
|
2599
|
-
body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' +
|
|
2780
|
+
body = await engine.appendText(p.userFile, '\n## ' + engine.memToday() + '\n' + write)
|
|
2600
2781
|
}
|
|
2601
2782
|
engine.state.userText = body; engine.state.loadedAt = Date.now()
|
|
2602
|
-
return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' +
|
|
2783
|
+
return '已更新用户级记忆: ' + p.userFile + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '')
|
|
2603
2784
|
}),
|
|
2604
2785
|
|
|
2605
2786
|
defineTool('memory_read', '按需读取记忆文件完整内容(某日日志/反思全文、用户级记忆、项目笔记、日历),注入上下文只含精简摘要,需要细节时用本工具,不要要求用户粘贴。', {
|
|
@@ -2651,7 +2832,7 @@ export function apply(ctx, config) {
|
|
|
2651
2832
|
text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
|
|
2652
2833
|
}, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
|
|
2653
2834
|
|
|
2654
|
-
defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import
|
|
2835
|
+
defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import 以纯链接模式接入(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆,只记录源文件路径指针、不写入内容,需要时按需读取;防止外部脏内容混入本地记忆)。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
|
|
2655
2836
|
action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
|
|
2656
2837
|
source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
|
|
2657
2838
|
target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
|
|
@@ -2818,6 +2999,27 @@ export function apply(ctx, config) {
|
|
|
2818
2999
|
try { writeJson(res, 200, await engine.debugInfo()) } catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
2819
3000
|
},
|
|
2820
3001
|
},
|
|
3002
|
+
{
|
|
3003
|
+
kind: 'exact',
|
|
3004
|
+
path: API.scanDirty,
|
|
3005
|
+
handler: async (req, res) => {
|
|
3006
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
3007
|
+
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
3008
|
+
try {
|
|
3009
|
+
const p = await engine.resolvePaths(undefined)
|
|
3010
|
+
const targets = [
|
|
3011
|
+
{ name: '用户级 MEMORY.md', path: p.userFile },
|
|
3012
|
+
{ name: '项目笔记 MEMORY.md', path: p.notesPath },
|
|
3013
|
+
{ name: '今日日志 ' + engine.memToday() + '.md', path: p.logPath },
|
|
3014
|
+
]
|
|
3015
|
+
const refl = await engine.listReflections(p.reflectDir, 50)
|
|
3016
|
+
for (const r of refl || []) targets.push({ name: '反思 ' + (r.name || ''), path: path.join(p.reflectDir, r.name) })
|
|
3017
|
+
const files = await dirtyScanForFiles(targets)
|
|
3018
|
+
const totalFindings = files.reduce((n, x) => n + (x.findings ? x.findings.length : 0), 0)
|
|
3019
|
+
writeJson(res, 200, { files, totalFindings, scannedAt: new Date().toISOString() })
|
|
3020
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
3021
|
+
},
|
|
3022
|
+
},
|
|
2821
3023
|
{
|
|
2822
3024
|
kind: 'exact',
|
|
2823
3025
|
path: API.browseDir,
|
|
@@ -2937,7 +3139,12 @@ export function apply(ctx, config) {
|
|
|
2937
3139
|
if (!body || typeof body.content !== 'string' || !body.content.trim()) return writeJson(res, 400, { error: 'invalid body' })
|
|
2938
3140
|
try {
|
|
2939
3141
|
const p = await engine.resolvePaths(undefined)
|
|
2940
|
-
|
|
3142
|
+
// 写闸门: 概览页手动追加与三个写入工具同规则(乱码/复读/重复行拒绝, 单条 8000 字, 复读去重)
|
|
3143
|
+
const gate = sanitizeForWrite(body.content.trim())
|
|
3144
|
+
if (!gate.ok) return writeJson(res, 400, { error: '写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。' })
|
|
3145
|
+
const existing = engine.state.notesText || (await engine.readTextSafe(p.notesPath)) || ''
|
|
3146
|
+
if (tailHas(existing, gate.clean)) return writeJson(res, 400, { error: '与笔记尾部已有内容重复,未写入(复读防护)。' })
|
|
3147
|
+
const text = '\n## ' + engine.memToday() + '\n' + gate.clean
|
|
2941
3148
|
const updated = await engine.appendText(p.notesPath, text)
|
|
2942
3149
|
engine.state.notesText = updated
|
|
2943
3150
|
engine.state.loadedAt = Date.now()
|
|
@@ -3137,3 +3344,6 @@ export function apply(ctx, config) {
|
|
|
3137
3344
|
|
|
3138
3345
|
console.log('[dsh-auto-memory] ready: engine + ' + tools.length + ' tools + injection + ' + routes.length + ' routes (external memory: ' + Object.keys(DEFAULT_CONFIG.externalSources).length + ' sources)')
|
|
3139
3346
|
}
|
|
3347
|
+
|
|
3348
|
+
/** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
|
|
3349
|
+
export { sanitizeForWrite, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON }
|
package/package.json
CHANGED