@a9i5k4/dsh-auto-memory 3.1.0 → 3.1.1
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/docs/HANDBOOK.md +13 -9
- package/lib/client.js +695 -121
- package/lib/config-io.js +59 -6
- package/lib/context-host.js +10 -1
- package/lib/evidence-store.js +27 -1
- package/lib/index.js +680 -42
- package/lib/jsonl-tail-cursor.js +75 -0
- package/lib/m7-index-sync-host.js +4 -6
- package/lib/memory-hub.js +25 -2
- package/lib/migrate-pack.js +351 -0
- package/lib/python-setup.js +109 -27
- package/lib/recall-stats.js +242 -0
- package/lib/rules-layer.js +106 -15
- package/lib/semantic-js.js +25 -7
- package/lib/shadow-host.js +41 -2
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -51,11 +51,15 @@ import { exportSkillForPre, resolveSkillsRootPre } from './skill-export-host.js'
|
|
|
51
51
|
import { createMemoryHubPre } from './memory-hub.js'
|
|
52
52
|
// ★#110(2026-09-22):hub 持久化 IO 适配器与其健康度投影(原本内联在本文件里、失败被静默吞掉)。
|
|
53
53
|
import { createHubIoPre, createHubIoHealthPre, hubIoHealthSnapshotPre } from './hub-io.js'
|
|
54
|
+
// ★issue#103:环形 JSONL 增量游标(内容指纹)。见 lib/jsonl-tail-cursor.js 文件头。
|
|
55
|
+
import { createJsonlTailCursorPre } from './jsonl-tail-cursor.js'
|
|
56
|
+
import { createRecallStatsPre } from './recall-stats.js'
|
|
54
57
|
import { pickConsolidationTextPre } from './intent-clean.js'
|
|
55
58
|
// T1-3(2026-09-19):hubFlushTick 写入前的**内容卫生门**复用 ⑨ 同源清洗器。
|
|
56
59
|
// 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
|
|
57
60
|
import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
|
|
58
61
|
import { createStorageManagerPre } from './storage-manage.js'
|
|
62
|
+
import { workspaceSlugPre as migrateSlugPre, buildPackPre, validatePackPre, planImportPre, mergeSummaryRecordPre, MIGRATE_PACK_FORMAT_PRE } from './migrate-pack.js'
|
|
59
63
|
import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
|
|
60
64
|
import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
|
|
61
65
|
import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre, reusableWindowCachePre, shouldArmAutoContinuePre } from './water-window.js'
|
|
@@ -81,6 +85,56 @@ import { writeTextAtomicPreSync, writeTextAtomicPre, readJsonQuarantinePreSync }
|
|
|
81
85
|
import { WB_SIDECAR_VERSION, buildSidecarEntryPre, rebuildSidecarIndexPre, expandByTagPre, traceByIdPre, applyAnchorsPre, collectAnchorIdsPre, wbRefPre, normalizeRelPathPre, normalizeTitlePre, buildKanbanPre, buildSectionCardsPre, splitSectionsPre, buildKanbanMatrixPre, ledgerDateOfPre, WB_KANBAN_LANES_V1 } from './wb-sidecar.js'
|
|
82
86
|
// P6A 规则层(规则类与参考类分开措辞;规则真源 = 既有用户级记忆,不新增 RULES.md)
|
|
83
87
|
import { extractRulesLayerPre, renderRulesSectionPre, RULES_SECTION_TITLE_V1, RULES_SECTION_GUIDE_V1, REFERENCE_SECTION_GUIDE_V1, LOG_KINDS_V1 } from './rules-layer.js'
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* ★P9(2026-09-22):用户级硬性约束的**条目级增删改** —— 前端路由与模型工具共用的**唯一写盘口**。
|
|
91
|
+
*
|
|
92
|
+
* 为什么必须存在:[规则 — 用户级硬性约束 · 必须遵守] 段**每轮无条件注入且不参与裁剪**
|
|
93
|
+
* (memory-envelope.js 的 rules 分项不受限),而这一层在 P9 之前**认不出状态标记**。
|
|
94
|
+
* ⇒ 过时条目只能**真删**;而该能力此前只接在前端路由(R7),**模型够不着** ⇒ 模型只能整篇重写
|
|
95
|
+
* 用户级记忆(高风险、易丢条),或干脆不动 ⇒ 错误规则永久留在每轮 prompt 里。
|
|
96
|
+
*
|
|
97
|
+
* 纪律:①真源仍是 userFile,本函数不新增事实来源;②写入复用既有 writeFull 事务(备份 + 校验),
|
|
98
|
+
* 绝不绕过;③删/改支持 requireExpect **内容锚定**(模型侧强制、GUI 侧沿用 R7 的前端二次确认)。
|
|
99
|
+
*
|
|
100
|
+
* @param {object} engine 宿主引擎
|
|
101
|
+
* @param {string} op list | add | update | remove
|
|
102
|
+
* @param {object} payload { index?, expect?, text?, dateSection? }
|
|
103
|
+
* @param {object} [opts] { requireExpect?:boolean }
|
|
104
|
+
* @returns {Promise<{ok:boolean, error?:string, path?:string, items?:Array, preview?:string}>}
|
|
105
|
+
*/
|
|
106
|
+
async function applyRuleEditPre(engine, op, payload = {}, opts = {}) {
|
|
107
|
+
const o = String(op || '')
|
|
108
|
+
if (!['list', 'add', 'update', 'remove'].includes(o)) return { ok: false, error: 'invalid-op' }
|
|
109
|
+
try {
|
|
110
|
+
const p = await engine.resolvePaths(undefined)
|
|
111
|
+
const before = (await engine.readTextSafe(p.userFile)) || ''
|
|
112
|
+
const view = (text) => {
|
|
113
|
+
const items = listRuleItemsPre(text)
|
|
114
|
+
return { ok: true, path: p.userFile, items, preview: items.map((x) => '- ' + x.text).join('\n') }
|
|
115
|
+
}
|
|
116
|
+
if (o === 'list') return view(before)
|
|
117
|
+
// ★内容锚定:删/改前先核对「你认为这条现在是什么」,不符即拒 —— 防索引漂移删错行
|
|
118
|
+
if (o === 'update' || o === 'remove') {
|
|
119
|
+
const items0 = listRuleItemsPre(before)
|
|
120
|
+
const idx = Number(payload.index)
|
|
121
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= items0.length) return { ok: false, error: '编辑被拒: index-out-of-range' }
|
|
122
|
+
if (opts && opts.requireExpect) {
|
|
123
|
+
const expect = String(payload.expect == null ? '' : payload.expect).trim()
|
|
124
|
+
if (!expect) return { ok: false, error: '编辑被拒: 缺少 expect(先 op=list 取该条当前文本再回填)' }
|
|
125
|
+
if (items0[idx].text !== expect) return { ok: false, error: '编辑被拒: expect 与当前第 ' + idx + ' 条不符(内容锚定失败,请重新 list)' }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let r
|
|
129
|
+
if (o === 'add') r = appendRuleItemPre(before, payload.text, { dateSection: payload.dateSection })
|
|
130
|
+
else if (o === 'update') r = updateRuleItemPre(before, Number(payload.index), payload.text)
|
|
131
|
+
else r = removeRuleItemPre(before, Number(payload.index))
|
|
132
|
+
if (!r.ok) return { ok: false, error: '编辑被拒: ' + r.error }
|
|
133
|
+
const written = await engine.writeFull(p.userFile, r.text)
|
|
134
|
+
const after = (await engine.readTextSafe(p.userFile)) || written || r.text
|
|
135
|
+
return view(after)
|
|
136
|
+
} catch (e) { return { ok: false, error: String((e && e.message) || e) } }
|
|
137
|
+
}
|
|
84
138
|
import * as nodeZlib from 'node:zlib'
|
|
85
139
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
86
140
|
import { createHash } from 'node:crypto'
|
|
@@ -157,7 +211,7 @@ const SECTION_ORDER = 10000
|
|
|
157
211
|
const NOTICES_URL = 'https://raw.githubusercontent.com/Aik358/dsh-auto-memory/main/notices.json'
|
|
158
212
|
|
|
159
213
|
/** Model-facing announcement (tools + engine). */
|
|
160
|
-
export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user
|
|
214
|
+
export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则(整篇/追加);memory_rules 对用户级硬约束做**条目级**增删改(op=list/add/update/remove,删改需带 expect 内容锚定;这是清理「每轮必注入的 [规则] 段」过时条目的正确手段);memory_recall 检索本地记忆 + 外部记忆(WorkBuddy/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE 记忆与会话)+ 历史 DSH 会话;memory_external 查看/接入外部记忆源;memory_maintain 归档 30 天前日志;memory_reflect 保存每日反思;memory_status 查看状态;memory_consolidate 让 AI 读日志发散提炼长期要点固化进笔记。自动沉淀:每轮对话结束插件自动评估本轮内容并写今日日志/升格长期记忆(寒暄轮跳过,间隔与每日额度可在设置页「自动化」分组调整),无需你手动调 memory_log。主动性纪律:任务开始遇到不熟悉的代码/领域/历史决策时,先 memory_recall 检索本机全部 AI 工具历史,不凭空猜测;新工作区主动探索历史。限制:记忆文件为明文 Markdown;不存密钥除非用户明确要求;外部会话检索为关键词级(非语义);GUI 侧边栏「记忆」面板(含「接续」页签,可查看来源内容、从记忆 prompt 移除已导入段落)与设置页可查看/配置/接入。用户提到「记忆 / 昨天做了什么 / 之前怎么做的 / 每日反思 / 接续 / 其他 AI 的记忆」时即指本插件,请据此协作。白板纪律(三层分工,2026-09-17 定稿):①**白板 PLAN.md = 项目稳定的"是什么/怎么跑"事实**,由 memory_note(kind=plan) 整体重写(这是 12 个工具里**唯一**能覆盖已有结论的能力,旧版自动归档);②**交接账本 handoff-*.md = 动态状态的唯一权威**(任务状态/目标/已试方案与失败原因/进度与下一步),由 memory_note(kind=handoff) 新开一篇,**append-only、不追改旧账本**;③**项目笔记 MEMORY.md = 可复用的结论/决策**。维护时机(**条件触发,不是每轮**):**① 白板还是「自动建立 · 待模型重写」的骨架时(说明本工作区还没有真白板)——在理解项目全貌后先把它重写成真内容**;② 完成阶段性工作、或发现白板/账本所述与现状不符、或方向有实质变化时——**写记忆的同时顺手维护白板**,不要新开一轮专程去做。(骨架由宿主自动落,但那只是占位;**不要停在骨架**。)**禁止**:未得用户同意不得删减白板既有内容(只报告过时,不自动删);白板/产物功能关闭时跳过白板维护、不要因此报错。'
|
|
161
215
|
|
|
162
216
|
/** Route family. */
|
|
163
217
|
export const API = {
|
|
@@ -172,6 +226,7 @@ export const API = {
|
|
|
172
226
|
browseDir: '/api/dsh-auto-memory/browse-dir',
|
|
173
227
|
pickDir: '/api/dsh-auto-memory/pick-dir',
|
|
174
228
|
updateCheck: '/api/dsh-auto-memory/update-check',
|
|
229
|
+
recallStats: '/api/dsh-auto-memory/recall-stats',
|
|
175
230
|
update: '/api/dsh-auto-memory/update',
|
|
176
231
|
config: '/api/dsh-auto-memory/config',
|
|
177
232
|
reflect: '/api/dsh-auto-memory/reflect',
|
|
@@ -211,6 +266,9 @@ export const API = {
|
|
|
211
266
|
'memory-hub': '/api/dsh-auto-memory/memory-hub',
|
|
212
267
|
'storage-manage': '/api/dsh-auto-memory/storage-manage',
|
|
213
268
|
models: '/api/dsh-auto-memory/models',
|
|
269
|
+
'migrate-export': '/api/dsh-auto-memory/migrate-export',
|
|
270
|
+
'migrate-inspect': '/api/dsh-auto-memory/migrate-inspect',
|
|
271
|
+
'migrate-import': '/api/dsh-auto-memory/migrate-import',
|
|
214
272
|
}
|
|
215
273
|
|
|
216
274
|
/**
|
|
@@ -298,6 +356,12 @@ const DEFAULT_CONFIG = {
|
|
|
298
356
|
/** Tier-0 目录最多占「注入预算」的比例(默认 **0.25**):目录是索引层,不得把证据层(日志/笔记)挤空。
|
|
299
357
|
* 与 `tier0MaxTokens` 取小生效;另在注入侧再封顶一次(见 renderMemoryDynamic 的 catalogCost)。 */
|
|
300
358
|
tier0BudgetShare: 0.25,
|
|
359
|
+
/** ★P10(2026-09-22 用户拍板):**注入分区开关**(对象:kind → boolean)。
|
|
360
|
+
* **缺省 = 全开**(只有显式 false 才关)⇒ 出厂行为逐字节不变、可一键回退。
|
|
361
|
+
* 可关清单 = PROMPT_SECTION_KEYS_V1;关掉某段即该段不再进入本轮注入。
|
|
362
|
+
* 设计取向(用户原话:不是为了优化,是让看不惯的用户能自己关):**这是用户主权开关,
|
|
363
|
+
* 不是性能门** —— 因此不做任何"自动判断该不该关"的启发式。 */
|
|
364
|
+
promptSectionToggles: {},
|
|
301
365
|
/** 群反馈第 4 条 / P0-④d:**注入来源排除**(坏记忆不再反复灌入)。
|
|
302
366
|
* 字符串数组,四种写法(写死口径,可测;只做精确/前缀匹配,不做模糊猜测):
|
|
303
367
|
* · 记忆 id:`mem_<32hex>` → 排除该条命中
|
|
@@ -626,7 +690,7 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
626
690
|
snapshotReflectionTitle: '最近反思 {date}(前一天工作精华)',
|
|
627
691
|
snapshotUserTitle: '用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守',
|
|
628
692
|
snapshotNotesTitle: '项目长期笔记',
|
|
629
|
-
snapshotPlanTitle: '[白板 PLAN.md — 项目全貌快照(模型维护,用户在面板可见)
|
|
693
|
+
snapshotPlanTitle: '[白板 PLAN.md — 项目全貌快照(模型维护,用户在面板可见);**还是上面那种自动骨架、或内容已过时、或理解有实质变化时,用 memory_note(kind=plan) 重写**(重写=整体覆盖,旧版自动归档)]',
|
|
630
694
|
snapshotHandoffTitle: '[最近交接 — 四段式账本(任务状态/目标/已试方案与失败原因/进度与下一步)]',
|
|
631
695
|
snapshotWaterTitle: '[上下文水位提示 — 接近窗口上限]',
|
|
632
696
|
snapshotWaterBody: '上下文水位约 {pct}%(官方公式估算:4 字符≈1 token)。窗口将满,按序执行:①memory_note(kind=handoff) 写交接账本——四段各 ≤5 行:任务状态(一句话+当前产物路径)、目标(可验证的完成标准)、已试方案与失败原因(写成「方案→失败原因」,保留关键报错词)、进度与下一步(可直接执行的第一步,带文件或命令);②memory_note(kind=plan) 刷新白板全貌;③在回复正文明确建议用户开新窗口续接。可分解的子任务优先派子代理隔离上下文。不要跳过账本直接继续对话。',
|
|
@@ -649,7 +713,7 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
649
713
|
+ '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
|
|
650
714
|
+ '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
|
|
651
715
|
+ '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\n · 本轮**改过 lib/ 下任何文件**(含测试/工具) ⇒ 必写 **memory_note(kind=handoff)** 四段式账本;\n · 白板"当前进度"与本轮结束时的**事实不符**(回归数字/已完成项/下一步) ⇒ 必做 **memory_note(kind=plan)** 重写白板;\n · 仅补充一条可复用结论 ⇒ 才用 **memory_note(kind=note, action=append)**。\n ⚠️ **kind=note 不等于白板/账本**——只写 note 却宣称"已更新白板与账本"是**失职**;write 直接写 docs/ 的 md 也**不算**插件记忆。\n'
|
|
652
|
-
+ '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
|
|
716
|
+
+ '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **规则条目增删改(用户级硬约束,含过时条目真删)→ memory_rules** / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
|
|
653
717
|
+ '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
|
|
654
718
|
+ '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
|
|
655
719
|
+ '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
|
|
@@ -666,6 +730,40 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
666
730
|
staticWriteDiscipline: '[记忆写入纪律 — 必须遵守]',
|
|
667
731
|
})
|
|
668
732
|
|
|
733
|
+
/**
|
|
734
|
+
* ★P10:**可关的注入分区**(设置页开关据此枚举)。
|
|
735
|
+
*
|
|
736
|
+
* 判据:①该段是"内容/行为"而非"框架包裹";②关掉它不会破坏注入块结构。
|
|
737
|
+
* **刻意排除**(不暴露开关):`frame-head` / `frame-meta` / `frame-inscription` / `frame-tail`
|
|
738
|
+
* —— 它们是 `<memory_system>` 的开合与状态行,关掉会产出没有闭合标签的块。
|
|
739
|
+
* 值与 `renderMemoryDynamic` 里 `pushPart(bucket, kind, …)` 的 kind 逐字对应(守卫锁一致性)。
|
|
740
|
+
*/
|
|
741
|
+
export const PROMPT_SECTION_KEYS_V1 = Object.freeze([
|
|
742
|
+
'rules-section', // 规则段(用户级硬约束;关掉=不再注入「必须遵守」层)
|
|
743
|
+
'tier0-catalog', // Tier-0 常驻目录(指引层)
|
|
744
|
+
'whiteboard-plan', // 白板 PLAN 快照
|
|
745
|
+
'handoff-ledger', // 交接账本
|
|
746
|
+
'calendar', // 日历与日程
|
|
747
|
+
'external-memory', // 其他 AI 工具记忆
|
|
748
|
+
'external-sessions', // 历史会话索引
|
|
749
|
+
'workspace-map', // 其他工作区记忆索引
|
|
750
|
+
'welcome-title', // 新工作区欢迎语(标题)
|
|
751
|
+
'welcome-body', // 新工作区欢迎语(正文)
|
|
752
|
+
'plan-update-request',// 白板更新请求(must;关掉=模型不再被要求更新白板)
|
|
753
|
+
'water-advisory', // 上下文水位提醒(must;关掉=不再提醒收尾写账本)
|
|
754
|
+
'handoff-pointer', // 未绑定工作区时的账本指针
|
|
755
|
+
])
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* ★P10-T2(2026-09-22):上面 13 段里**带硬性行为要求**的子集 —— 设置页二级页据此在开关旁
|
|
759
|
+
* 标注「关掉会发生什么」(普通段落只需通用提示)。
|
|
760
|
+
* 纪律:本集合**必须是 PROMPT_SECTION_KEYS_V1 的子集**(守卫断言),不得独立增删语义。
|
|
761
|
+
*/
|
|
762
|
+
export const PROMPT_SECTION_MUST_V1 = Object.freeze([
|
|
763
|
+
'plan-update-request', // 关掉 = 模型不再被要求更新白板
|
|
764
|
+
'water-advisory', // 关掉 = 不再提醒收尾写账本
|
|
765
|
+
])
|
|
766
|
+
|
|
669
767
|
export { DEFAULT_PROMPT_LAYERS }
|
|
670
768
|
|
|
671
769
|
// ---------- 小工具 ----------
|
|
@@ -923,13 +1021,44 @@ function hasParentSession(x) {
|
|
|
923
1021
|
} catch (e) { return false }
|
|
924
1022
|
}
|
|
925
1023
|
|
|
926
|
-
/**
|
|
1024
|
+
/**
|
|
1025
|
+
* 诊断日志路径(单一来源)。★2026-09-22(用户拍板):路径只在这里拼一次,
|
|
1026
|
+
* diag() 落盘、轮转、debugInfo().logs 三处共用,避免三份拼法各自漂移。
|
|
1027
|
+
*/
|
|
1028
|
+
function diagLogFile() {
|
|
1029
|
+
return path.join(dshHome(), 'dsh-auto-memory-diagnose.log')
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* 诊断日志轮转(★2026-09-22 用户拍板「记录太多了」)。
|
|
1034
|
+
* 触发:单文件 > DIAG_MAX_BYTES。动作:改名为 `.1`(覆盖上一份 .1)后重新开始写。
|
|
1035
|
+
* 只保留当前 + 1 份历史(最多 2 个文件),**不是无限增长**——实测未轮转时已达 11 MB / 11 万行。
|
|
1036
|
+
* 全程 try/catch:轮转失败绝不影响落盘本身(fail-soft,与既有 _diagChain 同纪律)。
|
|
1037
|
+
*/
|
|
1038
|
+
const DIAG_MAX_BYTES = 2 * 1024 * 1024
|
|
1039
|
+
function rotateDiagLogIfNeeded() {
|
|
1040
|
+
try {
|
|
1041
|
+
const f = diagLogFile()
|
|
1042
|
+
if (!existsSync(f)) return false
|
|
1043
|
+
if (statSync(f).size <= DIAG_MAX_BYTES) return false
|
|
1044
|
+
const bak = f + '.1'
|
|
1045
|
+
try { if (existsSync(bak)) rmSync(bak, { force: true }) } catch (e) {}
|
|
1046
|
+
renameSync(f, bak)
|
|
1047
|
+
return true
|
|
1048
|
+
} catch (e) { return false }
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** 诊断输出:写 diagLogFile()(append,超限自动轮转)。★2026-09-22 起**不再** console.log。 */
|
|
927
1052
|
let _diagChain = Promise.resolve()
|
|
1053
|
+
let _diagRotated = false
|
|
928
1054
|
function diag(msg) {
|
|
929
1055
|
try {
|
|
930
1056
|
const line = new Date().toISOString() + ' ' + msg + '\n'
|
|
931
|
-
|
|
932
|
-
|
|
1057
|
+
// ★2026-09-22(用户拍板):**不再向控制台输出**。诊断行只落盘,PowerShell 保持干净。
|
|
1058
|
+
// 根治的是"循环刷屏":本函数是唯一每轮/每次动作都会调用的日志出口(旧实现写盘后又打一遍控制台)。
|
|
1059
|
+
// 要看他:面板「诊断」页签(走 debugInfo().logs),或直接打开 diagLogFile() 指的路径。
|
|
1060
|
+
if (!_diagRotated) { _diagRotated = rotateDiagLogIfNeeded() }
|
|
1061
|
+
_diagChain = _diagChain.then(() => appendFile(diagLogFile(), line, 'utf8')).catch(() => {})
|
|
933
1062
|
} catch (e) {}
|
|
934
1063
|
}
|
|
935
1064
|
|
|
@@ -1628,7 +1757,9 @@ class MemoryEngine {
|
|
|
1628
1757
|
|
|
1629
1758
|
/** callId→rootCallId 关联账本(root/nested tool call;tools/result 与持久 tool/result 双通道合并,有界)。关闭模式零留存。 */
|
|
1630
1759
|
linkObserverCall(runtime, callId, rootCallId, name, via) {
|
|
1631
|
-
|
|
1760
|
+
// ★#117 P3-1②(2026-09-22):与同文件 ingestEnvelope 的 disposed 闸对齐 ——
|
|
1761
|
+
// 旧实现漏了这一处 ⇒ dispose 之后 linkObserverCall 仍会惰性重建 callLinks(挂在已 disposed runtime 上)。
|
|
1762
|
+
if (!runtime || runtime.disposed || !callId) return
|
|
1632
1763
|
if (this.config.associativeMemoryEnabled !== true) return // 方案 B:关闭时不保留任何关联数据
|
|
1633
1764
|
if (!runtime.callLinks) runtime.callLinks = new Map()
|
|
1634
1765
|
const key = String(callId)
|
|
@@ -2338,6 +2469,73 @@ class MemoryEngine {
|
|
|
2338
2469
|
/** 白板重写:旧 PLAN.md 有实质内容且与新内容不同时,先归档到 handoff/archive/PLAN-<ts>.md 再写新快照(保留更改历史)。
|
|
2339
2470
|
* P7(2026-09-09)白板老化:新内容中标题命中 /历史|踩坑|流水线要点/ 的顶层「## 」节移入 handoff/archive/PLAN-history-<ts>.md
|
|
2340
2471
|
* (同秒多写 -b/-c 后缀防撞),PLAN.md 只留当前状态;内容只移动不删除;无匹配节 → 行为与旧版完全一致。 */
|
|
2472
|
+
/**
|
|
2473
|
+
* ★2026-09-22(用户拍板):**新工作区自动首建白板骨架** —— 白板首建缺口的正解。
|
|
2474
|
+
*
|
|
2475
|
+
* 缺口(证据见 docs/internal/TODO-NEXT-20260922.md §C.4):写盘链路完整(含首建、有用例且绿),
|
|
2476
|
+
* 但四处指令**只描述「重写」**(见本批 ③④⑤⑥)⇒ 没有 PLAN.md 的工作区里触发条件结构性恒假;
|
|
2477
|
+
* 且注入侧 planText||latestHandoffText 为假时整段白板提示不注入,模型连暗示都收不到。
|
|
2478
|
+
*
|
|
2479
|
+
* 修法:宿主不再等模型 —— 工作区第一次有会话活动就把骨架落下来。骨架一存在:
|
|
2480
|
+
* ① 注入条件转真 ⇒ 白板快照每轮注入 ⇒ 既有「维护/重写」纪律自举生效;
|
|
2481
|
+
* ② 面板不再显示空态 ⇒ 前端承诺与后端事实一致;
|
|
2482
|
+
* ③ kind=plan 的「重写」语义有了对象,不再空转。
|
|
2483
|
+
*
|
|
2484
|
+
* 纪律:
|
|
2485
|
+
* - **只写一次**:PLAN.md 已存在且非空即跳过(模型重写过的白板绝不被骨架覆盖)。
|
|
2486
|
+
* - **只走唯一写盘口**:委托 writePlanSnapshot ⇒ 归档/锚点/sidecar/事件日志/写入门全复用。
|
|
2487
|
+
* - **产物层闸门**:只判 handoffEnabled(白板是产物层),**不判** boardMode 与 autoContinueEnabled
|
|
2488
|
+
* —— 后者管的是"要不要接续材料",与"白板存不存在"无关(功能开关解耦纪律)。
|
|
2489
|
+
* - **fail-soft**:任何失败只回 {ok:false},绝不影响轮次收尾。
|
|
2490
|
+
* - 骨架必须过 P-H1 判据(≥1 个 body ≥20 非空白字符的 '## ' 顶节),否则"首建"落不下去。
|
|
2491
|
+
*/
|
|
2492
|
+
async ensurePlanBoardPre(projectDir) {
|
|
2493
|
+
try {
|
|
2494
|
+
if (!projectDir) return { ok: false, error: 'no-project-dir' }
|
|
2495
|
+
if (this.config && this.config.handoffEnabled === false) return { ok: true, skipped: 'handoff-disabled' }
|
|
2496
|
+
const planPath = path.join(projectDir, 'handoff', 'PLAN.md')
|
|
2497
|
+
const existing = await this.readTextSafe(planPath)
|
|
2498
|
+
if (existing && existing.trim()) return { ok: true, skipped: 'exists' }
|
|
2499
|
+
const r = await this.writePlanSnapshot(projectDir, this.skeletonPlanTextPre())
|
|
2500
|
+
if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'seed-write-failed', gate: r && r.gate }
|
|
2501
|
+
return { ok: true, seeded: true, path: planPath, at: Date.now() }
|
|
2502
|
+
} catch (e) {
|
|
2503
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
/** 白板骨架正文(纯字符串,零 IO)。必须过 P-H1 判据;刻意不写任何假内容。 */
|
|
2508
|
+
skeletonPlanTextPre() {
|
|
2509
|
+
const NL = String.fromCharCode(10)
|
|
2510
|
+
return [
|
|
2511
|
+
'# 项目全貌(自动建立 · 待模型重写)',
|
|
2512
|
+
'',
|
|
2513
|
+
'## 这是什么',
|
|
2514
|
+
'type:state',
|
|
2515
|
+
'本白板由 dsh-auto-memory 在本工作区首次活动时**自动建立**,内容尚未填写。',
|
|
2516
|
+
'模型在理解本项目全貌后,请用 memory_note(kind=plan) 整体重写本页,写入真实的全貌。',
|
|
2517
|
+
'',
|
|
2518
|
+
'## 进度与下一步',
|
|
2519
|
+
'type:progress',
|
|
2520
|
+
'本工作区暂无阶段性结论。完成首轮实质工作后请重写白板,写明当前状态与下一步(可直接执行的第一步)。',
|
|
2521
|
+
'',
|
|
2522
|
+
].join(NL)
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/** 轮末兜底:为当前 agent 的工作区自动首建白板(异步、fail-soft、不阻塞收尾)。 */
|
|
2526
|
+
async ensurePlanBoardForAgentPre(agent) {
|
|
2527
|
+
try {
|
|
2528
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (_) {} }
|
|
2529
|
+
if (this.config && this.config.handoffEnabled === false) return { ok: true, skipped: 'handoff-disabled' }
|
|
2530
|
+
const p = await this.resolvePaths(agent)
|
|
2531
|
+
const projectDir = (p && p.projectDir) || (p && p.handoffDir ? path.dirname(p.handoffDir) : '')
|
|
2532
|
+
if (!projectDir) return { ok: false, error: 'no-project-dir' }
|
|
2533
|
+
return await this.ensurePlanBoardPre(projectDir)
|
|
2534
|
+
} catch (e) {
|
|
2535
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2341
2539
|
async writePlanSnapshot(projectDir, content, opts) {
|
|
2342
2540
|
const dir = path.join(projectDir, 'handoff')
|
|
2343
2541
|
const planPath = path.join(dir, 'PLAN.md')
|
|
@@ -5599,7 +5797,7 @@ class MemoryEngine {
|
|
|
5599
5797
|
// 旧行为的病(两条独立病因,都有代码行号):
|
|
5600
5798
|
// A. 措辞:开场白把"规矩"与"资料"统一降格为「只是背景事实与规则参考」⇒ 规则不醒目;
|
|
5601
5799
|
// B. 节奏:`snapshotMinGapRounds` 使规矩在第 2–5 轮**不在场**(模型不是不听话,是没收到)。
|
|
5602
|
-
// 开关 `rulesLayeringMode
|
|
5800
|
+
// 开关 `rulesLayeringMode`(**出厂默认 'self'**,见 DEFAULT_CONFIG;旧注释写成 'off' 是错的)= 新旧并存 + 一键回退:关掉即完全回到旧行为。
|
|
5603
5801
|
// 开启后规则段走分项账本的 `rules` 分项:**不受 gap 约束、不参与裁剪**(v2 §3.3 选 (ii))。
|
|
5604
5802
|
let rulesLayer = { enabled: false, text: '', rules: [] }
|
|
5605
5803
|
let rulesSection = { text: '', chars: 0 }
|
|
@@ -5633,7 +5831,13 @@ class MemoryEngine {
|
|
|
5633
5831
|
// ★2026-09-15 修复:每段带 `priority`(low / normal / must)。超额时**先丢 low,must 永不丢**。
|
|
5634
5832
|
// 旧实现是**纯位置式**丢弃(从分项尾部丢),尾部恰好是 user-memory / project-notes
|
|
5635
5833
|
// ⇒ 价值最高的两段先死(本机实测)。位置顺序是渲染顺序,不是价值顺序。
|
|
5834
|
+
// ★P10:**注入分区开关**的唯一闸门(每段都过这里)。缺省全开 ⇒ 只有显式 false 才拦。
|
|
5835
|
+
// 只影响本段自己,不触发任何其他功能变化(用户铁律:单一开关不得顺带改变别的行为)。
|
|
5836
|
+
const secToggles = (cfg && cfg.promptSectionToggles && typeof cfg.promptSectionToggles === 'object')
|
|
5837
|
+
? cfg.promptSectionToggles : {}
|
|
5838
|
+
const sectionsOff = []
|
|
5636
5839
|
const pushPart = (bucket, kind, text, priority) => {
|
|
5840
|
+
if (secToggles[kind] === false) { sectionsOff.push(kind); return }
|
|
5637
5841
|
if (text === undefined || text === null || text === '') return
|
|
5638
5842
|
segs.push({ bucket, kind, text, priority: priority || 'normal' })
|
|
5639
5843
|
}
|
|
@@ -5773,6 +5977,22 @@ class MemoryEngine {
|
|
|
5773
5977
|
// 只用于账本的「合计是否超预算」可见性(落 meta;诊断类降级不进注入文本,避免每轮噪声)
|
|
5774
5978
|
budgetChars,
|
|
5775
5979
|
})
|
|
5980
|
+
// ★③ 召回统计 · inject 通道(2026-09-22 用户拍板「分开计算」):记**这一轮实际注入了哪些段、各多少字符**。
|
|
5981
|
+
// 为什么与 model 通道分开:inject = 「系统一直塞给我」= **成本**;model = 「我明确想找」= 高信号。
|
|
5982
|
+
// 合成一个数会把「模型懒得检索」误读成「这条不重要」,而这个误读会被写进加权公式、越滚越偏。
|
|
5983
|
+
// 数据源用 `envelope`(不是 `segs`):它已过预算裁剪 ⇒ 记的是**真正进 prompt 的段**,不是想注入的段。
|
|
5984
|
+
// ★只读:本块不修改 envelope、不影响注入文本(守卫断言:注入文本逐字节不变)。
|
|
5985
|
+
try {
|
|
5986
|
+
if (this._recallStats && envelope && Array.isArray(envelope.segments)) {
|
|
5987
|
+
const segList = envelope.segments.map((sg) => ({
|
|
5988
|
+
name: sg.kind, chars: (sg.text || '').length, layer: sg.bucket,
|
|
5989
|
+
}))
|
|
5990
|
+
const droppedList = (envelope.dropped || []).map((x) => ({
|
|
5991
|
+
name: x.kind + '(dropped)', chars: 0, layer: x.bucket,
|
|
5992
|
+
}))
|
|
5993
|
+
this._recallStats.observeInjection(segList.concat(droppedList))
|
|
5994
|
+
}
|
|
5995
|
+
} catch (_) {}
|
|
5776
5996
|
// T0-3:账本挂 state,面板/排障/后续 P6A 都读它(**不再有第二本账**)
|
|
5777
5997
|
try {
|
|
5778
5998
|
this.state.envelopeMeta = {
|
|
@@ -5810,7 +6030,7 @@ class MemoryEngine {
|
|
|
5810
6030
|
lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
|
|
5811
6031
|
lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
|
|
5812
6032
|
lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
|
|
5813
|
-
lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff)
|
|
6033
|
+
lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;白板 PLAN.md(人能读的项目规划图,用户在面板实时可见):**新工作区的骨架由宿主自动落(标题带「自动建立 · 待模型重写」),那只是占位——你理解项目全貌后要用 memory_note(kind=plan) 把它重写成真内容**;此后对项目全貌的理解发生实质变化时同样重写(整体覆盖,旧版自动归档)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
|
|
5814
6034
|
lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
|
|
5815
6035
|
// ★T6(2026-09-20 用户拍板):**看板分列机制** —— 模型此前完全不知道有 tag 这回事。
|
|
5816
6036
|
// 根因:wb-sidecar.js:594-597 的 WB_KANBAN_LANES_V1 靠 matchTags 匹配
|
|
@@ -6256,7 +6476,7 @@ class MemoryEngine {
|
|
|
6256
6476
|
if (rank && rank.scores && rank.scores.size) semScores = rank.scores
|
|
6257
6477
|
} catch (eBest) {
|
|
6258
6478
|
// R3:预期外失败 —— 引擎/worker 抛错导致回退词法,用户应当能知道(不只是 diag 一行)。
|
|
6259
|
-
try {
|
|
6479
|
+
try { diagThrottled('recall-best', 'recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {}
|
|
6260
6480
|
try { if (this._degradeSink) this._degradeSink.record('semantic-arm', String((eBest && eBest.message) || eBest).slice(0, 160)) } catch (_) {}
|
|
6261
6481
|
}
|
|
6262
6482
|
if (semScores && semScores.size) {
|
|
@@ -6295,7 +6515,7 @@ class MemoryEngine {
|
|
|
6295
6515
|
} catch (eImp) {
|
|
6296
6516
|
// R3:预期外失败才记。目录不存在已被上面的 existsSync 守卫拦下(预期内分支,静默);
|
|
6297
6517
|
// 走到这里的都是真异常(聚合/读文件/import 失败)⇒ 应当留痕。
|
|
6298
|
-
try {
|
|
6518
|
+
try { diagThrottled('evidence-agg', 'evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {}
|
|
6299
6519
|
try { if (this._degradeSink) this._degradeSink.record('evidence-arm', String((eImp && eImp.message) || eImp).slice(0, 160)) } catch (_) {}
|
|
6300
6520
|
}
|
|
6301
6521
|
const l0Hits = l0Corpus.filter((c) => c.lex > 0 || (typeof c.sem === 'number' && c.sem >= 0.5))
|
|
@@ -6308,7 +6528,7 @@ class MemoryEngine {
|
|
|
6308
6528
|
const tp = await import('./temporal-parse.js')
|
|
6309
6529
|
tr = tp.parseTemporalQueryPre(query, { now: Date.now() })
|
|
6310
6530
|
dateMsOf = tp.labelToDateMsPre
|
|
6311
|
-
} catch (eTr) { try {
|
|
6531
|
+
} catch (eTr) { try { diagThrottled('temporal-parse', 'temporal-parse 降级(无时间臂): ' + String((eTr && eTr.message) || eTr).slice(0, 140)) } catch (_) {} }
|
|
6312
6532
|
const tempFieldOf = (c) => {
|
|
6313
6533
|
if (!tr || typeof dateMsOf !== 'function') return undefined
|
|
6314
6534
|
const d = dateMsOf(c.label)
|
|
@@ -6344,7 +6564,17 @@ class MemoryEngine {
|
|
|
6344
6564
|
.sort((a, b) => b.lex - a.lex || (b.sem || 0) - (a.sem || 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
6345
6565
|
.slice(0, Math.max(1, Number(limit) || 8))
|
|
6346
6566
|
}
|
|
6347
|
-
|
|
6567
|
+
// ③ 召回统计埋点(2026-09-22):**只读** l0Top 做计数,绝不改其顺序/内容。
|
|
6568
|
+
// 为什么放这里:l0Top 已定型(融合或降级排序完成)、尚未被转成输出文本
|
|
6569
|
+
// ⇒ 记的就是"本轮真实交付的命中集",含 finalRank 口径。fail-soft 包住,统计绝不干扰召回。
|
|
6570
|
+
try {
|
|
6571
|
+
if (this._recallStats) {
|
|
6572
|
+
this._recallStats.observe(l0Top.map((c) => ({
|
|
6573
|
+
id: c.id, layer: c.layer, score: typeof c.sem === 'number' ? c.sem : c.lex, reason: c.reason,
|
|
6574
|
+
})), { channel: 'model' })
|
|
6575
|
+
}
|
|
6576
|
+
} catch (_) {}
|
|
6577
|
+
if (l0Top.length) {
|
|
6348
6578
|
out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
|
|
6349
6579
|
// ★R4-B(2026-09-18)分层**呈现**:把已排好序的命中**按层分组**后展示。
|
|
6350
6580
|
// ★排序零改动:分组只重排"行",不改 `l0Top` 的融合/词法序;组内保持原相对顺序,
|
|
@@ -6701,6 +6931,208 @@ class MemoryEngine {
|
|
|
6701
6931
|
return { logs, notes: truncateHead(notes || '', 800) }
|
|
6702
6932
|
}
|
|
6703
6933
|
|
|
6934
|
+
// ───────────────────── 迁移搬包(migrate pack)─────────────────────
|
|
6935
|
+
// 用户拍板:导出范围=主体(不带语义索引);路径需重写;入口放存储管理;先做一次性搬包。
|
|
6936
|
+
// ★IO 全在本节,纯逻辑全在 lib/migrate-pack.js(可单测、可守卫)。
|
|
6937
|
+
|
|
6938
|
+
/** 汇总文件路径(跨工作区全局)。 */
|
|
6939
|
+
_wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.json') }
|
|
6940
|
+
|
|
6941
|
+
/** 当前工作区在 summary 里的那条记录(导出时随包带走)。 */
|
|
6942
|
+
async _wsSummaryRecord(ws) {
|
|
6943
|
+
try {
|
|
6944
|
+
const raw = await this.readTextSafe(this._wsSummaryFile())
|
|
6945
|
+
if (!raw) return null
|
|
6946
|
+
const j = JSON.parse(raw)
|
|
6947
|
+
const list = j && Array.isArray(j.workspaces) ? j.workspaces : []
|
|
6948
|
+
return list.find((x) => x && x.path === ws) || null
|
|
6949
|
+
} catch (_) { return null }
|
|
6950
|
+
}
|
|
6951
|
+
|
|
6952
|
+
/** 目标工作区目录里现有文件(键=相对 slug 目录的路径)—— 供「预览差异」算新增/覆盖。
|
|
6953
|
+
* ★只读,且只读文本类文件(记忆数据全是文本)。 */
|
|
6954
|
+
async _readExistingWorkspaceFiles(ws) {
|
|
6955
|
+
const dir = this.projectDirOf(ws)
|
|
6956
|
+
const out = {}
|
|
6957
|
+
const walk = async (q, rel) => {
|
|
6958
|
+
let ents = []
|
|
6959
|
+
try { ents = await readdir(q, { withFileTypes: true }) } catch (_) { return }
|
|
6960
|
+
for (const en of ents) {
|
|
6961
|
+
const abs = path.join(q, en.name)
|
|
6962
|
+
const r = rel ? rel + '/' + en.name : en.name
|
|
6963
|
+
if (en.isDirectory()) { await walk(abs, r); continue }
|
|
6964
|
+
try {
|
|
6965
|
+
const st = await stat(abs)
|
|
6966
|
+
if (!st.isFile() || st.size > 8 * 1024 * 1024) continue
|
|
6967
|
+
out[r] = await readFile(abs, 'utf8')
|
|
6968
|
+
} catch (_) {}
|
|
6969
|
+
}
|
|
6970
|
+
}
|
|
6971
|
+
await walk(dir, '')
|
|
6972
|
+
return out
|
|
6973
|
+
}
|
|
6974
|
+
|
|
6975
|
+
/** 导出:打包一个工作区的全部记忆到一个 .dam-pack(单 JSON;compress=true 时 gzip)。 */
|
|
6976
|
+
async migrateExport(opts) {
|
|
6977
|
+
const o = opts || {}
|
|
6978
|
+
const ws = String(o.ws || '')
|
|
6979
|
+
if (!ws) return { ok: false, error: 'missing-ws' }
|
|
6980
|
+
const outPath = String(o.outPath || '')
|
|
6981
|
+
if (!outPath) return { ok: false, error: 'missing-out-path' }
|
|
6982
|
+
const dir = this.projectDirOf(ws)
|
|
6983
|
+
const st = await stat(dir).catch(() => null)
|
|
6984
|
+
if (!st || !st.isDirectory()) return { ok: false, error: 'workspace-dir-missing' }
|
|
6985
|
+
// ★只收文本类文件:记忆数据全部是 .md/.json/.jsonl/.txt;二进制跳过并记警告(不静默丢)
|
|
6986
|
+
const TEXT_RE = /\.(md|json|jsonl|txt|markdown)$/i
|
|
6987
|
+
const files = {}
|
|
6988
|
+
const skipped = []
|
|
6989
|
+
let bytes = 0
|
|
6990
|
+
const walk = async (q, rel) => {
|
|
6991
|
+
let ents = []
|
|
6992
|
+
try { ents = await readdir(q, { withFileTypes: true }) } catch (_) { return }
|
|
6993
|
+
for (const en of ents) {
|
|
6994
|
+
const abs = path.join(q, en.name)
|
|
6995
|
+
const r = rel ? rel + '/' + en.name : en.name
|
|
6996
|
+
if (en.isDirectory()) { await walk(abs, r); continue }
|
|
6997
|
+
if (!TEXT_RE.test(en.name)) { skipped.push(r); continue }
|
|
6998
|
+
try {
|
|
6999
|
+
const s = await stat(abs)
|
|
7000
|
+
if (!s.isFile()) continue
|
|
7001
|
+
if (s.size > 32 * 1024 * 1024) { skipped.push(r + '(too-large)'); continue }
|
|
7002
|
+
const txt = await readFile(abs, 'utf8')
|
|
7003
|
+
files[r] = txt
|
|
7004
|
+
bytes += Buffer.byteLength(txt, 'utf8')
|
|
7005
|
+
} catch (_) { skipped.push(r + '(unreadable)') }
|
|
7006
|
+
}
|
|
7007
|
+
}
|
|
7008
|
+
await walk(dir, '')
|
|
7009
|
+
const built = buildPackPre({
|
|
7010
|
+
ws, files,
|
|
7011
|
+
summaryRecord: await this._wsSummaryRecord(ws),
|
|
7012
|
+
pluginVersion: (this.manifest && this.manifest.version) || '',
|
|
7013
|
+
sourceHost: os.hostname(),
|
|
7014
|
+
})
|
|
7015
|
+
if (!built.ok) return { ok: false, error: built.error, skipped }
|
|
7016
|
+
const json = JSON.stringify(built.pack)
|
|
7017
|
+
const compress = o.compress !== false
|
|
7018
|
+
let payload = json, ext = '.dam-pack'
|
|
7019
|
+
if (compress) { payload = nodeZlib.gzipSync(Buffer.from(json, 'utf8')); ext = '.dam-pack.gz' }
|
|
7020
|
+
let finalPath = outPath
|
|
7021
|
+
if (!/\.dam-pack(\.gz)?$/i.test(finalPath)) finalPath = finalPath.replace(/[\\/]+$/, '') + path.sep + migrateSlugPre(ws) + ext
|
|
7022
|
+
await mkdir(path.dirname(finalPath), { recursive: true })
|
|
7023
|
+
const tmp = finalPath + '.tmp'
|
|
7024
|
+
await writeFile(tmp, payload)
|
|
7025
|
+
await rename(tmp, finalPath) // 原子落盘:写一半的包不会被认为是完整的
|
|
7026
|
+
return {
|
|
7027
|
+
ok: true, path: finalPath, format: MIGRATE_PACK_FORMAT_PRE,
|
|
7028
|
+
stats: { fileCount: built.pack.stats.fileCount, bytes: built.pack.stats.bytes, packedBytes: Buffer.byteLength(payload) },
|
|
7029
|
+
checksum: built.pack.checksum.value, compress, skipped,
|
|
7030
|
+
warnings: built.warnings,
|
|
7031
|
+
}
|
|
7032
|
+
}
|
|
7033
|
+
|
|
7034
|
+
/** 读包(自动识别 gzip:gzip 魔数 1f 8b)。★坏包在 validate 阶段即拒,不写任何文件。 */
|
|
7035
|
+
async _readPack(packPath) {
|
|
7036
|
+
const buf = await readFile(packPath).catch(() => null)
|
|
7037
|
+
if (!buf || !buf.length) return { ok: false, errors: ['pack-unreadable'] }
|
|
7038
|
+
let json = null
|
|
7039
|
+
try {
|
|
7040
|
+
json = (buf[0] === 0x1f && buf[1] === 0x8b)
|
|
7041
|
+
? nodeZlib.gunzipSync(buf).toString('utf8')
|
|
7042
|
+
: buf.toString('utf8')
|
|
7043
|
+
} catch (_) { return { ok: false, errors: ['pack-corrupt'] } }
|
|
7044
|
+
let pack = null
|
|
7045
|
+
try { pack = JSON.parse(json) } catch (_) { return { ok: false, errors: ['pack-not-json'] } }
|
|
7046
|
+
const v = validatePackPre(pack)
|
|
7047
|
+
if (!v.ok) return { ok: false, errors: v.errors, warnings: v.warnings }
|
|
7048
|
+
return { ok: true, pack, warnings: v.warnings }
|
|
7049
|
+
}
|
|
7050
|
+
|
|
7051
|
+
/** 预览:算出「导入将要发生什么」。★这是**唯一**能算差异的地方;import 会重算同一份计划。 */
|
|
7052
|
+
async migrateInspect(opts) {
|
|
7053
|
+
const o = opts || {}
|
|
7054
|
+
const r = await this._readPack(String(o.packPath || ''))
|
|
7055
|
+
if (!r.ok) return { ok: false, errors: r.errors }
|
|
7056
|
+
const targetWs = String(o.targetWs || r.pack.source.ws)
|
|
7057
|
+
let existing = {}
|
|
7058
|
+
try { existing = await this._readExistingWorkspaceFiles(targetWs) } catch (_) {}
|
|
7059
|
+
const plan = planImportPre(r.pack, { targetWs, existingFiles: existing, onConflict: o.onConflict, rewriteBody: o.rewriteBody })
|
|
7060
|
+
const { writeFiles, ...wire } = plan
|
|
7061
|
+
return {
|
|
7062
|
+
ok: true,
|
|
7063
|
+
pack: {
|
|
7064
|
+
format: r.pack.format, createdAt: r.pack.createdAt,
|
|
7065
|
+
source: r.pack.source, runtime: r.pack.runtime,
|
|
7066
|
+
stats: r.pack.stats, checksum: r.pack.checksum,
|
|
7067
|
+
},
|
|
7068
|
+
plan: wire,
|
|
7069
|
+
sourceMissing: !(await stat(this.projectDirOf(r.pack.source.ws)).catch(() => null)),
|
|
7070
|
+
warnings: r.warnings,
|
|
7071
|
+
}
|
|
7072
|
+
}
|
|
7073
|
+
|
|
7074
|
+
/** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary(任一步失败即中止并回报)。 */
|
|
7075
|
+
async migrateImport(opts) {
|
|
7076
|
+
const o = opts || {}
|
|
7077
|
+
const r = await this._readPack(String(o.packPath || ''))
|
|
7078
|
+
if (!r.ok) return { ok: false, error: 'pack-invalid', errors: r.errors }
|
|
7079
|
+
const targetWs = String(o.targetWs || r.pack.source.ws)
|
|
7080
|
+
if (!targetWs) return { ok: false, error: 'missing-target-ws' }
|
|
7081
|
+
const targetDir = this.projectDirOf(targetWs)
|
|
7082
|
+
let existing = {}
|
|
7083
|
+
try { existing = await this._readExistingWorkspaceFiles(targetWs) } catch (_) {}
|
|
7084
|
+
// ★重算(而非信任前端传来的 plan):防「预览与执行之间目标被改动」的 TOCTOU
|
|
7085
|
+
const plan = planImportPre(r.pack, { targetWs, existingFiles: existing, onConflict: o.onConflict, rewriteBody: o.rewriteBody })
|
|
7086
|
+
/** @type {{path:string, bytes:number, action:string}[]} */
|
|
7087
|
+
const written = []
|
|
7088
|
+
// S1:目标目录存在且非空 ⇒ 先整体备份(不可逆操作前的唯一保险)
|
|
7089
|
+
let backup = null
|
|
7090
|
+
const tst = await stat(targetDir).catch(() => null)
|
|
7091
|
+
const hasExisting = Object.keys(existing).length > 0
|
|
7092
|
+
if (tst && tst.isDirectory() && hasExisting) {
|
|
7093
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
7094
|
+
backup = targetDir + '.bak-' + stamp
|
|
7095
|
+
try { await this.copyDir(targetDir, backup) } catch (e) { return { ok: false, error: 'backup-failed', detail: String(e && e.message || e) } }
|
|
7096
|
+
}
|
|
7097
|
+
try {
|
|
7098
|
+
await mkdir(targetDir, { recursive: true })
|
|
7099
|
+
for (const [rel, text] of Object.entries(plan.writeFiles)) {
|
|
7100
|
+
const abs = path.join(targetDir, rel)
|
|
7101
|
+
await mkdir(path.dirname(abs), { recursive: true })
|
|
7102
|
+
const tmp = abs + '.dam-tmp'
|
|
7103
|
+
await writeFile(tmp, text, 'utf8')
|
|
7104
|
+
await rename(tmp, abs)
|
|
7105
|
+
written.push({ path: rel, bytes: Buffer.byteLength(text, 'utf8'), action: 'write' })
|
|
7106
|
+
}
|
|
7107
|
+
} catch (e) {
|
|
7108
|
+
return { ok: false, error: 'write-failed', detail: String(e && e.message || e), backup, written }
|
|
7109
|
+
}
|
|
7110
|
+
// S4:summary 用 merge 不用 replace —— 目标机的其它工作区记录必须原样保留
|
|
7111
|
+
let summaryAction = 'skipped'
|
|
7112
|
+
let summaryError = null
|
|
7113
|
+
try {
|
|
7114
|
+
const cur = await this.readTextSafe(this._wsSummaryFile())
|
|
7115
|
+
const merged = mergeSummaryRecordPre(cur ? JSON.parse(cur) : null, r.pack.summaryRecord, targetWs)
|
|
7116
|
+
if (merged.changed) {
|
|
7117
|
+
const f = this._wsSummaryFile()
|
|
7118
|
+
const tmp = f + '.dam-tmp'
|
|
7119
|
+
await writeFile(tmp, JSON.stringify(merged.summary, null, 2), 'utf8')
|
|
7120
|
+
await rename(tmp, f)
|
|
7121
|
+
}
|
|
7122
|
+
summaryAction = merged.action
|
|
7123
|
+
} catch (e) { summaryError = String(e && e.message || e) }
|
|
7124
|
+
// 索引失效:语义索引按工作区缓存,路径/内容变了必须让它重建(S5:索引本身不随包走)
|
|
7125
|
+
try { this._semanticIndexStaleByWorkspace = this._semanticIndexStaleByWorkspace || new Set(); this._semanticIndexStaleByWorkspace.add(targetWs) } catch (_) {}
|
|
7126
|
+
try { if (this.state) this.state.loadedAt = 0; await this.refresh(undefined) } catch (_) {}
|
|
7127
|
+
return {
|
|
7128
|
+
ok: true, targetWs, targetSlug: migrateSlugPre(targetWs), backup,
|
|
7129
|
+
pathChanged: plan.pathChanged, onConflict: plan.onConflict,
|
|
7130
|
+
written: written.length, writtenFiles: written.slice(0, 200),
|
|
7131
|
+
rewrite: { totalHits: plan.rewrite.totalHits, fileCount: plan.rewrite.fileCount, files: plan.rewrite.files.slice(0, 50) },
|
|
7132
|
+
additions: plan.additions.length, overwrites: plan.overwrites.length,
|
|
7133
|
+
summaryAction, summaryError,
|
|
7134
|
+
}
|
|
7135
|
+
}
|
|
6704
7136
|
/** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
|
|
6705
7137
|
async workspaceOverview(agent, force) {
|
|
6706
7138
|
const cacheFile = path.join(dshHome(), 'memory', 'workspaces-summary.json')
|
|
@@ -6858,6 +7290,45 @@ class MemoryEngine {
|
|
|
6858
7290
|
* **无事实正文、无路径**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回 null)。
|
|
6859
7291
|
* `protected > 0` 表示已轮到删「重要项」⇒ 上限设得过低,前端据此给告警色与指引。
|
|
6860
7292
|
*/
|
|
7293
|
+
/**
|
|
7294
|
+
* ★2026-09-22(用户拍板):诊断日志只读投影 —— 让「日志」在**界面里可查**,
|
|
7295
|
+
* 不必再去翻 PowerShell 或手动找目录。与既有最小投影同一纪律:
|
|
7296
|
+
* **只出计数/大小/时间戳/路径,不出日志正文**(正文可能含工作区内容)。
|
|
7297
|
+
* 形状:{ path, dir, sizeBytes, sizeText, historyBytes, historyPath, lines, exists, rotatedNow }
|
|
7298
|
+
* 消费点:lib/client.js 诊断页签(模块级渲染,不随刷新闪烁)。
|
|
7299
|
+
*/
|
|
7300
|
+
_logsViewSnapshot() {
|
|
7301
|
+
try {
|
|
7302
|
+
const f = diagLogFile()
|
|
7303
|
+
const bak = f + '.1'
|
|
7304
|
+
let sizeBytes = 0
|
|
7305
|
+
let exists = false
|
|
7306
|
+
try { const st = statSync(f); sizeBytes = st.size; exists = true } catch (e) {}
|
|
7307
|
+
let historyBytes = 0
|
|
7308
|
+
try { historyBytes = statSync(bak).size } catch (e) {}
|
|
7309
|
+
// 行数:只在文件不太大时数(避免每次开面板都全量扫 10MB+)
|
|
7310
|
+
let lines = null
|
|
7311
|
+
if (exists && sizeBytes <= DIAG_MAX_BYTES * 2) {
|
|
7312
|
+
try {
|
|
7313
|
+
const text = readFileSync(f, 'utf8')
|
|
7314
|
+
lines = text.length ? text.replace(/\r\n$/, '').split(/\r?\n/).length : 0
|
|
7315
|
+
} catch (e) {}
|
|
7316
|
+
}
|
|
7317
|
+
return {
|
|
7318
|
+
path: f,
|
|
7319
|
+
dir: path.dirname(f),
|
|
7320
|
+
sizeBytes,
|
|
7321
|
+
sizeText: fmtBytes(sizeBytes),
|
|
7322
|
+
historyBytes,
|
|
7323
|
+
historyPath: bak,
|
|
7324
|
+
lines,
|
|
7325
|
+
exists,
|
|
7326
|
+
rotatedNow: _diagRotated,
|
|
7327
|
+
note: '诊断日志(只读投影,不含正文)。崩溃/异常时请把这个路径下的文件附给维护者。',
|
|
7328
|
+
}
|
|
7329
|
+
} catch (e) { return { exists: false, error: String((e && e.message) || e) } }
|
|
7330
|
+
}
|
|
7331
|
+
|
|
6861
7332
|
_factsPruneViewSnapshot() {
|
|
6862
7333
|
try {
|
|
6863
7334
|
const st = this._factStore
|
|
@@ -6991,6 +7462,9 @@ class MemoryEngine {
|
|
|
6991
7462
|
// (只出聚合计数与时间戳,**无事实正文、无路径**)。此前 getLastPrune/retentionLimit
|
|
6992
7463
|
// 在双侧零调用方 ⇒「事实被悄悄淘汰了」在界面上无处可查。
|
|
6993
7464
|
factsPrune: this._factsPruneViewSnapshot(),
|
|
7465
|
+
// ★2026-09-22(用户拍板):诊断日志投影 —— 与既有最小投影同一出口、同一纪律。
|
|
7466
|
+
// 回答:「日志在哪、多大、多少行」——用户不必再翻 PowerShell 或手动找目录。
|
|
7467
|
+
logs: this._logsViewSnapshot(),
|
|
6994
7468
|
runtimes: this.runtimes.values().map((rt) => ({
|
|
6995
7469
|
key: rt.key,
|
|
6996
7470
|
sessionId: rt.sessionId,
|
|
@@ -9205,8 +9679,11 @@ export function apply(ctx, config) {
|
|
|
9205
9679
|
// 新bug修复①·启动预热(2026-09-08):重启后首轮 renderMemoryDynamic 可能先于 _doRefresh 完成,
|
|
9206
9680
|
// 此时 globalLedgerPath 尚未就位,兜底行会错过它要救的那一轮。挂载即异步预热全局账本缓存(只读扫描,零副作用)。
|
|
9207
9681
|
try { void engine.findLatestGlobalHandoff().then((v) => { if (!engine._globalLedgerCache) engine._globalLedgerCache = { at: Date.now(), value: v || '' } }).catch(() => {}) } catch (e) {}
|
|
9682
|
+
engine._recallStats = createRecallStatsPre({
|
|
9683
|
+
file: () => path.join(dshHome(), 'memory', 'recall-stats.json'),
|
|
9684
|
+
})
|
|
9208
9685
|
// M4-3:Shadow Retrieval Host 接线(三开关全开时才构造状态/IO;默认关闭零留存)
|
|
9209
|
-
engine._shadowHost = createShadowHost({ engine })
|
|
9686
|
+
engine._shadowHost = createShadowHost({ engine, onDiag: (key, msg) => diagThrottled(key, msg), recallStats: engine._recallStats })
|
|
9210
9687
|
// M5-3:Context Bridge Host 接线(assoc+contextBridge 双门;默认关闭零构造/零 IO)
|
|
9211
9688
|
engine._contextHost = createContextHost({ engine })
|
|
9212
9689
|
// R3(2026-09-18):降级台账。挂在引擎上,与各 host 同层 —— 跨臂统一,不分散到各 host。
|
|
@@ -9218,10 +9695,25 @@ export function apply(ctx, config) {
|
|
|
9218
9695
|
// 注意:**绝不能**把常规观测塞进 `_degradeSink.record` —— 台账判据是「只记预期外失败」,
|
|
9219
9696
|
// 混入后「有没有降级」将永远非空,降级信号被淹没。
|
|
9220
9697
|
engine._quotaProbe = createQuotaProbePre({})
|
|
9698
|
+
// ③ 召回统计(2026-09-22 用户拍板「先只记录、不加权」)—— 数据源给面板「统计」页签。
|
|
9699
|
+
// ★为什么只记录:加权会自我强化(召回多→权重高→更易被召回),在埋点口径被真实数据检验前加权
|
|
9700
|
+
// 会把口径错误放大成系统性偏差。故本模块**只累积计数并落盘**,召回排序与返回逐字节不变
|
|
9701
|
+
// (守卫 smoke-test-recall-stats-pre.mjs 断言:埋点调用不改任何排序输入)。
|
|
9702
|
+
// file 传**函数**而非字符串:DSH_HOME 可能在运行期变化,每次落盘重新解析路径。
|
|
9703
|
+
|
|
9221
9704
|
// 注:臂健康快照(deriveArmsHealthPre)在各臂执行路径上按实际情况标注,
|
|
9222
9705
|
// 见 recall() 内 semantic/evidence 段与 _l0IndexSync 段。
|
|
9223
9706
|
// M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
|
|
9224
9707
|
engine._activationHost = createActivationHost({ engine })
|
|
9708
|
+
// ★issue#104(2026-09-22 移植自 PR #118 / 孤儿快照 475abfe):把两个 host **回填给 runtime 释放路径**。
|
|
9709
|
+
// `SessionRuntimeStore.dispose()` 里按 `this._shadowHost` / `this._activationHost` 清理 per-runtime 态,
|
|
9710
|
+
// 而这两个属性只挂在 engine 上 ⇒ 不回填则两处 `if` 恒假、清理恒不执行(激活态与 shadow 态随会话数单调增长)。
|
|
9711
|
+
// runtimes 在引擎构造期创建,早于各 host ⇒ 只能在此处回填。
|
|
9712
|
+
// ★回归警示:3.0.1 发布提交 53d20e7 曾整行删掉 activation 侧的回填,而旧测试只断言 dispose
|
|
9713
|
+
// 函数体里的**字符串** ⇒ 静默回退。现由 smoke-test-issue103-105-portfix-pre.mjs 的「消费侧行为」
|
|
9714
|
+
// 断言 + 本处「生产侧成对」断言双锁住守,删任一侧必红。
|
|
9715
|
+
engine.runtimes._shadowHost = engine._shadowHost
|
|
9716
|
+
engine.runtimes._activationHost = engine._activationHost
|
|
9225
9717
|
// M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
|
|
9226
9718
|
engine._indexSyncHost = createIndexSyncHostPre({
|
|
9227
9719
|
engine,
|
|
@@ -9270,6 +9762,9 @@ export function apply(ctx, config) {
|
|
|
9270
9762
|
const factStore = createFactStorePre({ config: { maxFacts: Number(engine.config.factRetentionMax) || 1000 }, io: hubIo('facts.json') })
|
|
9271
9763
|
engine._factStore = factStore
|
|
9272
9764
|
engine._memoryHub = createMemoryHubPre({
|
|
9765
|
+
// issue #110:把 io 工厂的批控制交给 hub 本身 ⇒ 所有批量喂数入口(喂数循环、HTTP
|
|
9766
|
+
// `action=feed`、将来的重放器)都自动「一批一次落盘」,不必各自记得在外面 begin/end。
|
|
9767
|
+
batch: hubIo,
|
|
9273
9768
|
stores: {
|
|
9274
9769
|
episodic: createEpisodicStorePre({ config: { minSegments: Number(engine.config.episodicMinSegments) || 2, retention: Number(engine.config.episodicRetention) || 256 }, io: hubIo('episodes.json') }),
|
|
9275
9770
|
facts: factStore,
|
|
@@ -9328,7 +9823,15 @@ export function apply(ctx, config) {
|
|
|
9328
9823
|
// 写回:P1④ 主闭环——hub.facts 中 confirmed/未过期/未撤销/置信≥0.6 的事实,每日限额
|
|
9329
9824
|
// 治理式写入 notesPath/userFile(autoConsolidate 同款 appendText 原子事务)→ 进 M7 语料。
|
|
9330
9825
|
{
|
|
9331
|
-
const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs
|
|
9826
|
+
const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs}
|
|
9827
|
+
// ★issue#103(2026-09-22 移植自 PR #119):环形 JSONL 的增量游标 —— **内容指纹**,替代原来的行数游标。
|
|
9828
|
+
// 为什么不能用行数:该文件由 Python 侧按「保尾丢弃」维护(SHADOW_LOG_MAX=256,每次重写为
|
|
9829
|
+
// lines[-256:])⇒ 写满后 lines.length 恒等于上次的 count,增量区间恒为空 ⇒ 消费端永久停摆,
|
|
9830
|
+
// 且因为「没有新行」是合法状态而**不留任何痕迹**、事后不可发现。
|
|
9831
|
+
// maxSeen 取环形上限 256 的 4 倍,避免指纹被淘汰后旧行被重复交付。
|
|
9832
|
+
// ★本次移植实测教训:锚点不匹配会让整块补丁被静默跳过,而 `node --check` 与静态守卫**都不会报**
|
|
9833
|
+
// (引用在另一处)⇒ 运行期才炸 ReferenceError。故补丁脚本必须配「声明与使用成对」的哨兵断言。
|
|
9834
|
+
const hubFeedCursor = createJsonlTailCursorPre({ maxSeen: 1024 })
|
|
9332
9835
|
const hubFlushState = { date: '', count: 0, flushed: {} } // flushed[factId]=true;持久化 hub/flush-state.json
|
|
9333
9836
|
const hubFlushFile = () => path.join(dshHome(), 'memory', 'hub', 'flush-state.json')
|
|
9334
9837
|
const hubFlushLoad = () => {
|
|
@@ -9374,15 +9877,18 @@ export function apply(ctx, config) {
|
|
|
9374
9877
|
const f = path.join(dshHome(), 'memory', 'semantic', 'judgement-shadow.jsonl')
|
|
9375
9878
|
let st
|
|
9376
9879
|
try { st = statSync(f) } catch (_) { return }
|
|
9377
|
-
const prev = hubFeedState.shadow
|
|
9378
|
-
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) return
|
|
9379
9880
|
const lines = readFileSync(f, 'utf8').split('\n').filter((l) => l.trim())
|
|
9380
|
-
|
|
9881
|
+
// ★issue#103(2026-09-22 移植自 PR #119):游标**不能再用行数**。该文件由 Python 侧按
|
|
9882
|
+
// 「保尾丢弃」维护(SHADOW_LOG_MAX=256,每次重写为 lines[-256:])⇒ 写满后 lines.length 恒等于
|
|
9883
|
+
// 上次的 count,增量区间恒为空、消费端永久停摆,而且"没有新行"是合法状态、不留任何痕迹。
|
|
9884
|
+
// 改为行内容指纹:环内每行只交付一次,与截断无关。(首次 take() 只登记现状不喂历史行,
|
|
9885
|
+
// 避免重启后把环里旧判据再吃一遍导致 observe 重复计数。)
|
|
9886
|
+
const cursor = (hubFeedCursor.take(lines).fresh)
|
|
9381
9887
|
const newRows = []
|
|
9382
|
-
for (let i =
|
|
9383
|
-
try { const d = JSON.parse(
|
|
9888
|
+
for (let i = 0; i < cursor.length; i++) {
|
|
9889
|
+
try { const d = JSON.parse(cursor[i]); if (d && d.kindCandidate) newRows.push(d) } catch (_) {}
|
|
9384
9890
|
}
|
|
9385
|
-
hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs
|
|
9891
|
+
hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs }
|
|
9386
9892
|
if (!newRows.length) return
|
|
9387
9893
|
// 异步富化+喂送(不阻塞 tick);单行失败静默
|
|
9388
9894
|
void (async () => {
|
|
@@ -9989,6 +10495,9 @@ export function apply(ctx, config) {
|
|
|
9989
10495
|
try { if (engine._ownSubagents && engine._ownSubagents.has(agent)) return } catch (e) {}
|
|
9990
10496
|
// 最后活动时间写入该 agent 自己的 runtime(不再落到 currentRuntime()/default)
|
|
9991
10497
|
try { engine.runtimeFor(agent).lastActiveAt = Date.now() } catch (e) {}
|
|
10498
|
+
// ★2026-09-22(用户拍板):新工作区自动首建白板骨架 —— 只写一次、fail-soft、不阻塞收尾。
|
|
10499
|
+
// 放在水位测量之前:首建是"产物存在性"兜底,与水位阈值无关,新工作区首轮就该落下来。
|
|
10500
|
+
try { void engine.ensurePlanBoardForAgentPre(agent).catch(function () {}) } catch (eSeed) {}
|
|
9992
10501
|
// M-CM4 水位感知:每轮测量会话消息体量,越阈值→advisory+自动骨架账本(异步,不阻塞收尾)
|
|
9993
10502
|
try { void engine.checkWaterLevel(agent).then(function () {
|
|
9994
10503
|
// M-CM6-C 宿主兜底:轮末测量完成后若达标 → arm 倒计时(浏览器活着时由它展示确认卡;被节流/关闭则宿主到期自执行)
|
|
@@ -10250,7 +10759,7 @@ export function apply(ctx, config) {
|
|
|
10250
10759
|
return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
|
|
10251
10760
|
}),
|
|
10252
10761
|
|
|
10253
|
-
defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命;**账本/白板内容要落进面板看板泳道,须在标题或正文写 tag**:type:goal / type:state / type:dead-end / type:progress。kind=plan
|
|
10762
|
+
defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。kind=handoff:写一篇四段式交接账本(任务状态/目标/已试方案与失败原因/进度与下一步)到 handoff/,阶段产出或方向变化时用,给下一个上下文窗口续命;**账本/白板内容要落进面板看板泳道,须在标题或正文写 tag**:type:goal / type:state / type:dead-end / type:progress。kind=plan:整体写入/重写白板 PLAN.md(人能读的项目全貌规划图)。**首建亦可**——工作区若还没有白板,宿主已自动落了一版骨架,你理解全貌后直接整体重写成真内容即可;对项目全貌的理解发生实质变化时同样重写。旧版自动归档。**别把骨架留在那里当白板**。**结论失效时的两个通道(不要混用)**:supersedes=被更新结论取代(有后继);retract=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
|
|
10254
10763
|
content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
|
|
10255
10764
|
action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
|
|
10256
10765
|
kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
|
|
@@ -10465,12 +10974,47 @@ export function apply(ctx, config) {
|
|
|
10465
10974
|
title: { type: 'string', required: true, description: '事项标题。' },
|
|
10466
10975
|
}, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
|
|
10467
10976
|
|
|
10977
|
+
defineTool('memory_rules', '用户级硬性约束(每轮无条件注入的 [规则] 段,真源 ~/.dsh/memory/MEMORY.md)的**条目级增删改**。op=list 列出全部条目(0 基序号/来源日期段/文本);op=add 追加;op=update 改写一条;op=remove **真删**一条。★**删/改必须带 expect**(=该条**当前**文本,从 list 原样回填),不符即拒 —— 防索引漂移删错行。★这段**每轮注入且不参与裁剪**,所以它同时是 prompt 成本的主要来源:清理过时条目的首选手段就是本工具的 remove(而不是把整篇用户级记忆用 memory_user replace 重写)。★status 被标 superseded/retracted 的条目自 P9 起**不再进规则段**,但仍留在 list 里(要彻底消失用 remove;要软失效用 memory_note 的 retract/supersedes)。**调用后必须在本轮回复正文中向用户转述改了什么**。', {
|
|
10978
|
+
op: { type: 'string', enum: ['list', 'add', 'update', 'remove'], required: true, description: 'list=列出; add=追加; update=改写一条(需 index+expect+content); remove=真删一条(需 index+expect)。' },
|
|
10979
|
+
index: { type: 'number', description: '条目序号(0 基,来自 op=list 的顺序)。' },
|
|
10980
|
+
expect: { type: 'string', description: '该条**当前**文本(内容锚定,防索引漂移)。update/remove 必填。' },
|
|
10981
|
+
content: { type: 'string', description: 'update/add 的新文本(单行,≤2000 字符)。' },
|
|
10982
|
+
dateSection: { type: 'string', description: 'add 可选:追加到指定日期段(YYYY-MM-DD);缺省追加到文件头部(用户手写区)。' },
|
|
10983
|
+
}, async (args) => {
|
|
10984
|
+
const op = String(args.op || '')
|
|
10985
|
+
if (op === 'list') {
|
|
10986
|
+
const r0 = await applyRuleEditPre(engine, 'list', {})
|
|
10987
|
+
if (!r0.ok) return 'memory_rules: ' + r0.error
|
|
10988
|
+
const rows = (r0.items || []).map((x, i) => '[' + i + '] (' + x.source + (x.dateSection ? ' ' + x.dateSection : '') + ') ' + x.text)
|
|
10989
|
+
let extra = ''
|
|
10990
|
+
try {
|
|
10991
|
+
const lay = extractRulesLayerPre({ userText: await engine.readTextSafe(r0.path), rulesLayeringMode: 'self' })
|
|
10992
|
+
const n = (lay.retired || []).length
|
|
10993
|
+
if (n) extra = '\\n注:其中 ' + n + ' 条 status≠current,**已不再进规则段**(仍可用 remove 真删)。'
|
|
10994
|
+
} catch (_) {}
|
|
10995
|
+
return '用户级硬约束共 ' + rows.length + ' 条(0 基):\\n' + rows.join('\\n') + extra
|
|
10996
|
+
}
|
|
10997
|
+
if (op === 'add') {
|
|
10998
|
+
const gate = sanitizeForWrite(String(args.content || ''), { maxEntryChars: 2000 })
|
|
10999
|
+
if (!gate.ok) return 'memory_rules: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
11000
|
+
const ra = await applyRuleEditPre(engine, 'add', { text: gate.clean || String(args.content || ''), dateSection: args.dateSection })
|
|
11001
|
+
if (!ra.ok) return 'memory_rules: ' + ra.error
|
|
11002
|
+
return '已追加规则,现共 ' + (ra.items || []).length + ' 条。\\n' + ra.preview
|
|
11003
|
+
}
|
|
11004
|
+
const r = await applyRuleEditPre(engine, op, { index: args.index, expect: args.expect, text: args.content }, { requireExpect: true })
|
|
11005
|
+
if (!r.ok) return 'memory_rules: ' + r.error
|
|
11006
|
+
return (op === 'remove' ? '已删除' : '已改写') + '第 ' + args.index + ' 条,现共 ' + (r.items || []).length + ' 条。\\n' + r.preview
|
|
11007
|
+
}),
|
|
11008
|
+
|
|
10468
11009
|
defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。★**与「自动沉淀」的边界**:每轮对话结束框架会**自动**评估并写日志/升格要点,**你不需要为日常轮次做这件事**;本工具是**你主动发起的加料**——隔一段时间(或一个阶段收尾时)用来做一次更彻底的提炼,读的是**多日日志**、输出进项目笔记与用户级记忆。⇒ 用它做「阶段性固化」,不要用它替代每轮的 memory_log。', {
|
|
10469
11010
|
days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
|
|
10470
11011
|
}, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
|
|
10471
11012
|
|
|
10472
11013
|
// ★★★ T4(2026-09-19 用户拍板「让大模型来介入 procedure memory」)★★★
|
|
10473
|
-
// **工具数
|
|
11014
|
+
// **工具数 17→18**(无条件注册;P9 新增 memory_rules)。★硬锁联动**六处**(原注释只写四处,
|
|
11015
|
+
// 实测还漏了 p23-wb-sidecar 与 docs/HANDBOOK.md §6 表 ⇒ 加工具时一并改):
|
|
11016
|
+
// tests/smoke/smoke-test.mjs / m3b3-pre / context-observer / graph-mode-pre / p23-wb-sidecar-pre / docs/HANDBOOK.md。
|
|
11017
|
+
// 另有发版侧两表必然漏登记(memory_rules,见 tools/release.mjs 的 T7-e 注与 smoke-test-t7e)。
|
|
10474
11018
|
// 背景:此前三条记忆线(episodic / semantic / procedural)**全部只有机械生成**,
|
|
10475
11019
|
// 模型没有任何写入通路。procedural 线的唯一来源是 `memory-hub.js` 的 crossFeed:
|
|
10476
11020
|
// 它把每个"成功 episode"机械切成候选,而 episode 的 actions 就是 `['user','user','user']`
|
|
@@ -11050,7 +11594,24 @@ export function apply(ctx, config) {
|
|
|
11050
11594
|
const body = await readJsonBody(req).catch(() => ({}))
|
|
11051
11595
|
const action = String((body && body.action) || '')
|
|
11052
11596
|
if (action === 'consolidate') return writeJson(res, 200, hub.consolidateEpisodes())
|
|
11053
|
-
if (action === 'feed')
|
|
11597
|
+
if (action === 'feed') {
|
|
11598
|
+
// ★#110(2026-09-22):HTTP feed 入口也要走批内合并落盘 —— 旧实现直调 ingestJudgementRows,
|
|
11599
|
+
// 每行一次 upsert 就是一次整份快照写盘(N 行 = N 次写放大)。批控制与 hub 喂数循环同源,
|
|
11600
|
+
// 挂在 engine._hubIoFactory;批末一定 flush(finally),任何行抛错也不会把已接受的改动留在内存。
|
|
11601
|
+
const rows = Array.isArray(body.rows) ? body.rows : []
|
|
11602
|
+
const ioFactory = engine._hubIoFactory
|
|
11603
|
+
let fedResult
|
|
11604
|
+
try {
|
|
11605
|
+
if (ioFactory && typeof ioFactory.beginBatch === 'function') ioFactory.beginBatch()
|
|
11606
|
+
fedResult = hub.ingestJudgementRows(rows)
|
|
11607
|
+
} finally {
|
|
11608
|
+
if (ioFactory && typeof ioFactory.endBatch === 'function') {
|
|
11609
|
+
const b = ioFactory.endBatch()
|
|
11610
|
+
if (b && b.ok === false) diag('hub feed(http) batch: 有快照落盘失败 —— 原因见 hubIo 健康度(debugInfo().associativeMemory.hubIo)')
|
|
11611
|
+
}
|
|
11612
|
+
}
|
|
11613
|
+
return writeJson(res, 200, fedResult)
|
|
11614
|
+
}
|
|
11054
11615
|
if (action === 'render') return writeJson(res, 200, { checklists: hub.renderChecklists() })
|
|
11055
11616
|
if (action === 'crossfeed') return writeJson(res, 200, hub.crossFeed(String((body && body.sessionRef) || '')))
|
|
11056
11617
|
// M9 审批动作(2026-08-30,G-02 同款 loopback+append-only 精神):用户在 hubTab
|
|
@@ -11262,6 +11823,46 @@ export function apply(ctx, config) {
|
|
|
11262
11823
|
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11263
11824
|
},
|
|
11264
11825
|
},
|
|
11826
|
+
{
|
|
11827
|
+
kind: 'exact',
|
|
11828
|
+
path: API['migrate-export'],
|
|
11829
|
+
handler: async (req, res) => {
|
|
11830
|
+
// 迁移搬包 P1:导出。loopback-only(记忆含隐私,绝不对外)。
|
|
11831
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11832
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11833
|
+
const body = await readJsonBody(req).catch(() => ({}))
|
|
11834
|
+
try {
|
|
11835
|
+
const ws = String((body && body.ws) || (engine._lastAgent && engine._lastAgent.cwd) || '')
|
|
11836
|
+
writeJson(res, 200, await engine.migrateExport({ ws, outPath: body && body.outPath, compress: !(body && body.compress === false) }))
|
|
11837
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11838
|
+
},
|
|
11839
|
+
},
|
|
11840
|
+
{
|
|
11841
|
+
kind: 'exact',
|
|
11842
|
+
path: API['migrate-inspect'],
|
|
11843
|
+
handler: async (req, res) => {
|
|
11844
|
+
// 迁移搬包 P2:预览差异(只读,不写任何文件)。
|
|
11845
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11846
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11847
|
+
const body = await readJsonBody(req).catch(() => ({}))
|
|
11848
|
+
try {
|
|
11849
|
+
writeJson(res, 200, await engine.migrateInspect({ packPath: body && body.packPath, targetWs: body && body.targetWs, onConflict: body && body.onConflict, rewriteBody: !(body && body.rewriteBody === false) }))
|
|
11850
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11851
|
+
},
|
|
11852
|
+
},
|
|
11853
|
+
{
|
|
11854
|
+
kind: 'exact',
|
|
11855
|
+
path: API['migrate-import'],
|
|
11856
|
+
handler: async (req, res) => {
|
|
11857
|
+
// 迁移搬包 P3:执行(先备份后落盘;冲突默认 keep)。
|
|
11858
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11859
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11860
|
+
const body = await readJsonBody(req).catch(() => ({}))
|
|
11861
|
+
try {
|
|
11862
|
+
writeJson(res, 200, await engine.migrateImport({ packPath: body && body.packPath, targetWs: body && body.targetWs, onConflict: body && body.onConflict, rewriteBody: !(body && body.rewriteBody === false) }))
|
|
11863
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11864
|
+
},
|
|
11865
|
+
},
|
|
11265
11866
|
{
|
|
11266
11867
|
kind: 'exact',
|
|
11267
11868
|
path: API.debug,
|
|
@@ -11338,6 +11939,28 @@ export function apply(ctx, config) {
|
|
|
11338
11939
|
},
|
|
11339
11940
|
},
|
|
11340
11941
|
{
|
|
11942
|
+
kind: 'exact',
|
|
11943
|
+
path: API.recallStats,
|
|
11944
|
+
handler: async (req, res) => {
|
|
11945
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11946
|
+
const method = req.method || 'GET'
|
|
11947
|
+
// 只读:GET 取快照;POST 仅用于显式复位(面板按钮),同样 loopback-only。
|
|
11948
|
+
if (method !== 'GET' && method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11949
|
+
try {
|
|
11950
|
+
if (!engine._recallStats) return writeJson(res, 200, { enabled: false })
|
|
11951
|
+
if (method === 'POST') {
|
|
11952
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
11953
|
+
if (url.searchParams.get('reset') === '1') {
|
|
11954
|
+
engine._recallStats.reset()
|
|
11955
|
+
return writeJson(res, 200, { ok: true, reset: true })
|
|
11956
|
+
}
|
|
11957
|
+
return writeJson(res, 400, { error: 'unknown action' })
|
|
11958
|
+
}
|
|
11959
|
+
writeJson(res, 200, { enabled: true, stats: engine._recallStats.snapshot() })
|
|
11960
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11961
|
+
},
|
|
11962
|
+
},
|
|
11963
|
+
{
|
|
11341
11964
|
kind: 'exact',
|
|
11342
11965
|
path: API.updateCheck,
|
|
11343
11966
|
handler: async (req, res) => {
|
|
@@ -11384,7 +12007,15 @@ export function apply(ctx, config) {
|
|
|
11384
12007
|
const method = req.method || 'GET'
|
|
11385
12008
|
try {
|
|
11386
12009
|
if (method === 'GET') {
|
|
11387
|
-
|
|
12010
|
+
// ★P10-T1(2026-09-22):把「可关的注入分区」清单**从宿主常量发给前端**——设置页
|
|
12011
|
+
// 二级页据此枚举开关,前端不得硬编码第二份清单(守卫断言集合相等)。
|
|
12012
|
+
// 搭本路由的便车而非新开路由:避免 API 路径锁(A1/A2/A4)三处同步的额外风险。
|
|
12013
|
+
writeJson(res, 200, {
|
|
12014
|
+
config: await engine.loadConfig(),
|
|
12015
|
+
path: engine._configPath,
|
|
12016
|
+
promptSections: PROMPT_SECTION_KEYS_V1,
|
|
12017
|
+
promptSectionMust: PROMPT_SECTION_MUST_V1,
|
|
12018
|
+
})
|
|
11388
12019
|
return
|
|
11389
12020
|
}
|
|
11390
12021
|
if (method === 'POST' || method === 'PUT') {
|
|
@@ -11403,6 +12034,22 @@ export function apply(ctx, config) {
|
|
|
11403
12034
|
patch[key] = body[key].map((x) => x.trim()).filter(Boolean)
|
|
11404
12035
|
continue
|
|
11405
12036
|
}
|
|
12037
|
+
// ★#117 P3-20②(2026-09-22):路径类键 fail-closed —— 旧实现对绝大多数键裸赋值,
|
|
12038
|
+
// 而 memoryRoot / userMemoryDir 就在 DEFAULT_CONFIG 键集内 ⇒ 一次 POST 可把后续
|
|
12039
|
+
// append/rewrite 的落点移出记忆区。判据:非空字符串 + 展开后必须落在 dshHome() 之下。
|
|
12040
|
+
// 注意:① 必须先经 expandUserPath 展开(默认值本身就带 `~`,否则默认值过不了自己的闸);
|
|
12041
|
+
// ② 用 path.relative 判,纯 startsWith 会漏放行 `~/.dsh/memory-backup` 这类前缀兄弟目录。
|
|
12042
|
+
// 通过校验后仍存**原样**(保持 DEFAULT_CONFIG 的 `~` 形书写约定),与 injectExcludeSources 同款 fail-closed 丢弃。
|
|
12043
|
+
if (key === 'memoryRoot' || key === 'userMemoryDir') {
|
|
12044
|
+
const rawPath = body[key]
|
|
12045
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) continue
|
|
12046
|
+
const expanded = engine.expandUserPath(rawPath)
|
|
12047
|
+
if (typeof expanded !== 'string' || !path.isAbsolute(expanded)) continue
|
|
12048
|
+
const rel = path.relative(dshHome(), expanded)
|
|
12049
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
12050
|
+
patch[key] = rawPath
|
|
12051
|
+
continue
|
|
12052
|
+
}
|
|
11406
12053
|
patch[key] = body[key]
|
|
11407
12054
|
}
|
|
11408
12055
|
const saved = await engine.saveConfig(patch)
|
|
@@ -11472,22 +12119,11 @@ export function apply(ctx, config) {
|
|
|
11472
12119
|
const op = String((body && body.op) || '')
|
|
11473
12120
|
if (!['add', 'update', 'remove'].includes(op)) return writeJson(res, 400, { error: 'invalid-op' })
|
|
11474
12121
|
try {
|
|
11475
|
-
|
|
11476
|
-
|
|
11477
|
-
|
|
11478
|
-
if (
|
|
11479
|
-
|
|
11480
|
-
else r = removeRuleItemPre(before, Number(body.index))
|
|
11481
|
-
if (!r.ok) return writeJson(res, 400, { error: '编辑被拒: ' + r.error })
|
|
11482
|
-
// ★ 复用既有事务(备份 + 校验 + anchor 处理),绝不绕过
|
|
11483
|
-
const written = await engine.writeFull(p.userFile, r.text)
|
|
11484
|
-
const after = (await engine.readTextSafe(p.userFile)) || written || r.text
|
|
11485
|
-
const items = listRuleItemsPre(after)
|
|
11486
|
-
writeJson(res, 200, {
|
|
11487
|
-
result: op + ' ok',
|
|
11488
|
-
items,
|
|
11489
|
-
preview: items.map((x) => '- ' + x.text).join('\n'),
|
|
11490
|
-
})
|
|
12122
|
+
// ★P9:与模型工具 memory_rules 共用同一写盘口(requireExpect 保持 false ——
|
|
12123
|
+
// R7 的设计是「前端做二次确认,host 侧只如实执行」,不在此处引入第二套语义)
|
|
12124
|
+
const r = await applyRuleEditPre(engine, op, { index: body.index, text: body.text, dateSection: body.dateSection })
|
|
12125
|
+
if (!r.ok) return writeJson(res, r.error === 'invalid-op' ? 400 : 400, { error: r.error })
|
|
12126
|
+
writeJson(res, 200, { result: op + ' ok', items: r.items, preview: r.preview })
|
|
11491
12127
|
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11492
12128
|
},
|
|
11493
12129
|
},
|
|
@@ -11682,7 +12318,7 @@ export function apply(ctx, config) {
|
|
|
11682
12318
|
pid: process.pid,
|
|
11683
12319
|
heartbeatAt: Date.now(),
|
|
11684
12320
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
11685
|
-
queueLength: q
|
|
12321
|
+
queueLength: q,
|
|
11686
12322
|
lastConsolidatedAt: (engine.autoStats && engine.autoStats.lastAt) || 0,
|
|
11687
12323
|
todayCount: (engine.autoStats && engine.autoStats.count) || 0,
|
|
11688
12324
|
}, null, 2), 'utf8')
|
|
@@ -11749,3 +12385,5 @@ export function apply(ctx, config) {
|
|
|
11749
12385
|
|
|
11750
12386
|
/** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
|
|
11751
12387
|
export { sanitizeForWrite, hygieneGateForPrimitive, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession, attachmentsOfContent, attachmentBlobPathsPre, renderAttachmentLinesPre }
|
|
12388
|
+
/** 导出会话运行态仓库(issue#104):让 dispose 的清理接线能被行为测试直接驱动,不必靠断言源码字符串。 */
|
|
12389
|
+
export { SessionRuntimeStore }
|