@a9i5k4/dsh-auto-memory 0.1.25 → 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 +266 -234
- package/README.zh-CN.md +264 -232
- package/lib/client.js +56 -1
- package/lib/index.js +234 -24
- package/package.json +1 -1
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