@a9i5k4/dsh-auto-memory 3.1.0 → 3.1.3
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/CONTRIBUTORS.html +2 -2
- package/docs/HANDBOOK.md +13 -9
- package/lib/client.js +786 -134
- 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 +937 -51
- 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 +431 -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/lib/subagent-gc.js +5 -1
- package/lib/wb-sidecar.js +8 -3
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { createReadStream, existsSync, readdirSync, readFileSync, writeFileSync,
|
|
|
22
22
|
import { exec as cpExec } from 'node:child_process'
|
|
23
23
|
import { promisify } from 'node:util'
|
|
24
24
|
const execP = promisify(cpExec)
|
|
25
|
-
import { homedir } from 'node:os'
|
|
25
|
+
import { homedir, hostname as hostnameOf } from 'node:os'
|
|
26
26
|
import { randomUUID } from 'node:crypto'
|
|
27
27
|
import path from 'node:path'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
@@ -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, calendarMergePre, 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',
|
|
@@ -199,6 +254,8 @@ export const API = {
|
|
|
199
254
|
'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
|
|
200
255
|
'handoff-state': '/api/dsh-auto-memory/handoff-state',
|
|
201
256
|
'kanban-board': '/api/dsh-auto-memory/kanban-board',
|
|
257
|
+
// ★v3.1.2:卡片全文按需取(看板载荷不再内联 full ⇒ 展开全文时单取一张)。
|
|
258
|
+
'kanban-card': '/api/dsh-auto-memory/kanban-card',
|
|
202
259
|
'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
|
|
203
260
|
'handoff-permission': '/api/dsh-auto-memory/handoff-permission',
|
|
204
261
|
'auto-continue-state': '/api/dsh-auto-memory/auto-continue-state',
|
|
@@ -211,6 +268,9 @@ export const API = {
|
|
|
211
268
|
'memory-hub': '/api/dsh-auto-memory/memory-hub',
|
|
212
269
|
'storage-manage': '/api/dsh-auto-memory/storage-manage',
|
|
213
270
|
models: '/api/dsh-auto-memory/models',
|
|
271
|
+
'migrate-export': '/api/dsh-auto-memory/migrate-export',
|
|
272
|
+
'migrate-inspect': '/api/dsh-auto-memory/migrate-inspect',
|
|
273
|
+
'migrate-import': '/api/dsh-auto-memory/migrate-import',
|
|
214
274
|
}
|
|
215
275
|
|
|
216
276
|
/**
|
|
@@ -298,6 +358,12 @@ const DEFAULT_CONFIG = {
|
|
|
298
358
|
/** Tier-0 目录最多占「注入预算」的比例(默认 **0.25**):目录是索引层,不得把证据层(日志/笔记)挤空。
|
|
299
359
|
* 与 `tier0MaxTokens` 取小生效;另在注入侧再封顶一次(见 renderMemoryDynamic 的 catalogCost)。 */
|
|
300
360
|
tier0BudgetShare: 0.25,
|
|
361
|
+
/** ★P10(2026-09-22 用户拍板):**注入分区开关**(对象:kind → boolean)。
|
|
362
|
+
* **缺省 = 全开**(只有显式 false 才关)⇒ 出厂行为逐字节不变、可一键回退。
|
|
363
|
+
* 可关清单 = PROMPT_SECTION_KEYS_V1;关掉某段即该段不再进入本轮注入。
|
|
364
|
+
* 设计取向(用户原话:不是为了优化,是让看不惯的用户能自己关):**这是用户主权开关,
|
|
365
|
+
* 不是性能门** —— 因此不做任何"自动判断该不该关"的启发式。 */
|
|
366
|
+
promptSectionToggles: {},
|
|
301
367
|
/** 群反馈第 4 条 / P0-④d:**注入来源排除**(坏记忆不再反复灌入)。
|
|
302
368
|
* 字符串数组,四种写法(写死口径,可测;只做精确/前缀匹配,不做模糊猜测):
|
|
303
369
|
* · 记忆 id:`mem_<32hex>` → 排除该条命中
|
|
@@ -537,6 +603,8 @@ const DEFAULT_CONFIG = {
|
|
|
537
603
|
*/
|
|
538
604
|
l0IndexEnabled: true,
|
|
539
605
|
/** pre-step 软注入。 */
|
|
606
|
+
/** ★v3.1.3 审计标注:本键当前**无消费者**(全 lib/ 零读取点、设置页零控件)——
|
|
607
|
+
* 属保留字段(不删,避免历史配置反序列化告警)。若将来启用,请同时补设置页控件与守卫。 */
|
|
540
608
|
softInjectionEnabled: false,
|
|
541
609
|
/** M7.5:分支/子代理会话是否也纳入上下文观测(shadow 观测零注入风险;2026-08-26 裁定默认开:开源模型为主,思维链/分支是主要观测面)。 */
|
|
542
610
|
contextBridgeObserveChildSessions: true,
|
|
@@ -626,7 +694,7 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
626
694
|
snapshotReflectionTitle: '最近反思 {date}(前一天工作精华)',
|
|
627
695
|
snapshotUserTitle: '用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守',
|
|
628
696
|
snapshotNotesTitle: '项目长期笔记',
|
|
629
|
-
snapshotPlanTitle: '[白板 PLAN.md — 项目全貌快照(模型维护,用户在面板可见)
|
|
697
|
+
snapshotPlanTitle: '[白板 PLAN.md — 项目全貌快照(模型维护,用户在面板可见);**还是上面那种自动骨架、或内容已过时、或理解有实质变化时,用 memory_note(kind=plan) 重写**(重写=整体覆盖,旧版自动归档)]',
|
|
630
698
|
snapshotHandoffTitle: '[最近交接 — 四段式账本(任务状态/目标/已试方案与失败原因/进度与下一步)]',
|
|
631
699
|
snapshotWaterTitle: '[上下文水位提示 — 接近窗口上限]',
|
|
632
700
|
snapshotWaterBody: '上下文水位约 {pct}%(官方公式估算:4 字符≈1 token)。窗口将满,按序执行:①memory_note(kind=handoff) 写交接账本——四段各 ≤5 行:任务状态(一句话+当前产物路径)、目标(可验证的完成标准)、已试方案与失败原因(写成「方案→失败原因」,保留关键报错词)、进度与下一步(可直接执行的第一步,带文件或命令);②memory_note(kind=plan) 刷新白板全貌;③在回复正文明确建议用户开新窗口续接。可分解的子任务优先派子代理隔离上下文。不要跳过账本直接继续对话。',
|
|
@@ -649,7 +717,7 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
649
717
|
+ '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
|
|
650
718
|
+ '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
|
|
651
719
|
+ '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\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'
|
|
720
|
+
+ '③ 长期记忆判断(三个去处)—— 跨会话有用的 → memory_note / 跨项目规则 → memory_user / **规则条目增删改(用户级硬约束,含过时条目真删)→ memory_rules** / **跑通且可复用的多步流程 → memory_procedure**(技能库唯一模型入口,不写就没有;建议填 successCriteria);\n'
|
|
653
721
|
+ '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
|
|
654
722
|
+ '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
|
|
655
723
|
+ '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
|
|
@@ -666,6 +734,40 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
666
734
|
staticWriteDiscipline: '[记忆写入纪律 — 必须遵守]',
|
|
667
735
|
})
|
|
668
736
|
|
|
737
|
+
/**
|
|
738
|
+
* ★P10:**可关的注入分区**(设置页开关据此枚举)。
|
|
739
|
+
*
|
|
740
|
+
* 判据:①该段是"内容/行为"而非"框架包裹";②关掉它不会破坏注入块结构。
|
|
741
|
+
* **刻意排除**(不暴露开关):`frame-head` / `frame-meta` / `frame-inscription` / `frame-tail`
|
|
742
|
+
* —— 它们是 `<memory_system>` 的开合与状态行,关掉会产出没有闭合标签的块。
|
|
743
|
+
* 值与 `renderMemoryDynamic` 里 `pushPart(bucket, kind, …)` 的 kind 逐字对应(守卫锁一致性)。
|
|
744
|
+
*/
|
|
745
|
+
export const PROMPT_SECTION_KEYS_V1 = Object.freeze([
|
|
746
|
+
'rules-section', // 规则段(用户级硬约束;关掉=不再注入「必须遵守」层)
|
|
747
|
+
'tier0-catalog', // Tier-0 常驻目录(指引层)
|
|
748
|
+
'whiteboard-plan', // 白板 PLAN 快照
|
|
749
|
+
'handoff-ledger', // 交接账本
|
|
750
|
+
'calendar', // 日历与日程
|
|
751
|
+
'external-memory', // 其他 AI 工具记忆
|
|
752
|
+
'external-sessions', // 历史会话索引
|
|
753
|
+
'workspace-map', // 其他工作区记忆索引
|
|
754
|
+
'welcome-title', // 新工作区欢迎语(标题)
|
|
755
|
+
'welcome-body', // 新工作区欢迎语(正文)
|
|
756
|
+
'plan-update-request',// 白板更新请求(must;关掉=模型不再被要求更新白板)
|
|
757
|
+
'water-advisory', // 上下文水位提醒(must;关掉=不再提醒收尾写账本)
|
|
758
|
+
'handoff-pointer', // 未绑定工作区时的账本指针
|
|
759
|
+
])
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* ★P10-T2(2026-09-22):上面 13 段里**带硬性行为要求**的子集 —— 设置页二级页据此在开关旁
|
|
763
|
+
* 标注「关掉会发生什么」(普通段落只需通用提示)。
|
|
764
|
+
* 纪律:本集合**必须是 PROMPT_SECTION_KEYS_V1 的子集**(守卫断言),不得独立增删语义。
|
|
765
|
+
*/
|
|
766
|
+
export const PROMPT_SECTION_MUST_V1 = Object.freeze([
|
|
767
|
+
'plan-update-request', // 关掉 = 模型不再被要求更新白板
|
|
768
|
+
'water-advisory', // 关掉 = 不再提醒收尾写账本
|
|
769
|
+
])
|
|
770
|
+
|
|
669
771
|
export { DEFAULT_PROMPT_LAYERS }
|
|
670
772
|
|
|
671
773
|
// ---------- 小工具 ----------
|
|
@@ -923,13 +1025,44 @@ function hasParentSession(x) {
|
|
|
923
1025
|
} catch (e) { return false }
|
|
924
1026
|
}
|
|
925
1027
|
|
|
926
|
-
/**
|
|
1028
|
+
/**
|
|
1029
|
+
* 诊断日志路径(单一来源)。★2026-09-22(用户拍板):路径只在这里拼一次,
|
|
1030
|
+
* diag() 落盘、轮转、debugInfo().logs 三处共用,避免三份拼法各自漂移。
|
|
1031
|
+
*/
|
|
1032
|
+
function diagLogFile() {
|
|
1033
|
+
return path.join(dshHome(), 'dsh-auto-memory-diagnose.log')
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* 诊断日志轮转(★2026-09-22 用户拍板「记录太多了」)。
|
|
1038
|
+
* 触发:单文件 > DIAG_MAX_BYTES。动作:改名为 `.1`(覆盖上一份 .1)后重新开始写。
|
|
1039
|
+
* 只保留当前 + 1 份历史(最多 2 个文件),**不是无限增长**——实测未轮转时已达 11 MB / 11 万行。
|
|
1040
|
+
* 全程 try/catch:轮转失败绝不影响落盘本身(fail-soft,与既有 _diagChain 同纪律)。
|
|
1041
|
+
*/
|
|
1042
|
+
const DIAG_MAX_BYTES = 2 * 1024 * 1024
|
|
1043
|
+
function rotateDiagLogIfNeeded() {
|
|
1044
|
+
try {
|
|
1045
|
+
const f = diagLogFile()
|
|
1046
|
+
if (!existsSync(f)) return false
|
|
1047
|
+
if (statSync(f).size <= DIAG_MAX_BYTES) return false
|
|
1048
|
+
const bak = f + '.1'
|
|
1049
|
+
try { if (existsSync(bak)) rmSync(bak, { force: true }) } catch (e) {}
|
|
1050
|
+
renameSync(f, bak)
|
|
1051
|
+
return true
|
|
1052
|
+
} catch (e) { return false }
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** 诊断输出:写 diagLogFile()(append,超限自动轮转)。★2026-09-22 起**不再** console.log。 */
|
|
927
1056
|
let _diagChain = Promise.resolve()
|
|
1057
|
+
let _diagRotated = false
|
|
928
1058
|
function diag(msg) {
|
|
929
1059
|
try {
|
|
930
1060
|
const line = new Date().toISOString() + ' ' + msg + '\n'
|
|
931
|
-
|
|
932
|
-
|
|
1061
|
+
// ★2026-09-22(用户拍板):**不再向控制台输出**。诊断行只落盘,PowerShell 保持干净。
|
|
1062
|
+
// 根治的是"循环刷屏":本函数是唯一每轮/每次动作都会调用的日志出口(旧实现写盘后又打一遍控制台)。
|
|
1063
|
+
// 要看他:面板「诊断」页签(走 debugInfo().logs),或直接打开 diagLogFile() 指的路径。
|
|
1064
|
+
if (!_diagRotated) { _diagRotated = rotateDiagLogIfNeeded() }
|
|
1065
|
+
_diagChain = _diagChain.then(() => appendFile(diagLogFile(), line, 'utf8')).catch(() => {})
|
|
933
1066
|
} catch (e) {}
|
|
934
1067
|
}
|
|
935
1068
|
|
|
@@ -1628,7 +1761,9 @@ class MemoryEngine {
|
|
|
1628
1761
|
|
|
1629
1762
|
/** callId→rootCallId 关联账本(root/nested tool call;tools/result 与持久 tool/result 双通道合并,有界)。关闭模式零留存。 */
|
|
1630
1763
|
linkObserverCall(runtime, callId, rootCallId, name, via) {
|
|
1631
|
-
|
|
1764
|
+
// ★#117 P3-1②(2026-09-22):与同文件 ingestEnvelope 的 disposed 闸对齐 ——
|
|
1765
|
+
// 旧实现漏了这一处 ⇒ dispose 之后 linkObserverCall 仍会惰性重建 callLinks(挂在已 disposed runtime 上)。
|
|
1766
|
+
if (!runtime || runtime.disposed || !callId) return
|
|
1632
1767
|
if (this.config.associativeMemoryEnabled !== true) return // 方案 B:关闭时不保留任何关联数据
|
|
1633
1768
|
if (!runtime.callLinks) runtime.callLinks = new Map()
|
|
1634
1769
|
const key = String(callId)
|
|
@@ -2338,6 +2473,73 @@ class MemoryEngine {
|
|
|
2338
2473
|
/** 白板重写:旧 PLAN.md 有实质内容且与新内容不同时,先归档到 handoff/archive/PLAN-<ts>.md 再写新快照(保留更改历史)。
|
|
2339
2474
|
* P7(2026-09-09)白板老化:新内容中标题命中 /历史|踩坑|流水线要点/ 的顶层「## 」节移入 handoff/archive/PLAN-history-<ts>.md
|
|
2340
2475
|
* (同秒多写 -b/-c 后缀防撞),PLAN.md 只留当前状态;内容只移动不删除;无匹配节 → 行为与旧版完全一致。 */
|
|
2476
|
+
/**
|
|
2477
|
+
* ★2026-09-22(用户拍板):**新工作区自动首建白板骨架** —— 白板首建缺口的正解。
|
|
2478
|
+
*
|
|
2479
|
+
* 缺口(证据见 docs/internal/TODO-NEXT-20260922.md §C.4):写盘链路完整(含首建、有用例且绿),
|
|
2480
|
+
* 但四处指令**只描述「重写」**(见本批 ③④⑤⑥)⇒ 没有 PLAN.md 的工作区里触发条件结构性恒假;
|
|
2481
|
+
* 且注入侧 planText||latestHandoffText 为假时整段白板提示不注入,模型连暗示都收不到。
|
|
2482
|
+
*
|
|
2483
|
+
* 修法:宿主不再等模型 —— 工作区第一次有会话活动就把骨架落下来。骨架一存在:
|
|
2484
|
+
* ① 注入条件转真 ⇒ 白板快照每轮注入 ⇒ 既有「维护/重写」纪律自举生效;
|
|
2485
|
+
* ② 面板不再显示空态 ⇒ 前端承诺与后端事实一致;
|
|
2486
|
+
* ③ kind=plan 的「重写」语义有了对象,不再空转。
|
|
2487
|
+
*
|
|
2488
|
+
* 纪律:
|
|
2489
|
+
* - **只写一次**:PLAN.md 已存在且非空即跳过(模型重写过的白板绝不被骨架覆盖)。
|
|
2490
|
+
* - **只走唯一写盘口**:委托 writePlanSnapshot ⇒ 归档/锚点/sidecar/事件日志/写入门全复用。
|
|
2491
|
+
* - **产物层闸门**:只判 handoffEnabled(白板是产物层),**不判** boardMode 与 autoContinueEnabled
|
|
2492
|
+
* —— 后者管的是"要不要接续材料",与"白板存不存在"无关(功能开关解耦纪律)。
|
|
2493
|
+
* - **fail-soft**:任何失败只回 {ok:false},绝不影响轮次收尾。
|
|
2494
|
+
* - 骨架必须过 P-H1 判据(≥1 个 body ≥20 非空白字符的 '## ' 顶节),否则"首建"落不下去。
|
|
2495
|
+
*/
|
|
2496
|
+
async ensurePlanBoardPre(projectDir) {
|
|
2497
|
+
try {
|
|
2498
|
+
if (!projectDir) return { ok: false, error: 'no-project-dir' }
|
|
2499
|
+
if (this.config && this.config.handoffEnabled === false) return { ok: true, skipped: 'handoff-disabled' }
|
|
2500
|
+
const planPath = path.join(projectDir, 'handoff', 'PLAN.md')
|
|
2501
|
+
const existing = await this.readTextSafe(planPath)
|
|
2502
|
+
if (existing && existing.trim()) return { ok: true, skipped: 'exists' }
|
|
2503
|
+
const r = await this.writePlanSnapshot(projectDir, this.skeletonPlanTextPre())
|
|
2504
|
+
if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'seed-write-failed', gate: r && r.gate }
|
|
2505
|
+
return { ok: true, seeded: true, path: planPath, at: Date.now() }
|
|
2506
|
+
} catch (e) {
|
|
2507
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/** 白板骨架正文(纯字符串,零 IO)。必须过 P-H1 判据;刻意不写任何假内容。 */
|
|
2512
|
+
skeletonPlanTextPre() {
|
|
2513
|
+
const NL = String.fromCharCode(10)
|
|
2514
|
+
return [
|
|
2515
|
+
'# 项目全貌(自动建立 · 待模型重写)',
|
|
2516
|
+
'',
|
|
2517
|
+
'## 这是什么',
|
|
2518
|
+
'type:state',
|
|
2519
|
+
'本白板由 dsh-auto-memory 在本工作区首次活动时**自动建立**,内容尚未填写。',
|
|
2520
|
+
'模型在理解本项目全貌后,请用 memory_note(kind=plan) 整体重写本页,写入真实的全貌。',
|
|
2521
|
+
'',
|
|
2522
|
+
'## 进度与下一步',
|
|
2523
|
+
'type:progress',
|
|
2524
|
+
'本工作区暂无阶段性结论。完成首轮实质工作后请重写白板,写明当前状态与下一步(可直接执行的第一步)。',
|
|
2525
|
+
'',
|
|
2526
|
+
].join(NL)
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
/** 轮末兜底:为当前 agent 的工作区自动首建白板(异步、fail-soft、不阻塞收尾)。 */
|
|
2530
|
+
async ensurePlanBoardForAgentPre(agent) {
|
|
2531
|
+
try {
|
|
2532
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (_) {} }
|
|
2533
|
+
if (this.config && this.config.handoffEnabled === false) return { ok: true, skipped: 'handoff-disabled' }
|
|
2534
|
+
const p = await this.resolvePaths(agent)
|
|
2535
|
+
const projectDir = (p && p.projectDir) || (p && p.handoffDir ? path.dirname(p.handoffDir) : '')
|
|
2536
|
+
if (!projectDir) return { ok: false, error: 'no-project-dir' }
|
|
2537
|
+
return await this.ensurePlanBoardPre(projectDir)
|
|
2538
|
+
} catch (e) {
|
|
2539
|
+
return { ok: false, error: String((e && e.message) || e) }
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2341
2543
|
async writePlanSnapshot(projectDir, content, opts) {
|
|
2342
2544
|
const dir = path.join(projectDir, 'handoff')
|
|
2343
2545
|
const planPath = path.join(dir, 'PLAN.md')
|
|
@@ -2671,6 +2873,39 @@ class MemoryEngine {
|
|
|
2671
2873
|
* (见 buildContinueCarry 头注释: 关时既不写、也不读) ⇒ 它管写入/取材; **看板是渲染**, 不该被它拦。
|
|
2672
2874
|
* 保持者: boardMode 门(下方那条) —— 那是渲染形态开关, 且 legacy 须与旧行为逐字节相同(:3673 纪律)。
|
|
2673
2875
|
*/
|
|
2876
|
+
/**
|
|
2877
|
+
* ★v3.1.2:单卡全文(看板「展开全文」按需取)。
|
|
2878
|
+
* 为什么单独开:看板载荷不再内联 full(1178 卡 × ≤6000 字符 ≈ 0.85 MB,且 lanes+matrix 双份),
|
|
2879
|
+
* 前端默认只渲染 preview,全文仅点开时才需要 ⇒ 按 id 单取更省。
|
|
2880
|
+
* 复用与看板同一读盘口径(_collectWhiteboardDocsPre + buildSectionCardsPre),保证 id 可命中。
|
|
2881
|
+
*/
|
|
2882
|
+
async kanbanCardBody(sessionId, cardId, opts = {}) {
|
|
2883
|
+
const want = String(cardId || '').trim()
|
|
2884
|
+
if (!want) return { ok: false, error: 'missing-card-id' }
|
|
2885
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2886
|
+
try {
|
|
2887
|
+
let p, wsBound = true
|
|
2888
|
+
if (sessionId) {
|
|
2889
|
+
p = await this.resolvePathsForSession(sessionId)
|
|
2890
|
+
wsBound = p.wsBound !== false
|
|
2891
|
+
} else {
|
|
2892
|
+
p = (this.state.handoffDir || this.state.planPath)
|
|
2893
|
+
? { handoffDir: this.state.handoffDir || path.dirname(this.state.planPath), planPath: this.state.planPath, ws: this.state.ws, projectDir: this.state.projectDir }
|
|
2894
|
+
: await this.resolvePaths(undefined)
|
|
2895
|
+
}
|
|
2896
|
+
if (!wsBound) return { ok: false, error: 'unbound-ws' }
|
|
2897
|
+
const projectDir = p.projectDir || path.dirname(p.handoffDir)
|
|
2898
|
+
// 展开全文要能找到**任意**一张卡(含归档),故这里 includeArchive: true。
|
|
2899
|
+
const docs = await this._collectWhiteboardDocsPre(projectDir, { includeArchive: true })
|
|
2900
|
+
const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
|
|
2901
|
+
const hit = cards.find((c) => String(c.id || '') === want)
|
|
2902
|
+
if (!hit) return { ok: false, error: 'card-not-found' }
|
|
2903
|
+
return { ok: true, id: want, full: String(hit.body || hit.preview || '').slice(0, 6000) }
|
|
2904
|
+
} catch (e) {
|
|
2905
|
+
return { ok: false, error: String(e && e.message ? e.message : e) }
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2674
2909
|
async kanbanBoardData(sessionId, opts = {}) {
|
|
2675
2910
|
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2676
2911
|
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
@@ -2692,7 +2927,8 @@ class MemoryEngine {
|
|
|
2692
2927
|
// v1 用文件级条目 ⇒ 一篇账本压成一根卡(实测 92 文件仅 92 条, 且 sectionOfPre 无 break
|
|
2693
2928
|
// 导致整篇归一到最后一个 ## 标题, 三条泳道恒空)。改为小节级后实测 92 → 417 条,
|
|
2694
2929
|
// 目标/进行中/失败与弯路/进度 四条泳道全部有内容。
|
|
2695
|
-
|
|
2930
|
+
// ★v3.1.2 性能:看板默认不含 archive(用户拍板 C)——归档只做历史留档,不进看板投影。
|
|
2931
|
+
const docs = await this._collectWhiteboardDocsPre(projectDir, { includeArchive: false })
|
|
2696
2932
|
const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
|
|
2697
2933
|
const index = await this._loadSidecarIndexPre(projectDir)
|
|
2698
2934
|
const kb = buildKanbanPre(index, { now: new Date().toISOString(), cards, perLaneCap: Number(opts.perLaneCap) || 0 })
|
|
@@ -2711,7 +2947,10 @@ class MemoryEngine {
|
|
|
2711
2947
|
* 读取白板/账本文档清单(看板与 sidecar 重建的**唯一读盘口径**)。
|
|
2712
2948
|
* 返回 [{relPath, text, title, kind, mtime, ts}] —— 供小节切分与文件级重建共用。
|
|
2713
2949
|
*/
|
|
2714
|
-
|
|
2950
|
+
// ★v3.1.2 性能(用户拍板 C):归档默认不进**看板**投影。
|
|
2951
|
+
// 实测 handoff/ 活跃账本 157 个 / archive 99 个,每次 /kanban-board 全量读盘+切分;
|
|
2952
|
+
// 归档属于历史留档,看板默认只看 活跃账本 + PLAN(白板/账本本身仍全量可读,不受影响)。
|
|
2953
|
+
async _collectWhiteboardDocsPre(projectDir, opts = {}) {
|
|
2715
2954
|
const sideDir = path.join(projectDir, 'handoff')
|
|
2716
2955
|
const docs = []
|
|
2717
2956
|
const planText = await this.readTextSafe(path.join(sideDir, 'PLAN.md'))
|
|
@@ -2726,7 +2965,7 @@ class MemoryEngine {
|
|
|
2726
2965
|
kind: 'ledger', mtime: st ? Number(st.mtimeMs) : 0,
|
|
2727
2966
|
})
|
|
2728
2967
|
}
|
|
2729
|
-
const archNames = await readdir(path.join(sideDir, 'archive')).catch(() => [])
|
|
2968
|
+
const archNames = (opts && opts.includeArchive) ? await readdir(path.join(sideDir, 'archive')).catch(() => []) : []
|
|
2730
2969
|
for (const f of archNames.filter((x) => /\.md$/.test(x)).sort()) {
|
|
2731
2970
|
const st = await stat(path.join(sideDir, 'archive', f)).catch(() => null)
|
|
2732
2971
|
docs.push({
|
|
@@ -2989,10 +3228,58 @@ class MemoryEngine {
|
|
|
2989
3228
|
return rec && Date.now() - (Number(rec.at) || 0) < 600000 ? rec : null
|
|
2990
3229
|
}
|
|
2991
3230
|
|
|
3231
|
+
/**
|
|
3232
|
+
* ★v3.1.3:从**目录列出的文件名**里挑出会话日志文件(纯函数,便于单测)。
|
|
3233
|
+
*
|
|
3234
|
+
* 为什么不再用固定候选表:实盘同时存在 `session.jsonl.zstd` 与 `session.v3.jsonl.zstd` 两代,
|
|
3235
|
+
* 代码注释自述该名已随宿主改过一次、靠人工追加候选名跟上。若宿主改名(如 v4),
|
|
3236
|
+
* 固定表会**返回空**并让五条功能链同时静默降级。这里改成按**结构**识别:
|
|
3237
|
+
* · 前缀 `session.`(或 `session-`)
|
|
3238
|
+
* · 含 `.jsonl`(可带 `.zstd`)
|
|
3239
|
+
* · 排除明显的备份/调试产物(`.broken-backup-` / `.dup-backup-` / 以 `_` 开头)
|
|
3240
|
+
* 排序优先级:**版本号高者优先**(v4 > v3 > 无版本)> 非压缩优先于压缩 > mtime 新者优先。
|
|
3241
|
+
* 这样既兼容未来命名,又不会把备份文件误当主文件。
|
|
3242
|
+
*
|
|
3243
|
+
* @param {string[]} names 目录里的文件名
|
|
3244
|
+
* @param {Record<string, number>} mtimes name → mtimeMs(缺省视为 0)
|
|
3245
|
+
* @returns {string} 选中的文件名;无可选返回 ''
|
|
3246
|
+
*/
|
|
3247
|
+
pickSessionFileNamePre(names, mtimes) {
|
|
3248
|
+
const list = Array.isArray(names) ? names : []
|
|
3249
|
+
const mt = mtimes && typeof mtimes === 'object' ? mtimes : {}
|
|
3250
|
+
const cands = []
|
|
3251
|
+
for (const n of list) {
|
|
3252
|
+
if (typeof n !== 'string' || !n) continue
|
|
3253
|
+
if (n.startsWith('_')) continue // _debug_child.jsonl 之类
|
|
3254
|
+
if (/backup|corrupt|\.tmp$|\.dam-tmp$/i.test(n)) continue
|
|
3255
|
+
if (!/^session[.-]/.test(n)) continue // session. / session-
|
|
3256
|
+
if (!/\.jsonl(\.zstd)?$/i.test(n)) continue // 只认 jsonl[.zstd]
|
|
3257
|
+
const vm = n.match(/session\.v(\d+)\./i)
|
|
3258
|
+
cands.push({ name: n, ver: vm ? Number(vm[1]) : 0, zstd: /\.zstd$/i.test(n) ? 1 : 0, m: Number(mt[n]) || 0 })
|
|
3259
|
+
}
|
|
3260
|
+
if (!cands.length) return ''
|
|
3261
|
+
cands.sort((a, b) => (b.ver - a.ver) || (a.zstd - b.zstd) || (b.m - a.m) || a.name.localeCompare(b.name))
|
|
3262
|
+
return cands[0].name
|
|
3263
|
+
}
|
|
3264
|
+
|
|
2992
3265
|
/** 2.3.1:解析会话日志文件路径 —— DSH 0.1.5 起会话格式迁移到 `session.v3.jsonl.zstd`
|
|
2993
3266
|
* (旧 `session.jsonl.zstd` 保留但停止更新);按 mtime 取最新存在的文件,兼容两代与未来命名。 */
|
|
2994
3267
|
async resolveSessionFile(dir) {
|
|
2995
3268
|
try {
|
|
3269
|
+
// ★v3.1.3:先扫目录(兼容任意 session.vN / 未来命名),目录读不到才回退固定表。
|
|
3270
|
+
let names = []
|
|
3271
|
+
try { names = await readdir(dir) } catch (_) { names = [] }
|
|
3272
|
+
let picked = ''
|
|
3273
|
+
if (names.length) {
|
|
3274
|
+
const mtimes = {}
|
|
3275
|
+
for (const n of names) {
|
|
3276
|
+
if (!/^session[.-].*\.jsonl(\.zstd)?$/i.test(n)) continue
|
|
3277
|
+
try { const st = await stat(path.join(dir, n)); if (st.isFile()) mtimes[n] = st.mtimeMs } catch (_) {}
|
|
3278
|
+
}
|
|
3279
|
+
picked = this.pickSessionFileNamePre(Object.keys(mtimes), mtimes)
|
|
3280
|
+
}
|
|
3281
|
+
if (picked) return path.join(dir, picked)
|
|
3282
|
+
// 回退(目录不可读时):旧的固定候选表,行为与历史一致
|
|
2996
3283
|
const cands = ['session.v3.jsonl.zstd', 'session.jsonl.zstd', 'session.v3.jsonl', 'session.jsonl']
|
|
2997
3284
|
let best = null
|
|
2998
3285
|
for (const name of cands) {
|
|
@@ -3771,9 +4058,18 @@ class MemoryEngine {
|
|
|
3771
4058
|
for (const ent of entries) {
|
|
3772
4059
|
if (!ent.isDirectory()) continue
|
|
3773
4060
|
if (!/^session-/.test(ent.name)) continue
|
|
4061
|
+
// ★v3.1.3:遍历该会话目录里**全部** session.* 文件(不再固定表),取最大 mtime。
|
|
3774
4062
|
let ms = 0
|
|
3775
|
-
|
|
3776
|
-
|
|
4063
|
+
try {
|
|
4064
|
+
for (const f of readdirSync(path.join(wsPath, ent.name))) {
|
|
4065
|
+
if (!/^session[.-].*\.jsonl(\.zstd)?$/i.test(f)) continue
|
|
4066
|
+
if (/backup|corrupt/i.test(f)) continue
|
|
4067
|
+
try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
|
|
4068
|
+
}
|
|
4069
|
+
} catch (_) {
|
|
4070
|
+
for (const f of ['session.v3.jsonl.zstd', 'session.jsonl.zstd']) {
|
|
4071
|
+
try { const st = statSync(path.join(wsPath, ent.name, f)); if (st.mtimeMs > ms) ms = st.mtimeMs } catch (_) {}
|
|
4072
|
+
}
|
|
3777
4073
|
}
|
|
3778
4074
|
if (ms > bestMs) { bestMs = ms; best = ent.name }
|
|
3779
4075
|
}
|
|
@@ -4481,7 +4777,10 @@ class MemoryEngine {
|
|
|
4481
4777
|
/** 对比本地版本与 npm registry 最新版;结果缓存 12 小时(启动/设置页打开都走缓存,不重复查网)。 */
|
|
4482
4778
|
async checkUpdate(force) {
|
|
4483
4779
|
const cacheFile = path.join(dshHome(), 'memory', 'update-check.json')
|
|
4484
|
-
|
|
4780
|
+
// ★v3.1.2:dev-link(profile 以 link: 挂载开发树)时,current 读的是**开发树自己的版本**,
|
|
4781
|
+
// 与 registry latest 恒等 ⇒ upToDate 永远 true,用户感知不到线上新版。故把开发树版本
|
|
4782
|
+
// 单独标成 devVersion,让前端能呈现「开发树 vX ↔ 线上 vY」而不是假装已是最新。
|
|
4783
|
+
const base = { current: '', installKind: 'unknown', devVersion: '' }
|
|
4485
4784
|
try {
|
|
4486
4785
|
const indexPath = fileURLToPath(import.meta.url)
|
|
4487
4786
|
const pkgPath = path.join(path.dirname(indexPath), '..', 'package.json')
|
|
@@ -4490,6 +4789,7 @@ class MemoryEngine {
|
|
|
4490
4789
|
} catch (e) {}
|
|
4491
4790
|
const prof = await this.findProfileDir()
|
|
4492
4791
|
if (prof) base.installKind = prof.installKind
|
|
4792
|
+
if (base.installKind === 'dev-link') base.devVersion = base.current
|
|
4493
4793
|
if (!force) {
|
|
4494
4794
|
try {
|
|
4495
4795
|
const cached = await this.readTextSafe(cacheFile)
|
|
@@ -5599,7 +5899,7 @@ class MemoryEngine {
|
|
|
5599
5899
|
// 旧行为的病(两条独立病因,都有代码行号):
|
|
5600
5900
|
// A. 措辞:开场白把"规矩"与"资料"统一降格为「只是背景事实与规则参考」⇒ 规则不醒目;
|
|
5601
5901
|
// B. 节奏:`snapshotMinGapRounds` 使规矩在第 2–5 轮**不在场**(模型不是不听话,是没收到)。
|
|
5602
|
-
// 开关 `rulesLayeringMode
|
|
5902
|
+
// 开关 `rulesLayeringMode`(**出厂默认 'self'**,见 DEFAULT_CONFIG;旧注释写成 'off' 是错的)= 新旧并存 + 一键回退:关掉即完全回到旧行为。
|
|
5603
5903
|
// 开启后规则段走分项账本的 `rules` 分项:**不受 gap 约束、不参与裁剪**(v2 §3.3 选 (ii))。
|
|
5604
5904
|
let rulesLayer = { enabled: false, text: '', rules: [] }
|
|
5605
5905
|
let rulesSection = { text: '', chars: 0 }
|
|
@@ -5633,7 +5933,13 @@ class MemoryEngine {
|
|
|
5633
5933
|
// ★2026-09-15 修复:每段带 `priority`(low / normal / must)。超额时**先丢 low,must 永不丢**。
|
|
5634
5934
|
// 旧实现是**纯位置式**丢弃(从分项尾部丢),尾部恰好是 user-memory / project-notes
|
|
5635
5935
|
// ⇒ 价值最高的两段先死(本机实测)。位置顺序是渲染顺序,不是价值顺序。
|
|
5936
|
+
// ★P10:**注入分区开关**的唯一闸门(每段都过这里)。缺省全开 ⇒ 只有显式 false 才拦。
|
|
5937
|
+
// 只影响本段自己,不触发任何其他功能变化(用户铁律:单一开关不得顺带改变别的行为)。
|
|
5938
|
+
const secToggles = (cfg && cfg.promptSectionToggles && typeof cfg.promptSectionToggles === 'object')
|
|
5939
|
+
? cfg.promptSectionToggles : {}
|
|
5940
|
+
const sectionsOff = []
|
|
5636
5941
|
const pushPart = (bucket, kind, text, priority) => {
|
|
5942
|
+
if (secToggles[kind] === false) { sectionsOff.push(kind); return }
|
|
5637
5943
|
if (text === undefined || text === null || text === '') return
|
|
5638
5944
|
segs.push({ bucket, kind, text, priority: priority || 'normal' })
|
|
5639
5945
|
}
|
|
@@ -5773,6 +6079,22 @@ class MemoryEngine {
|
|
|
5773
6079
|
// 只用于账本的「合计是否超预算」可见性(落 meta;诊断类降级不进注入文本,避免每轮噪声)
|
|
5774
6080
|
budgetChars,
|
|
5775
6081
|
})
|
|
6082
|
+
// ★③ 召回统计 · inject 通道(2026-09-22 用户拍板「分开计算」):记**这一轮实际注入了哪些段、各多少字符**。
|
|
6083
|
+
// 为什么与 model 通道分开:inject = 「系统一直塞给我」= **成本**;model = 「我明确想找」= 高信号。
|
|
6084
|
+
// 合成一个数会把「模型懒得检索」误读成「这条不重要」,而这个误读会被写进加权公式、越滚越偏。
|
|
6085
|
+
// 数据源用 `envelope`(不是 `segs`):它已过预算裁剪 ⇒ 记的是**真正进 prompt 的段**,不是想注入的段。
|
|
6086
|
+
// ★只读:本块不修改 envelope、不影响注入文本(守卫断言:注入文本逐字节不变)。
|
|
6087
|
+
try {
|
|
6088
|
+
if (this._recallStats && envelope && Array.isArray(envelope.segments)) {
|
|
6089
|
+
const segList = envelope.segments.map((sg) => ({
|
|
6090
|
+
name: sg.kind, chars: (sg.text || '').length, layer: sg.bucket,
|
|
6091
|
+
}))
|
|
6092
|
+
const droppedList = (envelope.dropped || []).map((x) => ({
|
|
6093
|
+
name: x.kind + '(dropped)', chars: 0, layer: x.bucket,
|
|
6094
|
+
}))
|
|
6095
|
+
this._recallStats.observeInjection(segList.concat(droppedList))
|
|
6096
|
+
}
|
|
6097
|
+
} catch (_) {}
|
|
5776
6098
|
// T0-3:账本挂 state,面板/排障/后续 P6A 都读它(**不再有第二本账**)
|
|
5777
6099
|
try {
|
|
5778
6100
|
this.state.envelopeMeta = {
|
|
@@ -5810,7 +6132,7 @@ class MemoryEngine {
|
|
|
5810
6132
|
lines.push('- 新工作区(无历史日志/笔记):主动用 memory_recall 探索本机历史,判断该项目是否曾在其他 AI 工具中工作过;也可调用 memory_external 查看并接入外部记忆;检索时在正文中说明"我先查一下之前的记录"。')
|
|
5811
6133
|
lines.push('- 完成实质性工作后立即调用 memory_log 追加今日日志(append-only,绝不覆盖):建/改应用、修 bug、写文档、重构、技术选型、用户约定或偏好。')
|
|
5812
6134
|
lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
|
|
5813
|
-
lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff)
|
|
6135
|
+
lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;白板 PLAN.md(人能读的项目规划图,用户在面板实时可见):**新工作区的骨架由宿主自动落(标题带「自动建立 · 待模型重写」),那只是占位——你理解项目全貌后要用 memory_note(kind=plan) 把它重写成真内容**;此后对项目全貌的理解发生实质变化时同样重写(整体覆盖,旧版自动归档)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
|
|
5814
6136
|
lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
|
|
5815
6137
|
// ★T6(2026-09-20 用户拍板):**看板分列机制** —— 模型此前完全不知道有 tag 这回事。
|
|
5816
6138
|
// 根因:wb-sidecar.js:594-597 的 WB_KANBAN_LANES_V1 靠 matchTags 匹配
|
|
@@ -6256,7 +6578,7 @@ class MemoryEngine {
|
|
|
6256
6578
|
if (rank && rank.scores && rank.scores.size) semScores = rank.scores
|
|
6257
6579
|
} catch (eBest) {
|
|
6258
6580
|
// R3:预期外失败 —— 引擎/worker 抛错导致回退词法,用户应当能知道(不只是 diag 一行)。
|
|
6259
|
-
try {
|
|
6581
|
+
try { diagThrottled('recall-best', 'recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {}
|
|
6260
6582
|
try { if (this._degradeSink) this._degradeSink.record('semantic-arm', String((eBest && eBest.message) || eBest).slice(0, 160)) } catch (_) {}
|
|
6261
6583
|
}
|
|
6262
6584
|
if (semScores && semScores.size) {
|
|
@@ -6295,7 +6617,7 @@ class MemoryEngine {
|
|
|
6295
6617
|
} catch (eImp) {
|
|
6296
6618
|
// R3:预期外失败才记。目录不存在已被上面的 existsSync 守卫拦下(预期内分支,静默);
|
|
6297
6619
|
// 走到这里的都是真异常(聚合/读文件/import 失败)⇒ 应当留痕。
|
|
6298
|
-
try {
|
|
6620
|
+
try { diagThrottled('evidence-agg', 'evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {}
|
|
6299
6621
|
try { if (this._degradeSink) this._degradeSink.record('evidence-arm', String((eImp && eImp.message) || eImp).slice(0, 160)) } catch (_) {}
|
|
6300
6622
|
}
|
|
6301
6623
|
const l0Hits = l0Corpus.filter((c) => c.lex > 0 || (typeof c.sem === 'number' && c.sem >= 0.5))
|
|
@@ -6308,7 +6630,7 @@ class MemoryEngine {
|
|
|
6308
6630
|
const tp = await import('./temporal-parse.js')
|
|
6309
6631
|
tr = tp.parseTemporalQueryPre(query, { now: Date.now() })
|
|
6310
6632
|
dateMsOf = tp.labelToDateMsPre
|
|
6311
|
-
} catch (eTr) { try {
|
|
6633
|
+
} catch (eTr) { try { diagThrottled('temporal-parse', 'temporal-parse 降级(无时间臂): ' + String((eTr && eTr.message) || eTr).slice(0, 140)) } catch (_) {} }
|
|
6312
6634
|
const tempFieldOf = (c) => {
|
|
6313
6635
|
if (!tr || typeof dateMsOf !== 'function') return undefined
|
|
6314
6636
|
const d = dateMsOf(c.label)
|
|
@@ -6344,7 +6666,17 @@ class MemoryEngine {
|
|
|
6344
6666
|
.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
6667
|
.slice(0, Math.max(1, Number(limit) || 8))
|
|
6346
6668
|
}
|
|
6347
|
-
|
|
6669
|
+
// ③ 召回统计埋点(2026-09-22):**只读** l0Top 做计数,绝不改其顺序/内容。
|
|
6670
|
+
// 为什么放这里:l0Top 已定型(融合或降级排序完成)、尚未被转成输出文本
|
|
6671
|
+
// ⇒ 记的就是"本轮真实交付的命中集",含 finalRank 口径。fail-soft 包住,统计绝不干扰召回。
|
|
6672
|
+
try {
|
|
6673
|
+
if (this._recallStats) {
|
|
6674
|
+
this._recallStats.observe(l0Top.map((c) => ({
|
|
6675
|
+
id: c.id, layer: c.layer, score: typeof c.sem === 'number' ? c.sem : c.lex, reason: c.reason,
|
|
6676
|
+
})), { channel: 'model' })
|
|
6677
|
+
}
|
|
6678
|
+
} catch (_) {}
|
|
6679
|
+
if (l0Top.length) {
|
|
6348
6680
|
out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
|
|
6349
6681
|
// ★R4-B(2026-09-18)分层**呈现**:把已排好序的命中**按层分组**后展示。
|
|
6350
6682
|
// ★排序零改动:分组只重排"行",不改 `l0Top` 的融合/词法序;组内保持原相对顺序,
|
|
@@ -6701,6 +7033,249 @@ class MemoryEngine {
|
|
|
6701
7033
|
return { logs, notes: truncateHead(notes || '', 800) }
|
|
6702
7034
|
}
|
|
6703
7035
|
|
|
7036
|
+
// ───────────────────── 迁移搬包(migrate pack)─────────────────────
|
|
7037
|
+
// 用户拍板:导出范围=主体(不带语义索引);路径需重写;入口放存储管理;先做一次性搬包。
|
|
7038
|
+
// ★IO 全在本节,纯逻辑全在 lib/migrate-pack.js(可单测、可守卫)。
|
|
7039
|
+
|
|
7040
|
+
/** 汇总文件路径(跨工作区全局)。 */
|
|
7041
|
+
_wsSummaryFile() { return path.join(dshHome(), 'memory', 'workspaces-summary.json') }
|
|
7042
|
+
|
|
7043
|
+
/** 当前工作区在 summary 里的那条记录(导出时随包带走)。 */
|
|
7044
|
+
async _wsSummaryRecord(ws) {
|
|
7045
|
+
try {
|
|
7046
|
+
const raw = await this.readTextSafe(this._wsSummaryFile())
|
|
7047
|
+
if (!raw) return null
|
|
7048
|
+
const j = JSON.parse(raw)
|
|
7049
|
+
const list = j && Array.isArray(j.workspaces) ? j.workspaces : []
|
|
7050
|
+
return list.find((x) => x && x.path === ws) || null
|
|
7051
|
+
} catch (_) { return null }
|
|
7052
|
+
}
|
|
7053
|
+
|
|
7054
|
+
/** 目标工作区目录里现有文件(键=相对 slug 目录的路径)—— 供「预览差异」算新增/覆盖。
|
|
7055
|
+
* ★只读,且只读文本类文件(记忆数据全是文本)。 */
|
|
7056
|
+
async _readExistingWorkspaceFiles(ws) {
|
|
7057
|
+
const dir = this.projectDirOf(ws)
|
|
7058
|
+
const out = {}
|
|
7059
|
+
const walk = async (q, rel) => {
|
|
7060
|
+
let ents = []
|
|
7061
|
+
try { ents = await readdir(q, { withFileTypes: true }) } catch (_) { return }
|
|
7062
|
+
for (const en of ents) {
|
|
7063
|
+
const abs = path.join(q, en.name)
|
|
7064
|
+
const r = rel ? rel + '/' + en.name : en.name
|
|
7065
|
+
if (en.isDirectory()) { await walk(abs, r); continue }
|
|
7066
|
+
try {
|
|
7067
|
+
const st = await stat(abs)
|
|
7068
|
+
if (!st.isFile() || st.size > 8 * 1024 * 1024) continue
|
|
7069
|
+
out[r] = await readFile(abs, 'utf8')
|
|
7070
|
+
} catch (_) {}
|
|
7071
|
+
}
|
|
7072
|
+
}
|
|
7073
|
+
await walk(dir, '')
|
|
7074
|
+
return out
|
|
7075
|
+
}
|
|
7076
|
+
|
|
7077
|
+
/** 导出:打包一个工作区的全部记忆到一个 .dam-pack(单 JSON;compress=true 时 gzip)。 */
|
|
7078
|
+
async migrateExport(opts) {
|
|
7079
|
+
const o = opts || {}
|
|
7080
|
+
const ws = String(o.ws || '')
|
|
7081
|
+
if (!ws) return { ok: false, error: 'missing-ws' }
|
|
7082
|
+
const outPath = String(o.outPath || '')
|
|
7083
|
+
if (!outPath) return { ok: false, error: 'missing-out-path' }
|
|
7084
|
+
const dir = this.projectDirOf(ws)
|
|
7085
|
+
const st = await stat(dir).catch(() => null)
|
|
7086
|
+
if (!st || !st.isDirectory()) return { ok: false, error: 'workspace-dir-missing' }
|
|
7087
|
+
// ★只收文本类文件:记忆数据全部是 .md/.json/.jsonl/.txt;二进制跳过并记警告(不静默丢)
|
|
7088
|
+
const TEXT_RE = /\.(md|json|jsonl|txt|markdown)$/i
|
|
7089
|
+
const files = {}
|
|
7090
|
+
const skipped = []
|
|
7091
|
+
let bytes = 0
|
|
7092
|
+
const walk = async (q, rel) => {
|
|
7093
|
+
let ents = []
|
|
7094
|
+
try { ents = await readdir(q, { withFileTypes: true }) } catch (_) { return }
|
|
7095
|
+
for (const en of ents) {
|
|
7096
|
+
const abs = path.join(q, en.name)
|
|
7097
|
+
const r = rel ? rel + '/' + en.name : en.name
|
|
7098
|
+
if (en.isDirectory()) { await walk(abs, r); continue }
|
|
7099
|
+
if (!TEXT_RE.test(en.name)) { skipped.push(r); continue }
|
|
7100
|
+
try {
|
|
7101
|
+
const s = await stat(abs)
|
|
7102
|
+
if (!s.isFile()) continue
|
|
7103
|
+
if (s.size > 32 * 1024 * 1024) { skipped.push(r + '(too-large)'); continue }
|
|
7104
|
+
const txt = await readFile(abs, 'utf8')
|
|
7105
|
+
files[r] = txt
|
|
7106
|
+
bytes += Buffer.byteLength(txt, 'utf8')
|
|
7107
|
+
} catch (_) { skipped.push(r + '(unreadable)') }
|
|
7108
|
+
}
|
|
7109
|
+
}
|
|
7110
|
+
await walk(dir, '')
|
|
7111
|
+
// ★v3.1.3:用户级文件(日历)也进包 —— 用户要求「把同步的范围扩大到日历」。
|
|
7112
|
+
// CALENDAR.md 在 userDir(跨工作区共享),故键用「相对 userDir 的路径」;
|
|
7113
|
+
// 导入侧按 merge 处理(见 _applyUserFilesPre / calendarMergePre),绝不整篇覆盖。
|
|
7114
|
+
const userFiles = {}
|
|
7115
|
+
try {
|
|
7116
|
+
const calRaw = await this.readTextSafe(path.join(this.userDirOf(), 'CALENDAR.md'))
|
|
7117
|
+
if (calRaw && String(calRaw).trim()) userFiles['CALENDAR.md'] = calRaw
|
|
7118
|
+
} catch (_) {}
|
|
7119
|
+
const built = buildPackPre({
|
|
7120
|
+
ws, files, userFiles,
|
|
7121
|
+
summaryRecord: await this._wsSummaryRecord(ws),
|
|
7122
|
+
pluginVersion: (this.manifest && this.manifest.version) || '',
|
|
7123
|
+
sourceHost: hostnameOf(),
|
|
7124
|
+
})
|
|
7125
|
+
if (!built.ok) return { ok: false, error: built.error, skipped }
|
|
7126
|
+
const json = JSON.stringify(built.pack)
|
|
7127
|
+
const compress = o.compress !== false
|
|
7128
|
+
let payload = json, ext = '.dam-pack'
|
|
7129
|
+
if (compress) { payload = nodeZlib.gzipSync(Buffer.from(json, 'utf8')); ext = '.dam-pack.gz' }
|
|
7130
|
+
let finalPath = outPath
|
|
7131
|
+
if (!/\.dam-pack(\.gz)?$/i.test(finalPath)) finalPath = finalPath.replace(/[\\/]+$/, '') + path.sep + migrateSlugPre(ws) + ext
|
|
7132
|
+
await mkdir(path.dirname(finalPath), { recursive: true })
|
|
7133
|
+
const tmp = finalPath + '.tmp'
|
|
7134
|
+
await writeFile(tmp, payload)
|
|
7135
|
+
await rename(tmp, finalPath) // 原子落盘:写一半的包不会被认为是完整的
|
|
7136
|
+
return {
|
|
7137
|
+
ok: true, path: finalPath, format: MIGRATE_PACK_FORMAT_PRE,
|
|
7138
|
+
stats: { fileCount: built.pack.stats.fileCount, bytes: built.pack.stats.bytes, packedBytes: Buffer.byteLength(payload) },
|
|
7139
|
+
checksum: built.pack.checksum.value, compress, skipped,
|
|
7140
|
+
warnings: built.warnings,
|
|
7141
|
+
}
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
/**
|
|
7145
|
+
* ★v3.1.3:把包内的用户级文件落地(当前只有 CALENDAR.md)。**合并而非覆盖**。
|
|
7146
|
+
* 为什么单独一个方法:日历在用户级目录、跨工作区共享,落盘口径与 files 那支完全不同。
|
|
7147
|
+
* 返回 { applied:[], skipped:[] } 供调用方汇总 —— 绝不静默。
|
|
7148
|
+
*/
|
|
7149
|
+
async _applyUserFilesPre(pack) {
|
|
7150
|
+
const applied = []
|
|
7151
|
+
const skipped = []
|
|
7152
|
+
const uf = (pack && pack.userFiles && typeof pack.userFiles === 'object') ? pack.userFiles : {}
|
|
7153
|
+
for (const rel of Object.keys(uf)) {
|
|
7154
|
+
if (rel !== 'CALENDAR.md') { skipped.push('user:' + rel + '(unsupported)'); continue }
|
|
7155
|
+
try {
|
|
7156
|
+
const target = path.join(this.userDirOf(), rel)
|
|
7157
|
+
const existing = await this.readTextSafe(target)
|
|
7158
|
+
const merged = calendarMergePre(existing, uf[rel])
|
|
7159
|
+
if (!merged.ok) { skipped.push('user:' + rel + '(merge-failed)'); continue }
|
|
7160
|
+
// 无新增且本机已有内容 ⇒ 不写盘(避免无谓 mtime 变动)
|
|
7161
|
+
if (merged.added === 0 && String(existing || '').trim()) { applied.push({ rel, added: 0, action: 'noop' }); continue }
|
|
7162
|
+
await this.writeFullRaw(target, merged.text)
|
|
7163
|
+
this.state.calendarText = merged.text; this.state.loadedAt = Date.now()
|
|
7164
|
+
applied.push({ rel, added: merged.added, action: 'merged' })
|
|
7165
|
+
} catch (e) { skipped.push('user:' + rel + '(error:' + String(e && e.message ? e.message : e) + ')') }
|
|
7166
|
+
}
|
|
7167
|
+
return { applied, skipped }
|
|
7168
|
+
}
|
|
7169
|
+
|
|
7170
|
+
/** 读包(自动识别 gzip:gzip 魔数 1f 8b)。★坏包在 validate 阶段即拒,不写任何文件。 */
|
|
7171
|
+
async _readPack(packPath) {
|
|
7172
|
+
const buf = await readFile(packPath).catch(() => null)
|
|
7173
|
+
if (!buf || !buf.length) return { ok: false, errors: ['pack-unreadable'] }
|
|
7174
|
+
let json = null
|
|
7175
|
+
try {
|
|
7176
|
+
json = (buf[0] === 0x1f && buf[1] === 0x8b)
|
|
7177
|
+
? nodeZlib.gunzipSync(buf).toString('utf8')
|
|
7178
|
+
: buf.toString('utf8')
|
|
7179
|
+
} catch (_) { return { ok: false, errors: ['pack-corrupt'] } }
|
|
7180
|
+
let pack = null
|
|
7181
|
+
try { pack = JSON.parse(json) } catch (_) { return { ok: false, errors: ['pack-not-json'] } }
|
|
7182
|
+
const v = validatePackPre(pack)
|
|
7183
|
+
if (!v.ok) return { ok: false, errors: v.errors, warnings: v.warnings }
|
|
7184
|
+
return { ok: true, pack, warnings: v.warnings }
|
|
7185
|
+
}
|
|
7186
|
+
|
|
7187
|
+
/** 预览:算出「导入将要发生什么」。★这是**唯一**能算差异的地方;import 会重算同一份计划。 */
|
|
7188
|
+
async migrateInspect(opts) {
|
|
7189
|
+
const o = opts || {}
|
|
7190
|
+
const r = await this._readPack(String(o.packPath || ''))
|
|
7191
|
+
if (!r.ok) return { ok: false, errors: r.errors }
|
|
7192
|
+
const targetWs = String(o.targetWs || r.pack.source.ws)
|
|
7193
|
+
let existing = {}
|
|
7194
|
+
try { existing = await this._readExistingWorkspaceFiles(targetWs) } catch (_) {}
|
|
7195
|
+
const plan = planImportPre(r.pack, { targetWs, existingFiles: existing, onConflict: o.onConflict, rewriteBody: o.rewriteBody })
|
|
7196
|
+
const { writeFiles, ...wire } = plan
|
|
7197
|
+
return {
|
|
7198
|
+
ok: true,
|
|
7199
|
+
pack: {
|
|
7200
|
+
format: r.pack.format, createdAt: r.pack.createdAt,
|
|
7201
|
+
source: r.pack.source, runtime: r.pack.runtime,
|
|
7202
|
+
stats: r.pack.stats, checksum: r.pack.checksum,
|
|
7203
|
+
},
|
|
7204
|
+
plan: wire,
|
|
7205
|
+
sourceMissing: !(await stat(this.projectDirOf(r.pack.source.ws)).catch(() => null)),
|
|
7206
|
+
warnings: r.warnings,
|
|
7207
|
+
}
|
|
7208
|
+
}
|
|
7209
|
+
|
|
7210
|
+
/** 导入:执行。★顺序 = 重算计划 → 备份 → 落盘 → 合并 summary → 合并用户级文件(任一步失败即中止并回报)。 */
|
|
7211
|
+
async migrateImport(opts) {
|
|
7212
|
+
const o = opts || {}
|
|
7213
|
+
const r = await this._readPack(String(o.packPath || ''))
|
|
7214
|
+
if (!r.ok) return { ok: false, error: 'pack-invalid', errors: r.errors }
|
|
7215
|
+
const targetWs = String(o.targetWs || r.pack.source.ws)
|
|
7216
|
+
if (!targetWs) return { ok: false, error: 'missing-target-ws' }
|
|
7217
|
+
const targetDir = this.projectDirOf(targetWs)
|
|
7218
|
+
let existing = {}
|
|
7219
|
+
try { existing = await this._readExistingWorkspaceFiles(targetWs) } catch (_) {}
|
|
7220
|
+
// ★重算(而非信任前端传来的 plan):防「预览与执行之间目标被改动」的 TOCTOU
|
|
7221
|
+
const plan = planImportPre(r.pack, { targetWs, existingFiles: existing, onConflict: o.onConflict, rewriteBody: o.rewriteBody })
|
|
7222
|
+
/** @type {{path:string, bytes:number, action:string}[]} */
|
|
7223
|
+
const written = []
|
|
7224
|
+
// S1:目标目录存在且非空 ⇒ 先整体备份(不可逆操作前的唯一保险)
|
|
7225
|
+
let backup = null
|
|
7226
|
+
const tst = await stat(targetDir).catch(() => null)
|
|
7227
|
+
const hasExisting = Object.keys(existing).length > 0
|
|
7228
|
+
if (tst && tst.isDirectory() && hasExisting) {
|
|
7229
|
+
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)
|
|
7230
|
+
backup = targetDir + '.bak-' + stamp
|
|
7231
|
+
try { await this.copyDir(targetDir, backup) } catch (e) { return { ok: false, error: 'backup-failed', detail: String(e && e.message || e) } }
|
|
7232
|
+
}
|
|
7233
|
+
try {
|
|
7234
|
+
await mkdir(targetDir, { recursive: true })
|
|
7235
|
+
for (const [rel, text] of Object.entries(plan.writeFiles)) {
|
|
7236
|
+
const abs = path.join(targetDir, rel)
|
|
7237
|
+
await mkdir(path.dirname(abs), { recursive: true })
|
|
7238
|
+
const tmp = abs + '.dam-tmp'
|
|
7239
|
+
await writeFile(tmp, text, 'utf8')
|
|
7240
|
+
await rename(tmp, abs)
|
|
7241
|
+
written.push({ path: rel, bytes: Buffer.byteLength(text, 'utf8'), action: 'write' })
|
|
7242
|
+
}
|
|
7243
|
+
} catch (e) {
|
|
7244
|
+
return { ok: false, error: 'write-failed', detail: String(e && e.message || e), backup, written }
|
|
7245
|
+
}
|
|
7246
|
+
// S4:summary 用 merge 不用 replace —— 目标机的其它工作区记录必须原样保留
|
|
7247
|
+
let summaryAction = 'skipped'
|
|
7248
|
+
let summaryError = null
|
|
7249
|
+
try {
|
|
7250
|
+
const cur = await this.readTextSafe(this._wsSummaryFile())
|
|
7251
|
+
const merged = mergeSummaryRecordPre(cur ? JSON.parse(cur) : null, r.pack.summaryRecord, targetWs)
|
|
7252
|
+
if (merged.changed) {
|
|
7253
|
+
const f = this._wsSummaryFile()
|
|
7254
|
+
const tmp = f + '.dam-tmp'
|
|
7255
|
+
await writeFile(tmp, JSON.stringify(merged.summary, null, 2), 'utf8')
|
|
7256
|
+
await rename(tmp, f)
|
|
7257
|
+
}
|
|
7258
|
+
summaryAction = merged.action
|
|
7259
|
+
} catch (e) { summaryError = String(e && e.message || e) }
|
|
7260
|
+
// ★v3.1.3:用户级文件(日历)落地 —— **merge 不覆盖**,与 summary 同一条纪律。
|
|
7261
|
+
// 放在 summary 之后、refresh 之前:日历改动要进 state.calendarText(_applyUserFilesPre 内已同步),
|
|
7262
|
+
// 失败不阻断导入主流程(计入 userFilesError,绝不静默)。
|
|
7263
|
+
let userFilesResult = { applied: [], skipped: [] }
|
|
7264
|
+
let userFilesError = null
|
|
7265
|
+
try { userFilesResult = await this._applyUserFilesPre(r.pack) } catch (e) { userFilesError = String(e && e.message || e) }
|
|
7266
|
+
// 索引失效:语义索引按工作区缓存,路径/内容变了必须让它重建(S5:索引本身不随包走)
|
|
7267
|
+
try { this._semanticIndexStaleByWorkspace = this._semanticIndexStaleByWorkspace || new Set(); this._semanticIndexStaleByWorkspace.add(targetWs) } catch (_) {}
|
|
7268
|
+
try { if (this.state) this.state.loadedAt = 0; await this.refresh(undefined) } catch (_) {}
|
|
7269
|
+
return {
|
|
7270
|
+
ok: true, targetWs, targetSlug: migrateSlugPre(targetWs), backup,
|
|
7271
|
+
pathChanged: plan.pathChanged, onConflict: plan.onConflict,
|
|
7272
|
+
written: written.length, writtenFiles: written.slice(0, 200),
|
|
7273
|
+
rewrite: { totalHits: plan.rewrite.totalHits, fileCount: plan.rewrite.fileCount, files: plan.rewrite.files.slice(0, 50) },
|
|
7274
|
+
additions: plan.additions.length, overwrites: plan.overwrites.length,
|
|
7275
|
+
summaryAction, summaryError,
|
|
7276
|
+
userFiles: userFilesResult.applied, userFilesSkipped: userFilesResult.skipped, userFilesError,
|
|
7277
|
+
}
|
|
7278
|
+
}
|
|
6704
7279
|
/** 工作区总览:每个工作区一个小总结 + 区内细分总结;结果缓存到用户级 ~/.dsh/memory/workspaces-summary.json(跨工作区全局)。 */
|
|
6705
7280
|
async workspaceOverview(agent, force) {
|
|
6706
7281
|
const cacheFile = path.join(dshHome(), 'memory', 'workspaces-summary.json')
|
|
@@ -6858,6 +7433,45 @@ class MemoryEngine {
|
|
|
6858
7433
|
* **无事实正文、无路径**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回 null)。
|
|
6859
7434
|
* `protected > 0` 表示已轮到删「重要项」⇒ 上限设得过低,前端据此给告警色与指引。
|
|
6860
7435
|
*/
|
|
7436
|
+
/**
|
|
7437
|
+
* ★2026-09-22(用户拍板):诊断日志只读投影 —— 让「日志」在**界面里可查**,
|
|
7438
|
+
* 不必再去翻 PowerShell 或手动找目录。与既有最小投影同一纪律:
|
|
7439
|
+
* **只出计数/大小/时间戳/路径,不出日志正文**(正文可能含工作区内容)。
|
|
7440
|
+
* 形状:{ path, dir, sizeBytes, sizeText, historyBytes, historyPath, lines, exists, rotatedNow }
|
|
7441
|
+
* 消费点:lib/client.js 诊断页签(模块级渲染,不随刷新闪烁)。
|
|
7442
|
+
*/
|
|
7443
|
+
_logsViewSnapshot() {
|
|
7444
|
+
try {
|
|
7445
|
+
const f = diagLogFile()
|
|
7446
|
+
const bak = f + '.1'
|
|
7447
|
+
let sizeBytes = 0
|
|
7448
|
+
let exists = false
|
|
7449
|
+
try { const st = statSync(f); sizeBytes = st.size; exists = true } catch (e) {}
|
|
7450
|
+
let historyBytes = 0
|
|
7451
|
+
try { historyBytes = statSync(bak).size } catch (e) {}
|
|
7452
|
+
// 行数:只在文件不太大时数(避免每次开面板都全量扫 10MB+)
|
|
7453
|
+
let lines = null
|
|
7454
|
+
if (exists && sizeBytes <= DIAG_MAX_BYTES * 2) {
|
|
7455
|
+
try {
|
|
7456
|
+
const text = readFileSync(f, 'utf8')
|
|
7457
|
+
lines = text.length ? text.replace(/\r\n$/, '').split(/\r?\n/).length : 0
|
|
7458
|
+
} catch (e) {}
|
|
7459
|
+
}
|
|
7460
|
+
return {
|
|
7461
|
+
path: f,
|
|
7462
|
+
dir: path.dirname(f),
|
|
7463
|
+
sizeBytes,
|
|
7464
|
+
sizeText: fmtBytes(sizeBytes),
|
|
7465
|
+
historyBytes,
|
|
7466
|
+
historyPath: bak,
|
|
7467
|
+
lines,
|
|
7468
|
+
exists,
|
|
7469
|
+
rotatedNow: _diagRotated,
|
|
7470
|
+
note: '诊断日志(只读投影,不含正文)。崩溃/异常时请把这个路径下的文件附给维护者。',
|
|
7471
|
+
}
|
|
7472
|
+
} catch (e) { return { exists: false, error: String((e && e.message) || e) } }
|
|
7473
|
+
}
|
|
7474
|
+
|
|
6861
7475
|
_factsPruneViewSnapshot() {
|
|
6862
7476
|
try {
|
|
6863
7477
|
const st = this._factStore
|
|
@@ -6991,6 +7605,9 @@ class MemoryEngine {
|
|
|
6991
7605
|
// (只出聚合计数与时间戳,**无事实正文、无路径**)。此前 getLastPrune/retentionLimit
|
|
6992
7606
|
// 在双侧零调用方 ⇒「事实被悄悄淘汰了」在界面上无处可查。
|
|
6993
7607
|
factsPrune: this._factsPruneViewSnapshot(),
|
|
7608
|
+
// ★2026-09-22(用户拍板):诊断日志投影 —— 与既有最小投影同一出口、同一纪律。
|
|
7609
|
+
// 回答:「日志在哪、多大、多少行」——用户不必再翻 PowerShell 或手动找目录。
|
|
7610
|
+
logs: this._logsViewSnapshot(),
|
|
6994
7611
|
runtimes: this.runtimes.values().map((rt) => ({
|
|
6995
7612
|
key: rt.key,
|
|
6996
7613
|
sessionId: rt.sessionId,
|
|
@@ -7091,6 +7708,44 @@ class MemoryEngine {
|
|
|
7091
7708
|
return '已保存反思 ' + file
|
|
7092
7709
|
}
|
|
7093
7710
|
|
|
7711
|
+
/**
|
|
7712
|
+
* ★v3.1.3:**同步目录(云盘/远端)探测** —— 用户要求「增加一个在云盘或者远端同步的接口,
|
|
7713
|
+
* 尝试看看能不能做好兼容性」。
|
|
7714
|
+
*
|
|
7715
|
+
* 定位:**不发明私有同步协议、不做后台自动同步**(延续用户既有拍板)。本方法只做两件事:
|
|
7716
|
+
* ① 判断这个目录现在能不能用(存在 / 可写);
|
|
7717
|
+
* ② 给出人话诊断,帮用户把「导出目录指向云盘」这一步走对。
|
|
7718
|
+
*
|
|
7719
|
+
* 兼容性做法:**只认为它是个普通目录** —— OneDrive / 坚果云 / Dropbox / iCloud / 网络盘 / U 盘 /
|
|
7720
|
+
* 本地路径一律同等对待:不嗅探客户端、不依赖专有 API、不假设路径形态、不要求特定盘符。
|
|
7721
|
+
*
|
|
7722
|
+
* @param {string} dir 待检测目录(空 ⇒ 用配置里的 syncDir)
|
|
7723
|
+
* @returns {{ok:boolean, dir:string, exists:boolean, writable:boolean, looksSynced:boolean, hint:string}}
|
|
7724
|
+
*/
|
|
7725
|
+
async syncDirProbe(dir) {
|
|
7726
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
7727
|
+
const want = String(dir || (this.config && this.config.syncDir) || '').trim()
|
|
7728
|
+
if (!want) {
|
|
7729
|
+
return { ok: false, dir: '', exists: false, writable: false, looksSynced: false,
|
|
7730
|
+
hint: '未配置同步目录。填写你的云盘本地目录(如 OneDrive/坚果云 的同步文件夹)即可把导出包自动带过去;也可留空走默认导出目录。' }
|
|
7731
|
+
}
|
|
7732
|
+
const expanded = this.expandUserPath(want) || want
|
|
7733
|
+
let exists = false, writable = false
|
|
7734
|
+
try { const st = await stat(expanded); exists = !!st.isDirectory() } catch (_) { exists = false }
|
|
7735
|
+
if (exists) {
|
|
7736
|
+
const probe = path.join(expanded, '.dam-sync-probe-' + Date.now())
|
|
7737
|
+
try { await writeFile(probe, 'ok'); await unlink(probe); writable = true } catch (_) { writable = false }
|
|
7738
|
+
}
|
|
7739
|
+
// 「像同步盘」只是**提示**,不作门槛:命中常见客户端名就给一句更贴心的说明。
|
|
7740
|
+
const looksSynced = /onedrive|dropbox|nutstore|坚果云|icloud|googledrive|google drive|baidunetdisk|百度网盘/i.test(expanded)
|
|
7741
|
+
let hint
|
|
7742
|
+
if (!exists) hint = '该目录不存在。请确认路径拼写;若是网络盘,先在文件管理器里连上再试。'
|
|
7743
|
+
else if (!writable) hint = '目录存在但**不可写**。若是云盘,可能还在首次同步或被占用;换个位置或稍后重试。'
|
|
7744
|
+
else if (looksSynced) hint = '目录可用,且看起来是同步盘。导出到这里的包会被云盘自动同步到另一台机器;到那边用「导入包」显式导入即可(靠包内校验和对齐,不做后台自动同步)。'
|
|
7745
|
+
else hint = '目录可用。若不是同步盘,这个包不会自动到另一台机器——请手动拷贝(网盘上传/邮件/U 盘均可)。'
|
|
7746
|
+
return { ok: exists && writable, dir: expanded, exists, writable, looksSynced, hint }
|
|
7747
|
+
}
|
|
7748
|
+
|
|
7094
7749
|
// ---------- 日历/日程(用户级 CALENDAR.md) ----------
|
|
7095
7750
|
/** 解析 CALENDAR.md 为条目数组。 */
|
|
7096
7751
|
parseCalendar(text) {
|
|
@@ -9205,8 +9860,11 @@ export function apply(ctx, config) {
|
|
|
9205
9860
|
// 新bug修复①·启动预热(2026-09-08):重启后首轮 renderMemoryDynamic 可能先于 _doRefresh 完成,
|
|
9206
9861
|
// 此时 globalLedgerPath 尚未就位,兜底行会错过它要救的那一轮。挂载即异步预热全局账本缓存(只读扫描,零副作用)。
|
|
9207
9862
|
try { void engine.findLatestGlobalHandoff().then((v) => { if (!engine._globalLedgerCache) engine._globalLedgerCache = { at: Date.now(), value: v || '' } }).catch(() => {}) } catch (e) {}
|
|
9863
|
+
engine._recallStats = createRecallStatsPre({
|
|
9864
|
+
file: () => path.join(dshHome(), 'memory', 'recall-stats.json'),
|
|
9865
|
+
})
|
|
9208
9866
|
// M4-3:Shadow Retrieval Host 接线(三开关全开时才构造状态/IO;默认关闭零留存)
|
|
9209
|
-
engine._shadowHost = createShadowHost({ engine })
|
|
9867
|
+
engine._shadowHost = createShadowHost({ engine, onDiag: (key, msg) => diagThrottled(key, msg), recallStats: engine._recallStats })
|
|
9210
9868
|
// M5-3:Context Bridge Host 接线(assoc+contextBridge 双门;默认关闭零构造/零 IO)
|
|
9211
9869
|
engine._contextHost = createContextHost({ engine })
|
|
9212
9870
|
// R3(2026-09-18):降级台账。挂在引擎上,与各 host 同层 —— 跨臂统一,不分散到各 host。
|
|
@@ -9218,10 +9876,25 @@ export function apply(ctx, config) {
|
|
|
9218
9876
|
// 注意:**绝不能**把常规观测塞进 `_degradeSink.record` —— 台账判据是「只记预期外失败」,
|
|
9219
9877
|
// 混入后「有没有降级」将永远非空,降级信号被淹没。
|
|
9220
9878
|
engine._quotaProbe = createQuotaProbePre({})
|
|
9879
|
+
// ③ 召回统计(2026-09-22 用户拍板「先只记录、不加权」)—— 数据源给面板「统计」页签。
|
|
9880
|
+
// ★为什么只记录:加权会自我强化(召回多→权重高→更易被召回),在埋点口径被真实数据检验前加权
|
|
9881
|
+
// 会把口径错误放大成系统性偏差。故本模块**只累积计数并落盘**,召回排序与返回逐字节不变
|
|
9882
|
+
// (守卫 smoke-test-recall-stats-pre.mjs 断言:埋点调用不改任何排序输入)。
|
|
9883
|
+
// file 传**函数**而非字符串:DSH_HOME 可能在运行期变化,每次落盘重新解析路径。
|
|
9884
|
+
|
|
9221
9885
|
// 注:臂健康快照(deriveArmsHealthPre)在各臂执行路径上按实际情况标注,
|
|
9222
9886
|
// 见 recall() 内 semantic/evidence 段与 _l0IndexSync 段。
|
|
9223
9887
|
// M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
|
|
9224
9888
|
engine._activationHost = createActivationHost({ engine })
|
|
9889
|
+
// ★issue#104(2026-09-22 移植自 PR #118 / 孤儿快照 475abfe):把两个 host **回填给 runtime 释放路径**。
|
|
9890
|
+
// `SessionRuntimeStore.dispose()` 里按 `this._shadowHost` / `this._activationHost` 清理 per-runtime 态,
|
|
9891
|
+
// 而这两个属性只挂在 engine 上 ⇒ 不回填则两处 `if` 恒假、清理恒不执行(激活态与 shadow 态随会话数单调增长)。
|
|
9892
|
+
// runtimes 在引擎构造期创建,早于各 host ⇒ 只能在此处回填。
|
|
9893
|
+
// ★回归警示:3.0.1 发布提交 53d20e7 曾整行删掉 activation 侧的回填,而旧测试只断言 dispose
|
|
9894
|
+
// 函数体里的**字符串** ⇒ 静默回退。现由 smoke-test-issue103-105-portfix-pre.mjs 的「消费侧行为」
|
|
9895
|
+
// 断言 + 本处「生产侧成对」断言双锁住守,删任一侧必红。
|
|
9896
|
+
engine.runtimes._shadowHost = engine._shadowHost
|
|
9897
|
+
engine.runtimes._activationHost = engine._activationHost
|
|
9225
9898
|
// M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
|
|
9226
9899
|
engine._indexSyncHost = createIndexSyncHostPre({
|
|
9227
9900
|
engine,
|
|
@@ -9270,6 +9943,9 @@ export function apply(ctx, config) {
|
|
|
9270
9943
|
const factStore = createFactStorePre({ config: { maxFacts: Number(engine.config.factRetentionMax) || 1000 }, io: hubIo('facts.json') })
|
|
9271
9944
|
engine._factStore = factStore
|
|
9272
9945
|
engine._memoryHub = createMemoryHubPre({
|
|
9946
|
+
// issue #110:把 io 工厂的批控制交给 hub 本身 ⇒ 所有批量喂数入口(喂数循环、HTTP
|
|
9947
|
+
// `action=feed`、将来的重放器)都自动「一批一次落盘」,不必各自记得在外面 begin/end。
|
|
9948
|
+
batch: hubIo,
|
|
9273
9949
|
stores: {
|
|
9274
9950
|
episodic: createEpisodicStorePre({ config: { minSegments: Number(engine.config.episodicMinSegments) || 2, retention: Number(engine.config.episodicRetention) || 256 }, io: hubIo('episodes.json') }),
|
|
9275
9951
|
facts: factStore,
|
|
@@ -9328,7 +10004,15 @@ export function apply(ctx, config) {
|
|
|
9328
10004
|
// 写回:P1④ 主闭环——hub.facts 中 confirmed/未过期/未撤销/置信≥0.6 的事实,每日限额
|
|
9329
10005
|
// 治理式写入 notesPath/userFile(autoConsolidate 同款 appendText 原子事务)→ 进 M7 语料。
|
|
9330
10006
|
{
|
|
9331
|
-
const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs
|
|
10007
|
+
const hubFeedState = { shadow: null } // 文件指纹 {size,mtimeMs}
|
|
10008
|
+
// ★issue#103(2026-09-22 移植自 PR #119):环形 JSONL 的增量游标 —— **内容指纹**,替代原来的行数游标。
|
|
10009
|
+
// 为什么不能用行数:该文件由 Python 侧按「保尾丢弃」维护(SHADOW_LOG_MAX=256,每次重写为
|
|
10010
|
+
// lines[-256:])⇒ 写满后 lines.length 恒等于上次的 count,增量区间恒为空 ⇒ 消费端永久停摆,
|
|
10011
|
+
// 且因为「没有新行」是合法状态而**不留任何痕迹**、事后不可发现。
|
|
10012
|
+
// maxSeen 取环形上限 256 的 4 倍,避免指纹被淘汰后旧行被重复交付。
|
|
10013
|
+
// ★本次移植实测教训:锚点不匹配会让整块补丁被静默跳过,而 `node --check` 与静态守卫**都不会报**
|
|
10014
|
+
// (引用在另一处)⇒ 运行期才炸 ReferenceError。故补丁脚本必须配「声明与使用成对」的哨兵断言。
|
|
10015
|
+
const hubFeedCursor = createJsonlTailCursorPre({ maxSeen: 1024 })
|
|
9332
10016
|
const hubFlushState = { date: '', count: 0, flushed: {} } // flushed[factId]=true;持久化 hub/flush-state.json
|
|
9333
10017
|
const hubFlushFile = () => path.join(dshHome(), 'memory', 'hub', 'flush-state.json')
|
|
9334
10018
|
const hubFlushLoad = () => {
|
|
@@ -9374,15 +10058,18 @@ export function apply(ctx, config) {
|
|
|
9374
10058
|
const f = path.join(dshHome(), 'memory', 'semantic', 'judgement-shadow.jsonl')
|
|
9375
10059
|
let st
|
|
9376
10060
|
try { st = statSync(f) } catch (_) { return }
|
|
9377
|
-
const prev = hubFeedState.shadow
|
|
9378
|
-
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) return
|
|
9379
10061
|
const lines = readFileSync(f, 'utf8').split('\n').filter((l) => l.trim())
|
|
9380
|
-
|
|
10062
|
+
// ★issue#103(2026-09-22 移植自 PR #119):游标**不能再用行数**。该文件由 Python 侧按
|
|
10063
|
+
// 「保尾丢弃」维护(SHADOW_LOG_MAX=256,每次重写为 lines[-256:])⇒ 写满后 lines.length 恒等于
|
|
10064
|
+
// 上次的 count,增量区间恒为空、消费端永久停摆,而且"没有新行"是合法状态、不留任何痕迹。
|
|
10065
|
+
// 改为行内容指纹:环内每行只交付一次,与截断无关。(首次 take() 只登记现状不喂历史行,
|
|
10066
|
+
// 避免重启后把环里旧判据再吃一遍导致 observe 重复计数。)
|
|
10067
|
+
const cursor = (hubFeedCursor.take(lines).fresh)
|
|
9381
10068
|
const newRows = []
|
|
9382
|
-
for (let i =
|
|
9383
|
-
try { const d = JSON.parse(
|
|
10069
|
+
for (let i = 0; i < cursor.length; i++) {
|
|
10070
|
+
try { const d = JSON.parse(cursor[i]); if (d && d.kindCandidate) newRows.push(d) } catch (_) {}
|
|
9384
10071
|
}
|
|
9385
|
-
hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs
|
|
10072
|
+
hubFeedState.shadow = { size: st.size, mtimeMs: st.mtimeMs }
|
|
9386
10073
|
if (!newRows.length) return
|
|
9387
10074
|
// 异步富化+喂送(不阻塞 tick);单行失败静默
|
|
9388
10075
|
void (async () => {
|
|
@@ -9989,6 +10676,9 @@ export function apply(ctx, config) {
|
|
|
9989
10676
|
try { if (engine._ownSubagents && engine._ownSubagents.has(agent)) return } catch (e) {}
|
|
9990
10677
|
// 最后活动时间写入该 agent 自己的 runtime(不再落到 currentRuntime()/default)
|
|
9991
10678
|
try { engine.runtimeFor(agent).lastActiveAt = Date.now() } catch (e) {}
|
|
10679
|
+
// ★2026-09-22(用户拍板):新工作区自动首建白板骨架 —— 只写一次、fail-soft、不阻塞收尾。
|
|
10680
|
+
// 放在水位测量之前:首建是"产物存在性"兜底,与水位阈值无关,新工作区首轮就该落下来。
|
|
10681
|
+
try { void engine.ensurePlanBoardForAgentPre(agent).catch(function () {}) } catch (eSeed) {}
|
|
9992
10682
|
// M-CM4 水位感知:每轮测量会话消息体量,越阈值→advisory+自动骨架账本(异步,不阻塞收尾)
|
|
9993
10683
|
try { void engine.checkWaterLevel(agent).then(function () {
|
|
9994
10684
|
// M-CM6-C 宿主兜底:轮末测量完成后若达标 → arm 倒计时(浏览器活着时由它展示确认卡;被节流/关闭则宿主到期自执行)
|
|
@@ -10083,6 +10773,14 @@ export function apply(ctx, config) {
|
|
|
10083
10773
|
try {
|
|
10084
10774
|
const agent = context && context.agent
|
|
10085
10775
|
if (!agent) return ''
|
|
10776
|
+
// ★v3.1.3(用户拍板「接线」):`injectEnabled` 此前是**死开关** —— 键白名单内有、
|
|
10777
|
+
// UI 能翻面能落盘,但全 lib/ 零读取点 ⇒ 用户关掉后记忆照旧每轮注入(界面骗人)。
|
|
10778
|
+
// 现在接入本回调最前面,作为**动态快照总闸**:关 = 真不注入。
|
|
10779
|
+
// ⚠️解耦(用户硬性要求「单一开关不得顺带改变其他功能」):只关这**一条**通路,不动
|
|
10780
|
+
// · 静态规则段 `renderMemoryStatic()`(systemPrompt.section,下方 :~10900,缓存锚 + 属另一功能)
|
|
10781
|
+
// · M6 tail surface(下方 :~10910)
|
|
10782
|
+
// · 记忆写入(memory_log / 自动沉淀)与检索(memory_recall)—— 关注入 ≠ 关记忆
|
|
10783
|
+
if (engine.config && engine.config.injectEnabled === false) return ''
|
|
10086
10784
|
// M0/M1: 绑定到该 agent 的 runtime 再读取(state 是 per-session getter,避免落到 default runtime 读到空)
|
|
10087
10785
|
return engine.withAgent(agent, () => {
|
|
10088
10786
|
const st = engine.stateFor(agent)
|
|
@@ -10250,7 +10948,7 @@ export function apply(ctx, config) {
|
|
|
10250
10948
|
return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
|
|
10251
10949
|
}),
|
|
10252
10950
|
|
|
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
|
|
10951
|
+
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
10952
|
content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
|
|
10255
10953
|
action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
|
|
10256
10954
|
kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
|
|
@@ -10465,12 +11163,47 @@ export function apply(ctx, config) {
|
|
|
10465
11163
|
title: { type: 'string', required: true, description: '事项标题。' },
|
|
10466
11164
|
}, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
|
|
10467
11165
|
|
|
11166
|
+
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)。**调用后必须在本轮回复正文中向用户转述改了什么**。', {
|
|
11167
|
+
op: { type: 'string', enum: ['list', 'add', 'update', 'remove'], required: true, description: 'list=列出; add=追加; update=改写一条(需 index+expect+content); remove=真删一条(需 index+expect)。' },
|
|
11168
|
+
index: { type: 'number', description: '条目序号(0 基,来自 op=list 的顺序)。' },
|
|
11169
|
+
expect: { type: 'string', description: '该条**当前**文本(内容锚定,防索引漂移)。update/remove 必填。' },
|
|
11170
|
+
content: { type: 'string', description: 'update/add 的新文本(单行,≤2000 字符)。' },
|
|
11171
|
+
dateSection: { type: 'string', description: 'add 可选:追加到指定日期段(YYYY-MM-DD);缺省追加到文件头部(用户手写区)。' },
|
|
11172
|
+
}, async (args) => {
|
|
11173
|
+
const op = String(args.op || '')
|
|
11174
|
+
if (op === 'list') {
|
|
11175
|
+
const r0 = await applyRuleEditPre(engine, 'list', {})
|
|
11176
|
+
if (!r0.ok) return 'memory_rules: ' + r0.error
|
|
11177
|
+
const rows = (r0.items || []).map((x, i) => '[' + i + '] (' + x.source + (x.dateSection ? ' ' + x.dateSection : '') + ') ' + x.text)
|
|
11178
|
+
let extra = ''
|
|
11179
|
+
try {
|
|
11180
|
+
const lay = extractRulesLayerPre({ userText: await engine.readTextSafe(r0.path), rulesLayeringMode: 'self' })
|
|
11181
|
+
const n = (lay.retired || []).length
|
|
11182
|
+
if (n) extra = '\\n注:其中 ' + n + ' 条 status≠current,**已不再进规则段**(仍可用 remove 真删)。'
|
|
11183
|
+
} catch (_) {}
|
|
11184
|
+
return '用户级硬约束共 ' + rows.length + ' 条(0 基):\\n' + rows.join('\\n') + extra
|
|
11185
|
+
}
|
|
11186
|
+
if (op === 'add') {
|
|
11187
|
+
const gate = sanitizeForWrite(String(args.content || ''), { maxEntryChars: 2000 })
|
|
11188
|
+
if (!gate.ok) return 'memory_rules: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
11189
|
+
const ra = await applyRuleEditPre(engine, 'add', { text: gate.clean || String(args.content || ''), dateSection: args.dateSection })
|
|
11190
|
+
if (!ra.ok) return 'memory_rules: ' + ra.error
|
|
11191
|
+
return '已追加规则,现共 ' + (ra.items || []).length + ' 条。\\n' + ra.preview
|
|
11192
|
+
}
|
|
11193
|
+
const r = await applyRuleEditPre(engine, op, { index: args.index, expect: args.expect, text: args.content }, { requireExpect: true })
|
|
11194
|
+
if (!r.ok) return 'memory_rules: ' + r.error
|
|
11195
|
+
return (op === 'remove' ? '已删除' : '已改写') + '第 ' + args.index + ' 条,现共 ' + (r.items || []).length + ' 条。\\n' + r.preview
|
|
11196
|
+
}),
|
|
11197
|
+
|
|
10468
11198
|
defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。★**与「自动沉淀」的边界**:每轮对话结束框架会**自动**评估并写日志/升格要点,**你不需要为日常轮次做这件事**;本工具是**你主动发起的加料**——隔一段时间(或一个阶段收尾时)用来做一次更彻底的提炼,读的是**多日日志**、输出进项目笔记与用户级记忆。⇒ 用它做「阶段性固化」,不要用它替代每轮的 memory_log。', {
|
|
10469
11199
|
days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
|
|
10470
11200
|
}, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
|
|
10471
11201
|
|
|
10472
11202
|
// ★★★ T4(2026-09-19 用户拍板「让大模型来介入 procedure memory」)★★★
|
|
10473
|
-
// **工具数
|
|
11203
|
+
// **工具数 17→18**(无条件注册;P9 新增 memory_rules)。★硬锁联动**六处**(原注释只写四处,
|
|
11204
|
+
// 实测还漏了 p23-wb-sidecar 与 docs/HANDBOOK.md §6 表 ⇒ 加工具时一并改):
|
|
11205
|
+
// tests/smoke/smoke-test.mjs / m3b3-pre / context-observer / graph-mode-pre / p23-wb-sidecar-pre / docs/HANDBOOK.md。
|
|
11206
|
+
// 另有发版侧两表必然漏登记(memory_rules,见 tools/release.mjs 的 T7-e 注与 smoke-test-t7e)。
|
|
10474
11207
|
// 背景:此前三条记忆线(episodic / semantic / procedural)**全部只有机械生成**,
|
|
10475
11208
|
// 模型没有任何写入通路。procedural 线的唯一来源是 `memory-hub.js` 的 crossFeed:
|
|
10476
11209
|
// 它把每个"成功 episode"机械切成候选,而 episode 的 actions 就是 `['user','user','user']`
|
|
@@ -10726,6 +11459,18 @@ export function apply(ctx, config) {
|
|
|
10726
11459
|
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
10727
11460
|
},
|
|
10728
11461
|
},
|
|
11462
|
+
{
|
|
11463
|
+
kind: 'exact',
|
|
11464
|
+
path: API['kanban-card'],
|
|
11465
|
+
handler: async (req, res) => {
|
|
11466
|
+
// ★v3.1.2:单卡全文(GET 只读;loopback)。看板载荷不再内联 full,展开时按 id 单取。
|
|
11467
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11468
|
+
try {
|
|
11469
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
11470
|
+
return writeJson(res, 200, await engine.kanbanCardBody(url.searchParams.get('sessionId'), url.searchParams.get('id')))
|
|
11471
|
+
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11472
|
+
},
|
|
11473
|
+
},
|
|
10729
11474
|
{
|
|
10730
11475
|
kind: 'exact',
|
|
10731
11476
|
path: API['kanban-board'],
|
|
@@ -11050,7 +11795,24 @@ export function apply(ctx, config) {
|
|
|
11050
11795
|
const body = await readJsonBody(req).catch(() => ({}))
|
|
11051
11796
|
const action = String((body && body.action) || '')
|
|
11052
11797
|
if (action === 'consolidate') return writeJson(res, 200, hub.consolidateEpisodes())
|
|
11053
|
-
if (action === 'feed')
|
|
11798
|
+
if (action === 'feed') {
|
|
11799
|
+
// ★#110(2026-09-22):HTTP feed 入口也要走批内合并落盘 —— 旧实现直调 ingestJudgementRows,
|
|
11800
|
+
// 每行一次 upsert 就是一次整份快照写盘(N 行 = N 次写放大)。批控制与 hub 喂数循环同源,
|
|
11801
|
+
// 挂在 engine._hubIoFactory;批末一定 flush(finally),任何行抛错也不会把已接受的改动留在内存。
|
|
11802
|
+
const rows = Array.isArray(body.rows) ? body.rows : []
|
|
11803
|
+
const ioFactory = engine._hubIoFactory
|
|
11804
|
+
let fedResult
|
|
11805
|
+
try {
|
|
11806
|
+
if (ioFactory && typeof ioFactory.beginBatch === 'function') ioFactory.beginBatch()
|
|
11807
|
+
fedResult = hub.ingestJudgementRows(rows)
|
|
11808
|
+
} finally {
|
|
11809
|
+
if (ioFactory && typeof ioFactory.endBatch === 'function') {
|
|
11810
|
+
const b = ioFactory.endBatch()
|
|
11811
|
+
if (b && b.ok === false) diag('hub feed(http) batch: 有快照落盘失败 —— 原因见 hubIo 健康度(debugInfo().associativeMemory.hubIo)')
|
|
11812
|
+
}
|
|
11813
|
+
}
|
|
11814
|
+
return writeJson(res, 200, fedResult)
|
|
11815
|
+
}
|
|
11054
11816
|
if (action === 'render') return writeJson(res, 200, { checklists: hub.renderChecklists() })
|
|
11055
11817
|
if (action === 'crossfeed') return writeJson(res, 200, hub.crossFeed(String((body && body.sessionRef) || '')))
|
|
11056
11818
|
// M9 审批动作(2026-08-30,G-02 同款 loopback+append-only 精神):用户在 hubTab
|
|
@@ -11262,6 +12024,51 @@ export function apply(ctx, config) {
|
|
|
11262
12024
|
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11263
12025
|
},
|
|
11264
12026
|
},
|
|
12027
|
+
{
|
|
12028
|
+
kind: 'exact',
|
|
12029
|
+
path: API['migrate-export'],
|
|
12030
|
+
handler: async (req, res) => {
|
|
12031
|
+
// 迁移搬包 P1:导出。loopback-only(记忆含隐私,绝不对外)。
|
|
12032
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
12033
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
12034
|
+
const body = await readJsonBody(req).catch(() => ({}))
|
|
12035
|
+
try {
|
|
12036
|
+
// ★v3.1.2 修:此前回退段用的是 `_lastAgent.cwd`,而该字段不存在 ——
|
|
12037
|
+
// 全库权威口径是 session.header.cwd(见 :2324 / :5781 / :10628),本文件 :2799 注释亦明写
|
|
12038
|
+
// 「不再用不存在的 agent.cwd」⇒ 回退段恒空,前端不传 ws 即 missing-ws(用户实测)。
|
|
12039
|
+
const _la = engine._lastAgent
|
|
12040
|
+
const _lacwd = (_la && _la.session && _la.session.header && _la.session.header.cwd) || ''
|
|
12041
|
+
const ws = String((body && body.ws) || _lacwd || '')
|
|
12042
|
+
writeJson(res, 200, await engine.migrateExport({ ws, outPath: body && body.outPath, compress: !(body && body.compress === false) }))
|
|
12043
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
12044
|
+
},
|
|
12045
|
+
},
|
|
12046
|
+
{
|
|
12047
|
+
kind: 'exact',
|
|
12048
|
+
path: API['migrate-inspect'],
|
|
12049
|
+
handler: async (req, res) => {
|
|
12050
|
+
// 迁移搬包 P2:预览差异(只读,不写任何文件)。
|
|
12051
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
12052
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
12053
|
+
const body = await readJsonBody(req).catch(() => ({}))
|
|
12054
|
+
try {
|
|
12055
|
+
writeJson(res, 200, await engine.migrateInspect({ packPath: body && body.packPath, targetWs: body && body.targetWs, onConflict: body && body.onConflict, rewriteBody: !(body && body.rewriteBody === false) }))
|
|
12056
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
12057
|
+
},
|
|
12058
|
+
},
|
|
12059
|
+
{
|
|
12060
|
+
kind: 'exact',
|
|
12061
|
+
path: API['migrate-import'],
|
|
12062
|
+
handler: async (req, res) => {
|
|
12063
|
+
// 迁移搬包 P3:执行(先备份后落盘;冲突默认 keep)。
|
|
12064
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
12065
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
12066
|
+
const body = await readJsonBody(req).catch(() => ({}))
|
|
12067
|
+
try {
|
|
12068
|
+
writeJson(res, 200, await engine.migrateImport({ packPath: body && body.packPath, targetWs: body && body.targetWs, onConflict: body && body.onConflict, rewriteBody: !(body && body.rewriteBody === false) }))
|
|
12069
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
12070
|
+
},
|
|
12071
|
+
},
|
|
11265
12072
|
{
|
|
11266
12073
|
kind: 'exact',
|
|
11267
12074
|
path: API.debug,
|
|
@@ -11321,8 +12128,22 @@ export function apply(ctx, config) {
|
|
|
11321
12128
|
handler: async (req, res) => {
|
|
11322
12129
|
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11323
12130
|
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11324
|
-
//
|
|
12131
|
+
// 调用系统的文件夹选择器。★v3.1.2 修:此前只试 `ctx.directoryPicker.capability()` 一种形态,
|
|
12132
|
+
// 与 DSH 内核标准契约不符 ⇒ 恒返回 native:false(用户实测「选择器不可用,请手动填写路径」)。
|
|
12133
|
+
// 内核形态(@linxin666/dsh-web-all / dsh-client-ui-skin-center 两处同源):
|
|
12134
|
+
// const r = await ctx.remote.directoryPicker.pick(); if (!r.ok) throw ...; return r.value
|
|
12135
|
+
// 这里做**双路径兼容**:内核契约优先 → 旧 capability 形态回退 → 都不可用才 native:false。
|
|
11325
12136
|
try {
|
|
12137
|
+
// 路径①:内核标准契约(远程/桌面形态下挂在 ctx.remote 上)
|
|
12138
|
+
let remotePicker = undefined
|
|
12139
|
+
try { remotePicker = ctx.remote && ctx.remote.directoryPicker } catch (e) {}
|
|
12140
|
+
if (remotePicker && typeof remotePicker.pick === 'function') {
|
|
12141
|
+
const r = await remotePicker.pick()
|
|
12142
|
+
if (r && r.ok === false) return writeJson(res, 200, { native: true, dir: null, error: String((r.error && r.error.message) || r.error || 'picker failed') })
|
|
12143
|
+
const v = r && (r.value !== undefined ? r.value : r)
|
|
12144
|
+
return writeJson(res, 200, { native: true, dir: (typeof v === 'string' ? v : (v && v.path) || null) })
|
|
12145
|
+
}
|
|
12146
|
+
// 路径②:本机既有形态(native 后端由 directory-picker-auto 按主机情况挂载)
|
|
11326
12147
|
let picker = undefined
|
|
11327
12148
|
try { picker = ctx.directoryPicker } catch (e) {}
|
|
11328
12149
|
if (!picker || typeof picker.capability !== 'function') return writeJson(res, 200, { native: false })
|
|
@@ -11338,6 +12159,28 @@ export function apply(ctx, config) {
|
|
|
11338
12159
|
},
|
|
11339
12160
|
},
|
|
11340
12161
|
{
|
|
12162
|
+
kind: 'exact',
|
|
12163
|
+
path: API.recallStats,
|
|
12164
|
+
handler: async (req, res) => {
|
|
12165
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
12166
|
+
const method = req.method || 'GET'
|
|
12167
|
+
// 只读:GET 取快照;POST 仅用于显式复位(面板按钮),同样 loopback-only。
|
|
12168
|
+
if (method !== 'GET' && method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
12169
|
+
try {
|
|
12170
|
+
if (!engine._recallStats) return writeJson(res, 200, { enabled: false })
|
|
12171
|
+
if (method === 'POST') {
|
|
12172
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
12173
|
+
if (url.searchParams.get('reset') === '1') {
|
|
12174
|
+
engine._recallStats.reset()
|
|
12175
|
+
return writeJson(res, 200, { ok: true, reset: true })
|
|
12176
|
+
}
|
|
12177
|
+
return writeJson(res, 400, { error: 'unknown action' })
|
|
12178
|
+
}
|
|
12179
|
+
writeJson(res, 200, { enabled: true, stats: engine._recallStats.snapshot() })
|
|
12180
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
12181
|
+
},
|
|
12182
|
+
},
|
|
12183
|
+
{
|
|
11341
12184
|
kind: 'exact',
|
|
11342
12185
|
path: API.updateCheck,
|
|
11343
12186
|
handler: async (req, res) => {
|
|
@@ -11362,12 +12205,40 @@ export function apply(ctx, config) {
|
|
|
11362
12205
|
if (!prof) return writeJson(res, 200, { ok: false, message: '未找到 dsh profile 目录(未安装插件或已删除)。' })
|
|
11363
12206
|
if (prof.installKind === 'dev-link') return writeJson(res, 200, { ok: false, message: '当前为本地开发链接(link:)安装,不适用 npm 更新,请直接同步开发源码。' })
|
|
11364
12207
|
if (prof.installKind !== 'registry') return writeJson(res, 200, { ok: false, message: '未检测到 registry 安装方式,无法自动更新。' })
|
|
11365
|
-
|
|
12208
|
+
// ★v3.1.2 修:pnpm 11 默认开启「1 天 Minimum Release Age」,对发布不足 24h 的版本**静默跳过**
|
|
12209
|
+
// (3.1.1 当天发布即中招)⇒ 旧命令 exit 0 但什么都没装,路由还报「成功」。
|
|
12210
|
+
// 改为:钉死目标版本(latest 也解析成具体版本号)+ 显式关闸门 + 装后复核(见下)。
|
|
12211
|
+
const target = String((await engine.checkUpdate(true)).latest || '').trim()
|
|
12212
|
+
const spec = '@a9i5k4/dsh-auto-memory' + (target ? '@' + target : '@latest')
|
|
12213
|
+
const cmd = prof.usesPnpm
|
|
12214
|
+
? 'pnpm add ' + spec + ' --config.minimumReleaseAge=0'
|
|
12215
|
+
: 'npm install ' + spec + ' --min-release-age=0'
|
|
11366
12216
|
const out = await execP(cmd, { cwd: prof.dir, timeout: 120000, windowsHide: true })
|
|
11367
12217
|
const tail = ((out.stdout || '') + (out.stderr || '')).trim().slice(-1200)
|
|
12218
|
+
// ★v3.1.2:装后复核 —— 旧实现无条件报成功,供应链闸门静默跳过时用户被谎报「安装成功」。
|
|
12219
|
+
// 读 profile 里该包的真实 version;与目标不符即 ok:false(并把命令输出带回去供排查)。
|
|
12220
|
+
let landed = ''
|
|
12221
|
+
try {
|
|
12222
|
+
const pj = await this.readTextSafe(path.join(prof.dir, 'node_modules', '@a9i5k4', 'dsh-auto-memory', 'package.json'))
|
|
12223
|
+
if (pj) { try { landed = String(JSON.parse(pj).version || '') } catch (_) {} }
|
|
12224
|
+
} catch (_) {}
|
|
12225
|
+
const want = target || landed
|
|
12226
|
+
if (want && landed !== want) {
|
|
12227
|
+
return writeJson(res, 200, {
|
|
12228
|
+
ok: false,
|
|
12229
|
+
message: (landed ? ('安装后版本仍为 ' + landed + ',目标 ' + want) : '未检测到已安装的包版本')
|
|
12230
|
+
+ '(若为 pnpm 11 供应链闸门所致,命令已带 --config.minimumReleaseAge=0,请把下方输出反馈给作者)',
|
|
12231
|
+
output: tail || '(无输出)',
|
|
12232
|
+
landedVersion: landed,
|
|
12233
|
+
targetVersion: want,
|
|
12234
|
+
installKind: prof.installKind,
|
|
12235
|
+
})
|
|
12236
|
+
}
|
|
11368
12237
|
writeJson(res, 200, {
|
|
11369
12238
|
ok: true,
|
|
11370
12239
|
output: tail || '(无输出)',
|
|
12240
|
+
landedVersion: landed,
|
|
12241
|
+
targetVersion: want,
|
|
11371
12242
|
restartHint: '更新完成,请重启 dsh web 生效。',
|
|
11372
12243
|
installKind: prof.installKind,
|
|
11373
12244
|
})
|
|
@@ -11384,7 +12255,15 @@ export function apply(ctx, config) {
|
|
|
11384
12255
|
const method = req.method || 'GET'
|
|
11385
12256
|
try {
|
|
11386
12257
|
if (method === 'GET') {
|
|
11387
|
-
|
|
12258
|
+
// ★P10-T1(2026-09-22):把「可关的注入分区」清单**从宿主常量发给前端**——设置页
|
|
12259
|
+
// 二级页据此枚举开关,前端不得硬编码第二份清单(守卫断言集合相等)。
|
|
12260
|
+
// 搭本路由的便车而非新开路由:避免 API 路径锁(A1/A2/A4)三处同步的额外风险。
|
|
12261
|
+
writeJson(res, 200, {
|
|
12262
|
+
config: await engine.loadConfig(),
|
|
12263
|
+
path: engine._configPath,
|
|
12264
|
+
promptSections: PROMPT_SECTION_KEYS_V1,
|
|
12265
|
+
promptSectionMust: PROMPT_SECTION_MUST_V1,
|
|
12266
|
+
})
|
|
11388
12267
|
return
|
|
11389
12268
|
}
|
|
11390
12269
|
if (method === 'POST' || method === 'PUT') {
|
|
@@ -11403,6 +12282,22 @@ export function apply(ctx, config) {
|
|
|
11403
12282
|
patch[key] = body[key].map((x) => x.trim()).filter(Boolean)
|
|
11404
12283
|
continue
|
|
11405
12284
|
}
|
|
12285
|
+
// ★#117 P3-20②(2026-09-22):路径类键 fail-closed —— 旧实现对绝大多数键裸赋值,
|
|
12286
|
+
// 而 memoryRoot / userMemoryDir 就在 DEFAULT_CONFIG 键集内 ⇒ 一次 POST 可把后续
|
|
12287
|
+
// append/rewrite 的落点移出记忆区。判据:非空字符串 + 展开后必须落在 dshHome() 之下。
|
|
12288
|
+
// 注意:① 必须先经 expandUserPath 展开(默认值本身就带 `~`,否则默认值过不了自己的闸);
|
|
12289
|
+
// ② 用 path.relative 判,纯 startsWith 会漏放行 `~/.dsh/memory-backup` 这类前缀兄弟目录。
|
|
12290
|
+
// 通过校验后仍存**原样**(保持 DEFAULT_CONFIG 的 `~` 形书写约定),与 injectExcludeSources 同款 fail-closed 丢弃。
|
|
12291
|
+
if (key === 'memoryRoot' || key === 'userMemoryDir') {
|
|
12292
|
+
const rawPath = body[key]
|
|
12293
|
+
if (typeof rawPath !== 'string' || !rawPath.trim()) continue
|
|
12294
|
+
const expanded = engine.expandUserPath(rawPath)
|
|
12295
|
+
if (typeof expanded !== 'string' || !path.isAbsolute(expanded)) continue
|
|
12296
|
+
const rel = path.relative(dshHome(), expanded)
|
|
12297
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
12298
|
+
patch[key] = rawPath
|
|
12299
|
+
continue
|
|
12300
|
+
}
|
|
11406
12301
|
patch[key] = body[key]
|
|
11407
12302
|
}
|
|
11408
12303
|
const saved = await engine.saveConfig(patch)
|
|
@@ -11472,22 +12367,11 @@ export function apply(ctx, config) {
|
|
|
11472
12367
|
const op = String((body && body.op) || '')
|
|
11473
12368
|
if (!['add', 'update', 'remove'].includes(op)) return writeJson(res, 400, { error: 'invalid-op' })
|
|
11474
12369
|
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
|
-
})
|
|
12370
|
+
// ★P9:与模型工具 memory_rules 共用同一写盘口(requireExpect 保持 false ——
|
|
12371
|
+
// R7 的设计是「前端做二次确认,host 侧只如实执行」,不在此处引入第二套语义)
|
|
12372
|
+
const r = await applyRuleEditPre(engine, op, { index: body.index, text: body.text, dateSection: body.dateSection })
|
|
12373
|
+
if (!r.ok) return writeJson(res, r.error === 'invalid-op' ? 400 : 400, { error: r.error })
|
|
12374
|
+
writeJson(res, 200, { result: op + ' ok', items: r.items, preview: r.preview })
|
|
11491
12375
|
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11492
12376
|
},
|
|
11493
12377
|
},
|
|
@@ -11682,7 +12566,7 @@ export function apply(ctx, config) {
|
|
|
11682
12566
|
pid: process.pid,
|
|
11683
12567
|
heartbeatAt: Date.now(),
|
|
11684
12568
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
11685
|
-
queueLength: q
|
|
12569
|
+
queueLength: q,
|
|
11686
12570
|
lastConsolidatedAt: (engine.autoStats && engine.autoStats.lastAt) || 0,
|
|
11687
12571
|
todayCount: (engine.autoStats && engine.autoStats.count) || 0,
|
|
11688
12572
|
}, null, 2), 'utf8')
|
|
@@ -11749,3 +12633,5 @@ export function apply(ctx, config) {
|
|
|
11749
12633
|
|
|
11750
12634
|
/** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
|
|
11751
12635
|
export { sanitizeForWrite, hygieneGateForPrimitive, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession, attachmentsOfContent, attachmentBlobPathsPre, renderAttachmentLinesPre }
|
|
12636
|
+
/** 导出会话运行态仓库(issue#104):让 dispose 的清理接线能被行为测试直接驱动,不必靠断言源码字符串。 */
|
|
12637
|
+
export { SessionRuntimeStore }
|