@a9i5k4/dsh-auto-memory 3.0.0 → 3.1.0
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 +30 -13
- package/README.zh-CN.md +30 -13
- package/docs/FRONTEND-CO-CREATION.md +191 -0
- package/docs/GM53-HOMEPAGE-PROMPT.md +323 -0
- package/docs/HANDBOOK.md +88 -52
- package/docs/HOMEPAGE-CONTENT-FOR-GM53.md +299 -0
- package/docs/PROMO-PROMPT-3.0.md +100 -0
- package/docs/USER-GUIDE.en.md +11 -11
- package/docs/USER-GUIDE.zh-CN.md +11 -11
- package/docs/WHITEPAPER.md +207 -0
- package/docs/screenshots/promo/promo-0-banner-v3.png +0 -0
- package/docs/screenshots/promo/promo-0-banner-v4.png +0 -0
- package/docs/screenshots/promo/promo-1b-auto-recall.png +0 -0
- package/lib/activation-host.js +69 -10
- package/lib/board-mode.js +1 -1
- package/lib/client.js +1697 -285
- package/lib/config-io.js +156 -0
- package/lib/context-bridge.js +3 -0
- package/lib/context-host.js +23 -10
- package/lib/degrade.js +385 -0
- package/lib/dsh-home.js +143 -0
- package/lib/episodic-store.js +142 -18
- package/lib/evidence-store.js +8 -1
- package/lib/fact-store.js +484 -43
- package/lib/hub-io.js +217 -0
- package/lib/index-sync.js +13 -1
- package/lib/index.js +1730 -202
- package/lib/intent-clean-safe.js +258 -40
- package/lib/l0-extract.js +231 -16
- package/lib/m4-corpus.js +8 -2
- package/lib/m7-index-sync-host.js +8 -1
- package/lib/memory-envelope.js +6 -1
- package/lib/memory-hub.js +164 -17
- package/lib/memory-index.js +4 -2
- package/lib/note-status-apply.js +118 -0
- package/lib/note-status.js +204 -0
- package/lib/procedure-store.js +333 -31
- package/lib/procedure-switch.js +38 -0
- package/lib/python-sidecar-client.js +314 -11
- package/lib/recall-fusion.js +83 -12
- package/lib/rules-edit.js +159 -0
- package/lib/semantic-decide.js +41 -8
- package/lib/semantic-js.js +51 -6
- package/lib/shadow-host.js +3 -5
- package/lib/skill-export-host.js +153 -0
- package/lib/skill-export.js +239 -0
- package/lib/storage-manage.js +6 -0
- package/lib/temporal-parse.js +191 -159
- package/lib/tier0-catalog.js +45 -3
- package/lib/wb-contract.js +198 -2
- package/lib/wb-sidecar.js +54 -3
- package/package.json +6 -2
- package/docs/internal/ACCEPT-35-LIVE.md +0 -143
- package/docs/internal/ACCEPTANCE-20260914.md +0 -90
- package/docs/internal/ARCH-REVIEW-BRIEF.md +0 -411
- package/docs/internal/ARCH-REVIEW-REQUEST.md +0 -201
- package/docs/internal/ARCH-REVIEW-ROUND2.md +0 -169
- package/docs/internal/ARCH-REVIEW-ROUND3.md +0 -206
- package/docs/internal/ART-DIRECTION-WIREFRAME.md +0 -181
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +0 -314
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +0 -192
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +0 -72
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +0 -131
- package/docs/internal/CUA-VISION-FIX-NOTES.md +0 -78
- package/docs/internal/DECISIONS-20260914-SESSION.md +0 -269
- package/docs/internal/DESIGN-OVERHAUL-PRE-RESEARCH.md +0 -292
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +0 -219
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +0 -132
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +0 -13
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +0 -74
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +0 -352
- package/docs/internal/GPT-REVIEW-PROMPT.md +0 -216
- package/docs/internal/GROUP-DIGEST-SETUP.md +0 -62
- package/docs/internal/GROUP-LISTENER-SETUP.md +0 -49
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +0 -93
- package/docs/internal/HANDOFF-TO-ZCODE.md +0 -168
- package/docs/internal/KICKOFF-P0.md +0 -254
- package/docs/internal/MASTER-PLAN-3.0.md +0 -411
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +0 -85
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +0 -222
- package/docs/internal/NEXT-VERSION-TODO.md +0 -95
- package/docs/internal/OFFICIAL-DISCUSSION-DRAFT.md +0 -80
- package/docs/internal/PENDING-FIXES-20260916.md +0 -289
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +0 -229
- package/docs/internal/RELEASE-PROCESS.md +0 -99
- package/docs/internal/REPORT-P0-NIGHTLY.md +0 -212
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +0 -31
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +0 -153
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +0 -81
- package/docs/internal/ROADMAP-20260917-WEEK.md +0 -305
- package/docs/internal/ROADMAP.md +0 -106
- package/docs/internal/RUN-P0-NIGHTLY.md +0 -227
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +0 -175
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +0 -125
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +0 -360
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +0 -90
- package/docs/internal/SUBAGENT-REPORT-ROUTING-PRE-RESEARCH.md +0 -261
- package/docs/internal/THREE-LAYER-CONTRACT.md +0 -210
- package/docs/internal/TODO-BACKLOG.md +0 -263
- package/docs/internal/TODO-GRAPH.html +0 -715
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +0 -493
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +0 -703
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +0 -710
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +0 -715
- package/docs/internal/WB-FORMAT-CONVENTION.md +0 -112
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +0 -71
- package/docs/internal/WB-GRAPH-INTEGRATION-PLAN.md +0 -386
- package/docs/internal/WB-GRAPH-RESEARCH-BRIEF.md +0 -118
- package/docs/internal/WB-GRAPH-RESEARCH-EXTERNAL.md +0 -228
- package/docs/internal/WB-GRAPH-RESEARCH-LOCAL.md +0 -190
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +0 -56
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +0 -787
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +0 -112
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +0 -230
package/lib/index.js
CHANGED
|
@@ -31,6 +31,9 @@ import { retryRename } from './fs-retry.js'
|
|
|
31
31
|
import { parseAnchors, stripAnchorLines } from './memory-anchor.js'
|
|
32
32
|
import { createShadowHost } from './shadow-host.js'
|
|
33
33
|
import { createContextHost } from './context-host.js'
|
|
34
|
+
// R3(2026-09-18):降级留痕层。fail-soft 本身是对的,缺陷在「降级不可见」——
|
|
35
|
+
// 检索链四条臂各自静默失效,使用者只感到「检索不太对」。本层让「哪条臂没在工作」可查询。
|
|
36
|
+
import { createDegradeSinkPre, deriveArmsHealthPre, persistDegradeLedgerPre, createQuotaProbePre, deriveQuotaVerdictPre } from './degrade.js'
|
|
34
37
|
import { createSuccessEvidencePre } from './context-bridge.js'
|
|
35
38
|
import { createIndexSyncHostPre } from './m7-index-sync-host.js'
|
|
36
39
|
import { createActivationHost } from './activation-host.js'
|
|
@@ -44,8 +47,14 @@ import { createPythonSetupPre } from './python-setup.js'
|
|
|
44
47
|
import { createEpisodicStorePre } from './episodic-store.js'
|
|
45
48
|
import { createFactStorePre } from './fact-store.js'
|
|
46
49
|
import { createProcedureStorePre } from './procedure-store.js'
|
|
50
|
+
import { exportSkillForPre, resolveSkillsRootPre } from './skill-export-host.js'
|
|
47
51
|
import { createMemoryHubPre } from './memory-hub.js'
|
|
52
|
+
// ★#110(2026-09-22):hub 持久化 IO 适配器与其健康度投影(原本内联在本文件里、失败被静默吞掉)。
|
|
53
|
+
import { createHubIoPre, createHubIoHealthPre, hubIoHealthSnapshotPre } from './hub-io.js'
|
|
48
54
|
import { pickConsolidationTextPre } from './intent-clean.js'
|
|
55
|
+
// T1-3(2026-09-19):hubFlushTick 写入前的**内容卫生门**复用 ⑨ 同源清洗器。
|
|
56
|
+
// 必须独占一行(本仓契约守卫以字面量断言既有 import 行,禁止为排版合并)。
|
|
57
|
+
import { stripRuntimeIntentPre, looksRuntimeResiduePre } from './intent-clean-safe.js'
|
|
49
58
|
import { createStorageManagerPre } from './storage-manage.js'
|
|
50
59
|
import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
|
|
51
60
|
import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
|
|
@@ -61,6 +70,14 @@ import { composeMemoryEnvelopePre } from './memory-envelope.js'
|
|
|
61
70
|
import { validateMutationBoundaryPre, mutationRefusalTextPre } from './memory-mutation.js'
|
|
62
71
|
import { parseWhiteboardPre, toMutationProjectionPre, extractProtectedRegionsPre, checkHandoffCriteriaPre, checkPlanCriteriaPre, criteriaRefusalTextPre, WB_MARKERS_V1, WB_CONTRACT_VERSION } from './wb-contract.js'
|
|
63
72
|
import { resolveBoardModePre } from './board-mode.js'
|
|
73
|
+
// ★#86-3(2026-09-20):DSH_HOME 解析统一到单一口径(此前全仓 7 处、4 种回落)。
|
|
74
|
+
import { resolveDshHomePre } from './dsh-home.js'
|
|
75
|
+
// ★R7(2026-09-20 用户要求「用户级硬性约束必须可以让用户自己增删改」):
|
|
76
|
+
// 条目级解析/增删改的**纯逻辑**(不含 IO;IO 走既有 writeFull 事务)。
|
|
77
|
+
import { listRuleItemsPre, updateRuleItemPre, removeRuleItemPre, appendRuleItemPre } from './rules-edit.js'
|
|
78
|
+
// ★#82(2026-09-20):配置读写切到原子写 + 损坏隔离模块。
|
|
79
|
+
// 原实现:写侧裸 writeFileSync(写到一半被杀 ⇒ 半截 JSON),读侧 catch 静默回落出厂默认。
|
|
80
|
+
import { writeTextAtomicPreSync, writeTextAtomicPre, readJsonQuarantinePreSync } from './config-io.js'
|
|
64
81
|
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'
|
|
65
82
|
// P6A 规则层(规则类与参考类分开措辞;规则真源 = 既有用户级记忆,不新增 RULES.md)
|
|
66
83
|
import { extractRulesLayerPre, renderRulesSectionPre, RULES_SECTION_TITLE_V1, RULES_SECTION_GUIDE_V1, REFERENCE_SECTION_GUIDE_V1, LOG_KINDS_V1 } from './rules-layer.js'
|
|
@@ -140,7 +157,7 @@ const SECTION_ORDER = 10000
|
|
|
140
157
|
const NOTICES_URL = 'https://raw.githubusercontent.com/Aik358/dsh-auto-memory/main/notices.json'
|
|
141
158
|
|
|
142
159
|
/** Model-facing announcement (tools + engine). */
|
|
143
|
-
export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;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
|
|
160
|
+
export const GUIDANCE = '本机已安装 dsh-auto-memory 插件(集中式自动记忆 + 外部记忆继承):三层本地记忆(用户级 ~/.dsh/memory/MEMORY.md、项目笔记与每日日志 .dsh-memory/)+ 会话自动注入 + 每日反思 + 其他 AI 工具记忆接入。能力:memory_log 追加今日日志(append-only,完成实质性工作后必须调用);memory_note 更新项目笔记;memory_user 更新用户级规则;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 = 可复用的结论/决策**。维护时机(**条件触发,不是每轮**):完成阶段性工作、或发现白板/账本所述与现状不符、或方向有实质变化时——**写记忆的同时顺手维护白板**,不要新开一轮专程去做。**禁止**:未得用户同意不得删减白板既有内容(只报告过时,不自动删);白板/产物功能关闭时跳过白板维护、不要因此报错。'
|
|
144
161
|
|
|
145
162
|
/** Route family. */
|
|
146
163
|
export const API = {
|
|
@@ -160,6 +177,9 @@ export const API = {
|
|
|
160
177
|
reflect: '/api/dsh-auto-memory/reflect',
|
|
161
178
|
'reflect-auto': '/api/dsh-auto-memory/reflect-auto',
|
|
162
179
|
note: '/api/dsh-auto-memory/note',
|
|
180
|
+
// ★R7:用户级硬性约束的条目级读写(前端「设置 → 硬性约束」页用)
|
|
181
|
+
'rules-list': '/api/dsh-auto-memory/rules',
|
|
182
|
+
'rules-apply': '/api/dsh-auto-memory/rules/apply',
|
|
163
183
|
external: '/api/dsh-auto-memory/external',
|
|
164
184
|
'external-view': '/api/dsh-auto-memory/external-view',
|
|
165
185
|
'external-import': '/api/dsh-auto-memory/external-import',
|
|
@@ -197,9 +217,20 @@ export const API = {
|
|
|
197
217
|
* 记忆文件**容量上限**(字符,非字节)。超过即触发一次自动整理(先 AI 折叠、后退整条归档),
|
|
198
218
|
* 整理后仍超才拒绝写入 —— 正常情况下"新记忆永不堵在外面"。
|
|
199
219
|
* 只影响"多久整理一次",不直接决定每轮注入体积(那是 injectBudgetChars 的职责,两者互不相干)。
|
|
220
|
+
*
|
|
221
|
+
* ★2026-09-18(用户裁定,用户级反馈驱动):12000 → **24000**。多个真实用户报"写满了、写不进去",
|
|
222
|
+
* 12k 对长期项目(多天累积、大量决策/路径)偏紧,触发整理过于频繁。翻倍到 24k 后,
|
|
223
|
+
* 一个"信息量大"的工作日(实测约 8700 字符)能连续记录近 3 天而不整理。
|
|
224
|
+
* ⚠️ 只改常量**救不了老用户** —— `saveConfig` 会把整个合并后的 config 落盘,
|
|
225
|
+
* 老用户只要在设置页存过任何一项,12000 就已被钉死在磁盘上。故配套一次性迁移
|
|
226
|
+
* `upgradeCapacityDefaultsPre()`,见其注释。
|
|
200
227
|
*/
|
|
201
|
-
const DEFAULT_NOTE_CAPACITY_CHARS =
|
|
202
|
-
const DEFAULT_USER_CAPACITY_CHARS =
|
|
228
|
+
const DEFAULT_NOTE_CAPACITY_CHARS = 24000
|
|
229
|
+
const DEFAULT_USER_CAPACITY_CHARS = 24000
|
|
230
|
+
/** 上一版出厂默认容量(迁移判据:配置里仍是这个值 ⇒ 视为"用户没表达过偏好")。 */
|
|
231
|
+
const DEFAULT_CAPACITY_CHARS_PREV = 12000
|
|
232
|
+
/** 容量出厂默认的档位版本(用于老配置**只升一次**;用户此后手动设回 12000 也不再被覆盖)。 */
|
|
233
|
+
const CAPACITY_DEFAULTS_VERSION = 24
|
|
203
234
|
/**
|
|
204
235
|
* 整理保护窗口:最近写入的这么多字符**不参与回收**(软下限)。
|
|
205
236
|
* 硬底线是"至少保留最新 1 条记录":若保护窗口自身就超过上限,允许对其折叠成要点(但不整条删除),
|
|
@@ -212,6 +243,20 @@ const COMPACT_FOLD_MAX_CHARS = 1500
|
|
|
212
243
|
* 注意:节流只作用于"折叠"这一步,不阻止"整条归档"——否则短时间内反复超容量时仍会拒绝写入。 */
|
|
213
244
|
const COMPACT_THROTTLE_MS = 10 * 60 * 1000
|
|
214
245
|
|
|
246
|
+
/** ★T7-a(2026-09-20 · 上游 #86-4):水位建议阈值的**唯一真源**。
|
|
247
|
+
* 为什么抽常量:此前 0.75 以字面量散在 8 处(7 处 `|| 0.75` 兜底 + DEFAULT_CONFIG 一行),
|
|
248
|
+
* 而该默认值历史上**全局调过一次**(2026-09-08 由 0.8 下调到 0.75)——下次再调极易漏站点,
|
|
249
|
+
* 任何一处漏改都会造成"部分路径用新值、部分路径用旧值"的静默不一致。
|
|
250
|
+
* ⚠️ 不要把它和 `Math.max(..., 0.1)` 的下限钳制混为一谈:后者是另一件事,保持原样。 */
|
|
251
|
+
export const DEFAULT_WATER_LEVEL_THRESHOLD = 0.75
|
|
252
|
+
|
|
253
|
+
/** ★T7-a(2026-09-20 · 上游 #86-4):自动接续阈值的**唯一真源**。
|
|
254
|
+
* ⚠️ **与 waterLevelThreshold 是两个独立配置项**(各自可单独设、各自有 `|| 兜底`),
|
|
255
|
+
* 只是历史上被**同时**下调过一次(2026-09-08 两者一起 0.8→0.75,见 DEFAULT_CONFIG 注释)。
|
|
256
|
+
* ⇒ 故意**不复用** DEFAULT_WATER_LEVEL_THRESHOLD:复用会让"只调水位、不动自动接续"变成不可能,
|
|
257
|
+
* 那是把两个开关耦合成一个(违反本仓「功能开关必须解耦」纪律)。 */
|
|
258
|
+
export const DEFAULT_AUTO_CONTINUE_THRESHOLD = 0.75
|
|
259
|
+
|
|
215
260
|
const DEFAULT_CONFIG = {
|
|
216
261
|
/** WB-GRAPH 白板线总开关(2026-09-16, board_mode_v1)。
|
|
217
262
|
* ★2026-09-17(3.0.0 大版本,用户裁定「白板默认新版,旧版为了兼容而保留」):默认由 'legacy'
|
|
@@ -277,12 +322,15 @@ const DEFAULT_CONFIG = {
|
|
|
277
322
|
subagentReasoningEffort: '',
|
|
278
323
|
/**
|
|
279
324
|
* 项目笔记 / 用户级记忆的**容量上限**(字符)。超过即自动整理(先 AI 折叠成要点,失败退回整条归档),
|
|
280
|
-
* 整理后仍超才拒绝写入 —— 新记忆不会被堵在外面。默认
|
|
281
|
-
*
|
|
282
|
-
*
|
|
325
|
+
* 整理后仍超才拒绝写入 —— 新记忆不会被堵在外面。默认 **24000**(2026-09-18 由 12000 上调;
|
|
326
|
+
* 实测一个"信息量大"的工作日约写入 8700 字符,24k 能连续记录近 3 天而不整理)。与「注入预算」
|
|
327
|
+
* injectBudgetChars 是两回事:容量上限管文件本体大小,注入预算管每轮往提示里塞多少摘要。
|
|
283
328
|
*/
|
|
284
329
|
noteCapacityChars: DEFAULT_NOTE_CAPACITY_CHARS,
|
|
285
330
|
userCapacityChars: DEFAULT_USER_CAPACITY_CHARS,
|
|
331
|
+
/** 容量出厂默认的档位版本(2026-09-18 新增)。低于当前值时,`upgradeCapacityDefaultsPre()`
|
|
332
|
+
* 会把仍是上一版默认(12000)的容量键抬到新默认;只升一次 ⇒ 用户自设值永不被覆盖。 */
|
|
333
|
+
capacityDefaultsVersion: CAPACITY_DEFAULTS_VERSION,
|
|
286
334
|
/** M3a 只读记忆索引开关(默认关闭;开启后仅构建只读索引与调试快照,不修改任何 Markdown)。 */
|
|
287
335
|
memoryFileIndexEnabled: false,
|
|
288
336
|
/** M3b 稳定 Anchor 写入开关(默认关闭=全部旧 Markdown 写法逐字节不变;开启后记忆写路径经 anchor-aware 事务,CALENDAR.md 始终除外)。 */
|
|
@@ -341,8 +389,12 @@ const DEFAULT_CONFIG = {
|
|
|
341
389
|
waterLevelWindowTokens: 0,
|
|
342
390
|
/** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。
|
|
343
391
|
* 2026-09-08 由 0.8 下调到 0.75:harness 官方自动压缩阈值是 80%,阈值贴着 80% 会在交接动作完成前先被官方压缩掉,
|
|
344
|
-
* 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。
|
|
345
|
-
|
|
392
|
+
* 留 5% 余量(约 1M 窗口的 50K token)才来得及走完「写账本 → 建新会话 → 注入材料」。
|
|
393
|
+
* ★T7-a(2026-09-20 · 上游 #86-4):**该默认值的唯一真源 = 常量 DEFAULT_WATER_LEVEL_THRESHOLD**。
|
|
394
|
+
* 此前 0.75 在 7 处内联兜底 + 本行字面量共 8 处;该默认值历史上**全局调过一次**(0.8→0.75),
|
|
395
|
+
* 下次再调极易漏站点,任何一处漏改即静默不一致。故抽常量、全站引用。
|
|
396
|
+
* ⚠️ `Math.max(..., 0.1)` 的下限钳制是**另一件事**,不在本常量职责内,保持原样。 */
|
|
397
|
+
waterLevelThreshold: DEFAULT_WATER_LEVEL_THRESHOLD,
|
|
346
398
|
/** 水位建议注入(无人值守时始终静默)。 */
|
|
347
399
|
waterLevelAdvisory: true,
|
|
348
400
|
/** 水位越阈时自动写一篇系统骨架账本(每会话一次;模型仍应自己写正式交接)。 */
|
|
@@ -353,7 +405,7 @@ const DEFAULT_CONFIG = {
|
|
|
353
405
|
// 注:老用户已落盘的显式值不受影响,只有新装用户吃这个默认。
|
|
354
406
|
autoContinueEnabled: false,
|
|
355
407
|
/** 自动接续水位阈值(0.5-0.95);2026-09-08 与 waterLevelThreshold 同步下调到 0.75(官方自动压缩阈值 80%,必须留余量)。 */
|
|
356
|
-
autoContinueThreshold:
|
|
408
|
+
autoContinueThreshold: DEFAULT_AUTO_CONTINUE_THRESHOLD,
|
|
357
409
|
/** 确认卡倒计时秒数(30-40s 无操作 = 挂机 → 自动接续兜底)。 */
|
|
358
410
|
autoContinueConfirmSeconds: 35,
|
|
359
411
|
/** 接续前刷新仪式(默认开):先让旧 Agent 刷新 PLAN.md + 交接账本,host 再用最新材料组装交接。 */
|
|
@@ -492,8 +544,31 @@ const DEFAULT_CONFIG = {
|
|
|
492
544
|
pythonBackendEnabled: false,
|
|
493
545
|
/** 推理 trace 观察器(2026-08-26 裁定默认开:监听目标是模型思维链,闭源概括式 CoT 同样纳入)。 */
|
|
494
546
|
reasoningObserverEnabled: true,
|
|
495
|
-
/**
|
|
547
|
+
/** ★ B-2 修复(2026-09-22):**技能注入/唤起总闸**,语义纠正后的正确键(默认 **true**)。
|
|
548
|
+
* 取证:旧键 `procedurePromotionEnabled` 名字叫「技能固化与晋升」,但它的两个消费者
|
|
549
|
+
* 都与"晋升"无关 —— context-host.js:537(技能是否**注入上下文**)、
|
|
550
|
+
* activation-host.js:330(**主动唤起**这条臂开不开);真正的晋升由 store 门限 +
|
|
551
|
+
* 路由动作决定。旧键默认 false ⇒ 新用户装完"技能永不生效"且无任何提示。
|
|
552
|
+
* ⚠️ 解析口径统一走 `lib/procedure-switch.js` 的 `resolveProcedureInjectEnabledPre`,
|
|
553
|
+
* 不得在本文件或其它文件里各自 inline 判断(否则又是一次口径漂移)。 */
|
|
554
|
+
procedureInjectEnabled: true,
|
|
555
|
+
/** 已弃用:语义错配的旧键,保留为**兼容别名**。
|
|
556
|
+
* 新键缺省时按本键取值(老用户显式关掉的不翻面);两键都缺省才取 true。
|
|
557
|
+
* 设置页已改为操作新键,本键仅作读取回退,不再新增写入入口。
|
|
558
|
+
* 注:**不新增 `procedureAutoPromoteEnabled`** —— 全仓无任何自动晋升代码路径
|
|
559
|
+
* (`applyAutomaticTransitions` 只导出零调用,见 BUGLIST P2-4),建了就是死配置。 */
|
|
496
560
|
procedurePromotionEnabled: false,
|
|
561
|
+
/** ★T10(2026-09-20):**机械 procedure 切片**开关。
|
|
562
|
+
* 背景(用户 2026-09-20 报「白板/审批里的技能名与内容看不懂」):
|
|
563
|
+
* `memory-hub.js` 的 `crossFeed()` 会把 episode 的 intent **机械截断**成
|
|
564
|
+
* `title: intent.slice(0, 40)` / `steps: ['观察任务:' + intent.slice(0, 80)]`
|
|
565
|
+
* —— **无任何模型介入**,产出的观察行既不像技能名也不像步骤。
|
|
566
|
+
* 默认 **false**:关闭该机械来源。procedural 线的写入只剩两条正经通路——
|
|
567
|
+
* ① 模型直写 `memory_procedure`(T4);② 用户手动。
|
|
568
|
+
* ⚠️ **解耦声明**:本开关**只**控制 procedure 切片;fact 分支、episode 巩固、
|
|
569
|
+
* judgement 消费等一律不受影响(对应用户硬规矩「单一开关不得顺带改变其他功能」)。
|
|
570
|
+
* 设为 true 可恢复旧行为(回退通路)。 */
|
|
571
|
+
hubMechanicalProcedureFeedEnabled: false,
|
|
497
572
|
/** ── M8 记忆中枢(Memory Hub)── 三层记忆(episodic/semantic/procedural)编排参数。
|
|
498
573
|
* 所有参数都在设置页「记忆中枢」分组可调;默认值对应 M-02/M-03/M-04 元代码门槛。 */
|
|
499
574
|
/** 记忆中枢总开关(2026-09-09 M8-3 经用户书面确认默认启用;开启后消费 judgement-shadow + 三层 store 运行;设置页「记忆中枢」开关可回滚)。 */
|
|
@@ -502,6 +577,12 @@ const DEFAULT_CONFIG = {
|
|
|
502
577
|
episodicMinSegments: 2,
|
|
503
578
|
/** episodic: 保留的最多 episode 数(超出按时间淘汰)。默认 256。 */
|
|
504
579
|
episodicRetention: 256,
|
|
580
|
+
/** ★#110(2026-09-22,用户拍板 1000):facts 保留上限。超出按「撤销优先 → 最旧优先」淘汰,
|
|
581
|
+
* 重要项(pinned / user-memory / explicit / confidence≥0.8)最后才删。<=0 表示不限。 */
|
|
582
|
+
factRetentionMax: 1000,
|
|
583
|
+
/** ★#102(2026-09-22):工作区发现上限。旧的硬编码 30 会把第 31 个起的工作区**静默丢弃**
|
|
584
|
+
* (跨区检索/注入索引/总览一起少项)。现按「最近会话 mtime」降序截断,默认 200。 */
|
|
585
|
+
workspaceDiscoverMax: 200,
|
|
505
586
|
/** procedure: 晋升所需跨会话多样性(≥N 个独立 session)。默认 3(M-04 元代码)。 */
|
|
506
587
|
procedureMinSessions: 3,
|
|
507
588
|
/** procedure: 晋升所需成功次数(≥N)。默认 2(一次成功不足以证明可靠)。 */
|
|
@@ -553,7 +634,25 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
553
634
|
snapshotCalendarTitle: '[日历与日程(未完成)]',
|
|
554
635
|
snapshotWelcomeTitle: '[欢迎回来]',
|
|
555
636
|
snapshotWelcomeBody: '用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。',
|
|
556
|
-
|
|
637
|
+
// ★T5(2026-09-20 用户拍板):**填回收尾自检正文**。
|
|
638
|
+
// 背景取证:本常量自 v0.1.30(fbc14fb, 2026-09-01)重构起即为空壳标题——v0.1.9(85d9340)
|
|
639
|
+
// 的尾部提醒正文在那次重构中被固化进 renderMemoryStatic(走 system prompt,位置在最前)。
|
|
640
|
+
// 用户裁定「记忆写入提醒必须固定在每轮收尾,不能只靠开头注入」⇒ 本段正是收尾位
|
|
641
|
+
// (动态快照倒数第二段,仅 frame-tail 在其后),recency 最高。
|
|
642
|
+
// 与 G4-6 的关系:G4-6 当时否决「往铭文塞白板散文」,理由是"纯每轮成本";
|
|
643
|
+
// 2026-09-20 用户明确要求恢复三大方向收尾提醒 ⇒ **该否决被推翻**,G4-6 断言同步改写。
|
|
644
|
+
// 成本纪律:只列**方向 + 工具名 + 分类枚举**,不写解释性散文(详版在 renderMemoryStatic,
|
|
645
|
+
// 那里是 system prompt,不随对话增长)。实测约 420 字符 ≈ 210 token,单份快照预算 8000 的 5%。
|
|
646
|
+
// 缓存纪律:本段走 systemPrompt.context()(user-role,追加在历史尾部),**不击穿前缀缓存**
|
|
647
|
+
// (见 :5194 设计说明);含 {date} 模板 ⇒ 日期不变则该段内容不变。
|
|
648
|
+
snapshotInscription: '[铭文 · 每轮提醒 {date}]\n'
|
|
649
|
+
+ '【收尾自检】本轮有实质产出才做;纯只读/闲聊轮跳过。三个方向 + 分类别丢:\n'
|
|
650
|
+
+ '① 写记忆文件 memory_log —— kind 按性质选,**不要一律 fact**:rule=用户约束/约定(会被规则层当硬约束注入)、preference=偏好、fact=事实记录、todo=待办;\n'
|
|
651
|
+
+ '② 更新白板与账本 —— ★**硬映射(满足即必须做,别自行判断"算不算"变化)**:\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'
|
|
653
|
+
+ '④ 结论失效/被取代 —— 传 `supersedes=` 旧条目 mem_id 标 **superseded(被更新结论取代)**;**当时就做错了要撤回**则传 `retract=` 标 **retracted(撤回)**并尽量附 `retractReason=` 说明错在哪(两者不同:前者有后继,后者本身就是教训);标错了用 `restore=` 撤回;\n'
|
|
654
|
+
+ '⑤ 看板落列 —— 白板/账本内容要进面板看板泳道,须在标题或正文写 tag:`type:goal` / `type:state` / `type:dead-end` / `type:progress`(5 条泳道含「版本归档」,靠归档动作而非 tag);不写 tag 系统只能按标题文字猜,常落空;\n'
|
|
655
|
+
+ '⑥ 语体 —— 客观陈述、第三人称,只留可复用的事实/决策/规则/路径(不写"我考虑/我排查/我想")。',
|
|
557
656
|
// ★2026-09-15(用户裁定"不是不注入,而是精简注入"):精简版尾部说明。
|
|
558
657
|
// 作用:让模型知道**这是精简版**、完整版每 {n} 轮来一次、以及"知道有什么但没给全文"时怎么取。
|
|
559
658
|
// 不写"请稍后再看"这类无效指令——只给可执行的取用方式(与 Tier-0 目录的用法一致)。
|
|
@@ -621,12 +720,132 @@ const truncateLinesBounded = (s, n) => {
|
|
|
621
720
|
return (nl > Math.floor(n * 0.5) ? cut.slice(0, nl) : cut) + '\n…(截断,全文见 handoff/ 白板与账本)'
|
|
622
721
|
}
|
|
623
722
|
const truncateTail = (s, n) => (s && s.length > n) ? '…(截断,完整内容用 memory_recall 或 GUI 面板)\n' + s.slice(-n) : (s || '')
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* 接续材料组装(L3, 2026-09-17) —— **导航区不可截断**。
|
|
726
|
+
*
|
|
727
|
+
* 为什么需要它(终端用户实测报障「有些文件没有办法接续过去」):
|
|
728
|
+
* 旧实现把所有层顺序 push 进一个数组, 最后 `parts.join(NL+NL).slice(0, 18000)` **从尾部一刀切**。
|
|
729
|
+
* 而 第2层(20 条 × 700 字 = 最多 14000) + 白板 3000 + 账本 8000 最坏 ≈ 25000 > 18000 ⇒ 必然溢出,
|
|
730
|
+
* 于是**第一个被砍掉的正是排在最后的第3层「完整转写路径」**——那是模型的**逃生通道**
|
|
731
|
+
* ("前 0-2 层不够时去 read 全量转写")。逃生通道被砍 ⇒ 模型根本不知道全量转写存在
|
|
732
|
+
* ⇒ 只能靠被砍过的摘要干活 ⇒ 表现为"接不过去"。
|
|
733
|
+
*
|
|
734
|
+
* 语义(与调用方约定):
|
|
735
|
+
* - `nav`: **永不截断**, 配额先扣(转写路径 / 锚点入口)。
|
|
736
|
+
* - `head`: 指令区, 也基本固定(短)。
|
|
737
|
+
* - `bulk`: 正文层(白板/账本/近期线程/辅助表), **可截断**; 超出时按预算切, 并**如实报告哪些层被丢**。
|
|
738
|
+
* - 发生截断时,**显式写入未包含清单** —— 让模型知道自己拿到的**不是全部**, 从而主动去 read,
|
|
739
|
+
* 而不是以为已经拿全了(旧实现的最大隐患是"沉默截断")。
|
|
740
|
+
*
|
|
741
|
+
* 纯函数、无副作用、字节稳定(同输入 → 同输出)。
|
|
742
|
+
*/
|
|
743
|
+
export function assembleCarryPre({ head = [], nav = [], bulk = [], budget = 18000 } = {}) {
|
|
744
|
+
const E = '\n\n'
|
|
745
|
+
const headText = head.filter(Boolean).join(E)
|
|
746
|
+
const navText = nav.filter(Boolean).join(E)
|
|
747
|
+
const bulkParts = bulk.filter(Boolean)
|
|
748
|
+
const fullBulk = bulkParts.join(E)
|
|
749
|
+
const fixed = headText.length + navText.length + (headText || navText ? E.length * 2 : 0)
|
|
750
|
+
const room = Math.max(0, Number(budget) - fixed)
|
|
751
|
+
if (fullBulk.length <= room) {
|
|
752
|
+
return { text: [headText, navText, fullBulk].filter(Boolean).join(E), truncated: false, dropped: [] }
|
|
753
|
+
}
|
|
754
|
+
// 需截断: 逐段累计, 记录被整体丢弃的段
|
|
755
|
+
const kept = []
|
|
756
|
+
const dropped = []
|
|
757
|
+
let used = 0
|
|
758
|
+
for (const p of bulkParts) {
|
|
759
|
+
const cost = p.length + (kept.length ? E.length : 0)
|
|
760
|
+
if (used + cost <= room) { kept.push(p); used += cost } else { dropped.push(p) }
|
|
761
|
+
}
|
|
762
|
+
// 若一段都放不下(room 太小), 至少保底切一段的开头
|
|
763
|
+
if (!kept.length && bulkParts.length) {
|
|
764
|
+
kept.push(bulkParts[0].slice(0, Math.max(0, room)))
|
|
765
|
+
}
|
|
766
|
+
const droppedHeads = dropped.map((p) => {
|
|
767
|
+
const heads = p.match(/^【[^】]{0,60}】/gm)
|
|
768
|
+
return heads ? heads.join(' ') : '(一段正文)'
|
|
769
|
+
})
|
|
770
|
+
const notice = '⚠️ **材料因预算被截断, 以下内容未包含**: ' + (droppedHeads.length ? droppedHeads.join('; ') : '(部分正文尾部)') +
|
|
771
|
+
'。**完整材料仍在第3层转写里**(见上方路径), 需要时请直接 read, 不要仅凭本摘要下结论。'
|
|
772
|
+
const text = [headText, navText, kept.join(E), notice].filter(Boolean).join(E)
|
|
773
|
+
return { text, truncated: true, dropped: droppedHeads }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** ★L3.6(2026-09-17)·旧会话转写**瘦身**(纯函数,供 smoke 驱动)。
|
|
777
|
+
* 实测(2026-09-17): 一个会话的事件里 `tool/ptc-dispatch` 1854 条 vs `assistant/message` 530 条
|
|
778
|
+
* ⇒ 工具噪声压过正文 3 倍以上。全量转写既浪费磁盘也让模型检索时被噪声淹没。
|
|
779
|
+
* 用户批准的方案:**只保留用户输入与助手的最终输出**, 工具调用/结果压成**计数行**。
|
|
780
|
+
* 保留角色标记与顺序 ⇒ 第2层"近期线程"的结构还原不受影响。
|
|
781
|
+
* 返回 { body, keptMsgs, toolCalls, toolResults, droppedChars }。 */
|
|
782
|
+
export function slimTranscriptPre(msgs, opts = {}) {
|
|
783
|
+
const NL = String.fromCharCode(10)
|
|
784
|
+
const perMsg = Number(opts.perMsgChars) > 0 ? Number(opts.perMsgChars) : 2000
|
|
785
|
+
const totalCap = Number(opts.totalChars) > 0 ? Number(opts.totalChars) : 60000
|
|
786
|
+
const decorate = typeof opts.decorate === 'function' ? opts.decorate : null
|
|
787
|
+
const list = Array.isArray(msgs) ? msgs : []
|
|
788
|
+
const out = []
|
|
789
|
+
let toolCalls = 0, toolResults = 0, droppedChars = 0, keptMsgs = 0, used = 0, trimmed = false
|
|
790
|
+
for (const m of list) {
|
|
791
|
+
const role = String((m && m.role) || '')
|
|
792
|
+
const text = String((m && m.text) || '')
|
|
793
|
+
if (role === 'tool_call') { toolCalls++; droppedChars += text.length; continue }
|
|
794
|
+
if (role === 'tool_result') { toolResults++; droppedChars += text.length; continue }
|
|
795
|
+
if (role !== 'user' && role !== 'assistant') continue
|
|
796
|
+
// 超总长即停(与旧实现同口径: 保留**较早**内容, 尾部截断并如实告知)
|
|
797
|
+
const extra = decorate ? String(decorate(m) || '') : ''
|
|
798
|
+
const chunk = '**' + role + '**: ' + (text.length > perMsg ? text.slice(0, perMsg) : text) + extra
|
|
799
|
+
if (used + chunk.length > totalCap) { trimmed = true; break }
|
|
800
|
+
out.push(chunk)
|
|
801
|
+
used += chunk.length + NL.length * 2
|
|
802
|
+
keptMsgs++
|
|
803
|
+
if (text.length > perMsg) droppedChars += text.length - perMsg
|
|
804
|
+
}
|
|
805
|
+
return { body: out.join(NL + NL), keptMsgs, toolCalls, toolResults, droppedChars, trimmed }
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** ★L3.6·会话转写的**检索锚点**(纯函数,供 smoke 驱动)。
|
|
809
|
+
* 旧会话转写此前是"孤岛": `listHandoffLedgers` 的正则
|
|
810
|
+
* `/^(?:handoff-\d{8}-\d{6}(-[a-z])?|PLAN-\d{8}-\d{6})\.md$/` **不匹配 prev-session-***,
|
|
811
|
+
* 所以 `scope='handoff'` 永远看不见它 ⇒ 用户说"有些文件接不过去"。
|
|
812
|
+
* 这里给每篇转写发一个**由 sid 决定的稳定锚点**(同 sid ⇒ 同 id, 不随写入时刻漂移),
|
|
813
|
+
* 格式与既有记忆锚点同域(`mem_` + 32 hex), 便于 L0 抽取与 grep。 */
|
|
814
|
+
export function prevSessionSidAnchorPre(sid, createHashFn) {
|
|
815
|
+
try {
|
|
816
|
+
const s = String(sid || '')
|
|
817
|
+
if (!s) return ''
|
|
818
|
+
const h = createHashFn ? createHashFn() : null
|
|
819
|
+
if (!h) return ''
|
|
820
|
+
return 'mem_' + h.update('prev-session\u0000' + s).digest('hex').slice(0, 32)
|
|
821
|
+
} catch (e) { return '' }
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** ★L3.6·为一篇旧会话转写生成 L0 摘要行(纯函数,供 smoke 驱动)。
|
|
825
|
+
* L0 = 一句话"这个会话在干什么" —— 取**首条用户输入** + **末条助手结论**各截断,
|
|
826
|
+
* 与 `l0-extract.js` 的 L0 口径一致(单行、可 grep、不整段读)。
|
|
827
|
+
* 作用: 让 `searchHandoffCorpus` 能按关键词命中旧会话, 而不必先通读全文。 */
|
|
828
|
+
export function prevSessionL0Pre(msgs, sid, opts = {}) {
|
|
829
|
+
try {
|
|
830
|
+
const cap = Number(opts.cap) > 0 ? Number(opts.cap) : 120
|
|
831
|
+
const oneLine = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, cap)
|
|
832
|
+
const list = Array.isArray(msgs) ? msgs : []
|
|
833
|
+
const firstUser = list.find((m) => m && m.role === 'user' && String(m.text || '').trim())
|
|
834
|
+
let lastAsst = null
|
|
835
|
+
for (const m of list) { if (m && m.role === 'assistant' && String(m.text || '').trim()) lastAsst = m }
|
|
836
|
+
const a = oneLine(firstUser && firstUser.text)
|
|
837
|
+
const b = oneLine(lastAsst && lastAsst.text)
|
|
838
|
+
if (!a && !b) return ''
|
|
839
|
+
const sid8 = String(sid || '').slice(0, 8)
|
|
840
|
+
return '[' + sid8 + '] ' + (a || '(无用户输入)') + (b ? ' → ' + b : '')
|
|
841
|
+
} catch (e) { return '' }
|
|
842
|
+
}
|
|
843
|
+
|
|
624
844
|
const fmtBytes = (n) => (n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B')
|
|
625
845
|
|
|
626
846
|
function dshHome() {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
return path.join(homedir(), '.dsh')
|
|
847
|
+
// ★#86-3:统一口径(原实现自带一套回落链,与其余 6 处不一致)。
|
|
848
|
+
return resolveDshHomePre()
|
|
630
849
|
}
|
|
631
850
|
|
|
632
851
|
/** 本插件 lib/ 的上级目录(开发树=仓库根;发行包=包根)。 */
|
|
@@ -634,6 +853,76 @@ function pluginRootDir() {
|
|
|
634
853
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
635
854
|
}
|
|
636
855
|
|
|
856
|
+
/**
|
|
857
|
+
* 诊断节流(2026-09-21):同一 key 在 windowMs(默认 5 分钟)内只输出一次。
|
|
858
|
+
*
|
|
859
|
+
* 为什么需要: tickTime 是**定时**驱动的,任何「每 tick 都失败且都打日志」的分支都会
|
|
860
|
+
* 在宿主控制台无限刷屏。实测事故: restoreLastAgent 的 candidate rejected 每 tick 一条,
|
|
861
|
+
* 用户侧表现为「打开就一直弹这些消息」。诊断信息本身有用,但**不该以 tick 频率重复**。
|
|
862
|
+
*/
|
|
863
|
+
const _diagLastAt = new Map()
|
|
864
|
+
function diagThrottled(key, msg, windowMs) {
|
|
865
|
+
try {
|
|
866
|
+
const w = Number(windowMs) > 0 ? Number(windowMs) : 300000
|
|
867
|
+
const now = Date.now()
|
|
868
|
+
if (now - (Number(_diagLastAt.get(key)) || 0) < w) return
|
|
869
|
+
_diagLastAt.set(key, now)
|
|
870
|
+
} catch (e) {}
|
|
871
|
+
diag(msg)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* 会话归属判定 —— 区分「子代理会话」与「接续会话」(2026-09-21 修 bug)。
|
|
876
|
+
*
|
|
877
|
+
* ── 背景(实测证据: tools/probe-session-kind.mjs 解压会话头部得到) ──
|
|
878
|
+
* 本插件原判据是「`header.parentSession` 非空 ⇒ 子代理, 一律排除」。实测**该判据过宽**:
|
|
879
|
+
* 带 parentSession 的会话其实分两类 ——
|
|
880
|
+
* · **子代理** : `delegationDepth: 1`, `origin: 'subagent'`
|
|
881
|
+
* · **接续会话**: `delegationDepth: 0`, `origin` 缺省 ← **用户真实在用的会话**
|
|
882
|
+
* 接续会话由「一键接续 / 自动接续」从旧会话派生, 是**用户会话**, 却被旧判据当子代理拒掉。
|
|
883
|
+
*
|
|
884
|
+
* ── 后果(用户报告的现象) ──
|
|
885
|
+
* `_lastAgent` 永远恢复不了, 而 `tickTime` 每 15 秒重试一次 ⇒ 控制台**无限刷屏**
|
|
886
|
+
* `restoreLastAgent: candidate rejected`。诊断日志可证: 该行**首次出现于 2026-09-20 17:16**,
|
|
887
|
+
* 正是 session-85e2b7e8(接续会话)的创建时刻 —— 此前用户一直用无 parent 的顶层会话,
|
|
888
|
+
* 故从未触发。⇒ 不是回归, 是「接续会话」这一新形态第一次撞上过宽判据。
|
|
889
|
+
*
|
|
890
|
+
* ── 判定方向(安全优先: 只认「明确是子代理」, 其余放行) ──
|
|
891
|
+
* 漏判子代理的代价 = 少一次恢复(退化为旧行为); 误拒用户会话的代价 = 功能失效 + 刷屏。
|
|
892
|
+
* 两害相权取轻。放行时会打一条**可观测**的诊断(带 origin/depth 原值), 便于事后核对误判。
|
|
893
|
+
*/
|
|
894
|
+
const SESSION_SUBAGENT_ORIGIN = 'subagent'
|
|
895
|
+
/** 从各种可能形态里取出会话头部(list 项 / agent.session.header / list 项自身)。 */
|
|
896
|
+
function sessionHeaderOf(x) {
|
|
897
|
+
try {
|
|
898
|
+
if (!x) return null
|
|
899
|
+
if (x.session && x.session.header) return x.session.header
|
|
900
|
+
if (x.header) return x.header
|
|
901
|
+
if (x.session) return x.session
|
|
902
|
+
return x
|
|
903
|
+
} catch (e) { return null }
|
|
904
|
+
}
|
|
905
|
+
/** 是否**明确**是子代理会话。字段缺失一律不算(⇒ 放行)。 */
|
|
906
|
+
function isSubAgentSession(x) {
|
|
907
|
+
try {
|
|
908
|
+
const h = sessionHeaderOf(x)
|
|
909
|
+
if (!h) return false
|
|
910
|
+
if (String(h.origin || '') === SESSION_SUBAGENT_ORIGIN) return true
|
|
911
|
+
const d = Number(h.delegationDepth)
|
|
912
|
+
if (Number.isFinite(d) && d > 0) return true
|
|
913
|
+
} catch (e) {}
|
|
914
|
+
return false
|
|
915
|
+
}
|
|
916
|
+
/** 是否有 parentSession(用于诊断与「接续会话」识别, 不单独作为排除依据)。 */
|
|
917
|
+
function hasParentSession(x) {
|
|
918
|
+
try {
|
|
919
|
+
const h = sessionHeaderOf(x)
|
|
920
|
+
if (!h) return false
|
|
921
|
+
const p = h.parentSession
|
|
922
|
+
return p !== undefined && p !== null && p !== ''
|
|
923
|
+
} catch (e) { return false }
|
|
924
|
+
}
|
|
925
|
+
|
|
637
926
|
/** 诊断输出:写 ~/.dsh/dsh-auto-memory-diagnose.log(append)+console.log 双保险。验证完移除。 */
|
|
638
927
|
let _diagChain = Promise.resolve()
|
|
639
928
|
function diag(msg) {
|
|
@@ -943,6 +1232,11 @@ class SessionRuntimeStore {
|
|
|
943
1232
|
runtime.pendingPacket = undefined
|
|
944
1233
|
// M4-3:Shadow per-runtime 状态与 inFlight abort
|
|
945
1234
|
try { if (this._shadowHost) this._shadowHost.disposeRuntime(runtime) } catch (e) {}
|
|
1235
|
+
// M6(issue#58 修复,2026-09-19):activation host 的 per-runtime 投影与步进计数器同样必须回收。
|
|
1236
|
+
// 旧实现只接了 shadowHost,`activationHost.disposeRuntime` 全仓零调用方 ⇒ `runtimeState`
|
|
1237
|
+
// (step/claimed)与 `stepsByRuntime`(会话×工作区步进)只增不减,长进程内存单调增长。
|
|
1238
|
+
// 传 runtime.key(与 activation-host 内部 runtimeState 的键空间一致)。
|
|
1239
|
+
try { if (this._activationHost && runtime.key) this._activationHost.disposeRuntime(runtime.key) } catch (e) {}
|
|
946
1240
|
// M2: 清空观察账本与语义环,断开 call 关联(abort 已由 abortController 完成);ring 为惰性分配,可能为 null
|
|
947
1241
|
if (runtime.envelopes) runtime.envelopes.clear()
|
|
948
1242
|
if (runtime.segments) runtime.segments.clear()
|
|
@@ -1620,6 +1914,51 @@ class MemoryEngine {
|
|
|
1620
1914
|
return this.config
|
|
1621
1915
|
}
|
|
1622
1916
|
|
|
1917
|
+
/**
|
|
1918
|
+
* 一次性迁移(2026-09-18,容量默认 12000 → 24000)。
|
|
1919
|
+
*
|
|
1920
|
+
* **为什么需要它**:`saveConfig` 把**整个合并后的 config** 落盘(实测某实例 95 个键全部在盘上)。
|
|
1921
|
+
* 所以老用户只要在设置页存过**任何一项**,`noteCapacityChars: 12000` 就已经被固化在磁盘里 ——
|
|
1922
|
+
* 光把 `DEFAULT_NOTE_CAPACITY_CHARS` 改成 24000 **对老用户完全无效**(配置值覆盖默认值)。
|
|
1923
|
+
* 这正是"很多人抱怨写满了、写不进去"的机制:他们被钉在 12k 上,永远不会拿到新默认。
|
|
1924
|
+
*
|
|
1925
|
+
* **幂等且尊重用户偏好**:
|
|
1926
|
+
* - 只在配置里**恰好等于上一版默认(12000)**时才抬到新默认 ⇒ "没表达过偏好"才动;
|
|
1927
|
+
* 用户若显式设成别的数(如 8000 或 50000)一律不动。
|
|
1928
|
+
* - 用 `capacityDefaultsVersion` 守卫 ⇒ **只升一次**。用户日后手动改回 12000 不会被再次覆盖。
|
|
1929
|
+
* - 任何异常都吞掉(fail-soft):迁移失败不能挡住插件启动。
|
|
1930
|
+
*
|
|
1931
|
+
* ★★ `rawCfg` 参数是**必须的**, 不是可选装饰 —— 这是 2026-09-18 实测踩到的真 bug:
|
|
1932
|
+
* `_mergeConfigPre` 做的是 `{...DEFAULT_CONFIG, ...parsed}`, 而 `DEFAULT_CONFIG` 里
|
|
1933
|
+
* **也有** `capacityDefaultsVersion: CAPACITY_DEFAULTS_VERSION` ⇒ 合并后的 config
|
|
1934
|
+
* 永远带着当前版本号 ⇒ 守卫 `ver >= VER` **结构性恒真** ⇒ 迁移函数一进就 return,
|
|
1935
|
+
* **永远不会执行**(单测用手搓 config 喂函数, 没走合并路径, 所以自洽地绿了)。
|
|
1936
|
+
* 修法:守卫读**磁盘原文**(未经默认值补全的 parsed), 而不是合并结果。
|
|
1937
|
+
* 调用方必须把 `JSON.parse(raw)` 的结果原样传进来。
|
|
1938
|
+
*
|
|
1939
|
+
* 返回被改动的键名数组(供日志/诊断;空数组表示无需迁移)。
|
|
1940
|
+
*/
|
|
1941
|
+
upgradeCapacityDefaultsPre(rawCfg) {
|
|
1942
|
+
const changed = []
|
|
1943
|
+
try {
|
|
1944
|
+
// 守卫读**磁盘原文**的版本号(缺失 = 老配置 = 0, 必然 < 当前版本 ⇒ 继续)
|
|
1945
|
+
const onDiskVer = Number(rawCfg && rawCfg.capacityDefaultsVersion)
|
|
1946
|
+
if (Number.isFinite(onDiskVer) && onDiskVer >= CAPACITY_DEFAULTS_VERSION) return changed
|
|
1947
|
+
for (const key of ['noteCapacityChars', 'userCapacityChars']) {
|
|
1948
|
+
const raw = Number(this.config[key])
|
|
1949
|
+
// 只在"仍是上一版出厂默认"时抬升;用户自设值(含 <500 的脏值由 capacityLimit 兜底)不动。
|
|
1950
|
+
if (Number.isFinite(raw) && raw === DEFAULT_CAPACITY_CHARS_PREV) {
|
|
1951
|
+
this.config[key] = key === 'userCapacityChars' ? DEFAULT_USER_CAPACITY_CHARS : DEFAULT_NOTE_CAPACITY_CHARS
|
|
1952
|
+
changed.push(key)
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
this.config.capacityDefaultsVersion = CAPACITY_DEFAULTS_VERSION
|
|
1956
|
+
return changed
|
|
1957
|
+
} catch (e) {
|
|
1958
|
+
return changed
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1623
1962
|
/**
|
|
1624
1963
|
* 同步配置加载(2026-09-16, 修 BUG-1/BUG-11 —— 注册闸门结构性恒假)。
|
|
1625
1964
|
*
|
|
@@ -1635,8 +1974,27 @@ class MemoryEngine {
|
|
|
1635
1974
|
*/
|
|
1636
1975
|
loadConfigSync() {
|
|
1637
1976
|
try {
|
|
1638
|
-
|
|
1639
|
-
|
|
1977
|
+
// ★#82:解析失败时先**把坏文件挪走留存**(readJsonQuarantinePreSync 内部完成),
|
|
1978
|
+
// 再把「坏过、坏在哪」写进 _readError 让诊断面可见 —— 旧实现只 catch 后静默回落,
|
|
1979
|
+
// 用户看到的只是「设置全没了」,无从知道是被重置。
|
|
1980
|
+
const rd = readJsonQuarantinePreSync(this._configPath)
|
|
1981
|
+
if (!rd.ok) {
|
|
1982
|
+
if (rd.missing) return this._mergeConfigPre(null)
|
|
1983
|
+
this._readError = rd.corrupted
|
|
1984
|
+
? ('config corrupted, quarantined=' + (rd.quarantined || '(failed)') + ' reason=' + rd.reason)
|
|
1985
|
+
: String(rd.reason)
|
|
1986
|
+
return this._mergeConfigPre(null)
|
|
1987
|
+
}
|
|
1988
|
+
const parsed = rd.value
|
|
1989
|
+
const cfg = this._mergeConfigPre(parsed)
|
|
1990
|
+
// ⚠️ 必须传 parsed(磁盘原文) —— 合并结果里 capacityDefaultsVersion 恒等于当前版本,
|
|
1991
|
+
// 用它做守卫会让迁移结构性永不执行(见 upgradeCapacityDefaultsPre 的注释)。
|
|
1992
|
+
const bumped = this.upgradeCapacityDefaultsPre(parsed)
|
|
1993
|
+
// ★ 这条路径**必须自己落盘**:它是注册期唯一真正跑的那条(apply 不是 async,
|
|
1994
|
+
// 工具注册前就要拿到真配置),而 `loadConfig` 往往再也不会被调用 ⇒
|
|
1995
|
+
// 不落盘的话内存已是 24000、磁盘仍写 12000,设置页读盘显示旧值(会被当成"没生效")。
|
|
1996
|
+
if (bumped.length) this.persistConfigSyncPre()
|
|
1997
|
+
return cfg
|
|
1640
1998
|
} catch (e) {
|
|
1641
1999
|
if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
|
|
1642
2000
|
return this._mergeConfigPre(null)
|
|
@@ -1645,14 +2003,62 @@ class MemoryEngine {
|
|
|
1645
2003
|
|
|
1646
2004
|
async loadConfig() {
|
|
1647
2005
|
try {
|
|
1648
|
-
|
|
1649
|
-
|
|
2006
|
+
// ★#82:与 loadConfigSync 同口径(同一模块,保证两条路径行为一致)。
|
|
2007
|
+
const rd = readJsonQuarantinePreSync(this._configPath)
|
|
2008
|
+
if (!rd.ok) {
|
|
2009
|
+
if (rd.missing) return this._mergeConfigPre(null)
|
|
2010
|
+
this._readError = rd.corrupted
|
|
2011
|
+
? ('config corrupted, quarantined=' + (rd.quarantined || '(failed)') + ' reason=' + rd.reason)
|
|
2012
|
+
: String(rd.reason)
|
|
2013
|
+
return this._mergeConfigPre(null)
|
|
2014
|
+
}
|
|
2015
|
+
const parsed = rd.value
|
|
2016
|
+
const cfg = this._mergeConfigPre(parsed)
|
|
2017
|
+
// ⚠️ 传 parsed(磁盘原文) —— 合并结果里版本号恒等于当前版本,会使命中外层守卫而永不迁移。
|
|
2018
|
+
const bumped = this.upgradeCapacityDefaultsPre(parsed)
|
|
2019
|
+
// 内存里已抬升,但磁盘上还是旧值 ⇒ 落盘一次(否则下次重启重复走迁移分支;
|
|
2020
|
+
// 且设置页读的是磁盘,不落盘会显示 12000 与实际生效值不一致)。
|
|
2021
|
+
if (bumped.length) await this.persistConfigPre()
|
|
2022
|
+
return cfg
|
|
1650
2023
|
} catch (e) {
|
|
1651
2024
|
if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
|
|
1652
2025
|
return this._mergeConfigPre(null)
|
|
1653
2026
|
}
|
|
1654
2027
|
}
|
|
1655
2028
|
|
|
2029
|
+
/** 把当前内存配置原子写盘(迁移用;与 saveConfig 的写盘段同口径,但不做迁移/联动副作用)。 */
|
|
2030
|
+
async persistConfigPre() {
|
|
2031
|
+
try {
|
|
2032
|
+
// ★#82:tmp → rename 原子写。读方永远只见到完整版本,不会看到半截 JSON。
|
|
2033
|
+
const r = await writeTextAtomicPre(this._configPath, JSON.stringify(this.config, null, 2))
|
|
2034
|
+
if (!r.ok) { console.error('[dsh-auto-memory] persistConfigPre failed', r.error); return false }
|
|
2035
|
+
return true
|
|
2036
|
+
} catch (e) {
|
|
2037
|
+
console.error('[dsh-auto-memory] persistConfigPre failed', e)
|
|
2038
|
+
return false
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* `persistConfigPre` 的**同步**版本 —— 给注册期的 `loadConfigSync` 用。
|
|
2044
|
+
*
|
|
2045
|
+
* 为什么不能用异步那个:`loadConfigSync` 是在 `apply()` 里同步调用的(见其注释:
|
|
2046
|
+
* apply 不是 async 且返回值不被 await),异步写盘会在插件"加载完成"之后才落,
|
|
2047
|
+
* 期间设置页读盘拿到旧值 ⇒ 用户看到"改了没生效"。同步写盘量极小(一个配置 JSON),
|
|
2048
|
+
* 代价可接受,换来的是"内存值 === 磁盘值"这条不变量在任何时刻都成立。
|
|
2049
|
+
*/
|
|
2050
|
+
persistConfigSyncPre() {
|
|
2051
|
+
try {
|
|
2052
|
+
// ★#82:tmp → rename 原子写(与异步版同模块,行为一致)。
|
|
2053
|
+
const r = writeTextAtomicPreSync(this._configPath, JSON.stringify(this.config, null, 2))
|
|
2054
|
+
if (!r.ok) { console.error('[dsh-auto-memory] persistConfigSyncPre failed', r.error); return false }
|
|
2055
|
+
return true
|
|
2056
|
+
} catch (e) {
|
|
2057
|
+
console.error('[dsh-auto-memory] persistConfigSyncPre failed', e)
|
|
2058
|
+
return false
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
1656
2062
|
async saveConfig(patch) {
|
|
1657
2063
|
await this.loadConfig()
|
|
1658
2064
|
const oldRoot = this.expandUserPath(this.config.memoryRoot)
|
|
@@ -1953,8 +2359,12 @@ class MemoryEngine {
|
|
|
1953
2359
|
let archived = ''
|
|
1954
2360
|
if (existing && existing.trim() && existing.trim() !== incoming.trim()) {
|
|
1955
2361
|
const archDir = path.join(dir, 'archive')
|
|
1956
|
-
|
|
2362
|
+
// ★2026-09-20 移植(issue #94② / PR #100):同秒两次重写会算出同一归档名并**静默覆盖**
|
|
2363
|
+
// 上一份 —— 补上与姊妹路径(PLAN-history / 账本)同款的防撞后缀循环。
|
|
2364
|
+
const archBase = 'PLAN-' + handoffStamp()
|
|
2365
|
+
let archPath = path.join(archDir, archBase + '.md')
|
|
1957
2366
|
await mkdir(archDir, { recursive: true })
|
|
2367
|
+
for (let c = 98; c <= 122 && existsSync(archPath); c++) { archPath = path.join(archDir, archBase + '-' + String.fromCharCode(c) + '.md') }
|
|
1958
2368
|
await writeFile(archPath, existing, 'utf8')
|
|
1959
2369
|
archived = archPath
|
|
1960
2370
|
}
|
|
@@ -1989,14 +2399,19 @@ class MemoryEngine {
|
|
|
1989
2399
|
// WB-FORMAT-CONVENTION §2 锚点写入(仅 graph 档; legacy 档逐字节不变)
|
|
1990
2400
|
// 根因修复: 白板内容凭锚点自动进 L0 检索语料 —— 此前只把 id 写进 sidecar, Markdown 正文零锚点,
|
|
1991
2401
|
// 导致 §2 承诺的收益恒为零(规划 §5「已知现状缺口: 规范已批准、代码从未实现」)。
|
|
2402
|
+
// ★L5(2026-09-17) 解除 boardMode 闸门: 锚点是**写入格式契约**(WB-FORMAT-CONVENTION §2),
|
|
2403
|
+
// 与看板**渲染形态**无关。旧实现在这里误把它当渲染闸门 ⇒ legacy 档的白板永远拿不到锚点,
|
|
2404
|
+
// §2 承诺的「白板内容凭锚点自动进 L0 检索语料」恒为空(规划 §5「规范已批准、代码从未实现」的真因)。
|
|
2405
|
+
// 判据(用户已批准): 不问是不是 boardMode 门, 只问**门控的是「渲染」还是「写入/取材」**。
|
|
2406
|
+
// ⚠️ 注意: `written` 的初始化**必须保留** —— 无锚点变化时它就是写盘内容;
|
|
2407
|
+
// 漏掉会让下面的 writeSidecarEntryPre / return 里的 written 全是 undefined
|
|
2408
|
+
// (p7-write-fix G2 实测抓到: 白板写入直接失败、历史簿路径 undefined)。
|
|
1992
2409
|
let written = finalContent
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
} catch (_) { /* fail-soft: 锚点绝不阻塞主写入 */ }
|
|
1999
|
-
}
|
|
2410
|
+
try {
|
|
2411
|
+
const wsKey = this.wbWsKeyPre(projectDir)
|
|
2412
|
+
const anchored = applyAnchorsPre(wsKey, 'handoff/PLAN.md', finalContent)
|
|
2413
|
+
if (anchored.text !== finalContent) { written = anchored.text; await this.writeFullRaw(planPath, written) }
|
|
2414
|
+
} catch (_) { /* fail-soft: 锚点绝不阻塞主写入 */ }
|
|
2000
2415
|
// P2 sidecar(仅 boardMode=graph; fail-soft, 不阻塞主写入)
|
|
2001
2416
|
await this.writeSidecarEntryPre(projectDir, 'handoff/PLAN.md', written, '白板 PLAN', '')
|
|
2002
2417
|
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
@@ -2032,15 +2447,13 @@ class MemoryEngine {
|
|
|
2032
2447
|
})
|
|
2033
2448
|
if (!gate.ok) return { ok: false, error: gate.error, gate: gate.gate, report: gate.report }
|
|
2034
2449
|
for (let c = 98; c <= 122 && existsSync(p); c++) { p = path.join(dir, base + '-' + String.fromCharCode(c) + '.md') }
|
|
2035
|
-
//
|
|
2450
|
+
// ★L5(2026-09-17) 同上: 账本锚点也是**写入格式契约**, 解除 boardMode 闸门。
|
|
2036
2451
|
let writtenLedger = fullText
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
} catch (_) { /* fail-soft */ }
|
|
2043
|
-
}
|
|
2452
|
+
try {
|
|
2453
|
+
const wsKey = this.wbWsKeyPre(projectDir)
|
|
2454
|
+
const anchored = applyAnchorsPre(wsKey, 'handoff/' + path.basename(p), fullText)
|
|
2455
|
+
if (anchored.text !== fullText) writtenLedger = anchored.text
|
|
2456
|
+
} catch (_) { /* fail-soft */ }
|
|
2044
2457
|
await this.writeFullRaw(p, writtenLedger)
|
|
2045
2458
|
// P2 sidecar(仅 boardMode=graph; fail-soft, 不阻塞主写入)
|
|
2046
2459
|
await this.writeSidecarEntryPre(projectDir, 'handoff/' + path.basename(p), writtenLedger, '交接账本 ' + path.basename(p), '')
|
|
@@ -2106,7 +2519,18 @@ class MemoryEngine {
|
|
|
2106
2519
|
// 保留调用方刚写入的富条目(rebuild 只是补倒排, 正文预览以已有条目为准)
|
|
2107
2520
|
fresh.entries = entries
|
|
2108
2521
|
await mkdir(sideDir, { recursive: true })
|
|
2109
|
-
|
|
2522
|
+
// ★2026-09-22:改**原子写**(tmp + 有界重试 rename)。
|
|
2523
|
+
// 旧实现是裸 `await writeFile(index.json, …)` —— 这份索引 235 KB,写入过程**不是原子的**:
|
|
2524
|
+
// 面板/工具恰在此时读,读到的要么是零字节、要么是半截 JSON ⇒ `JSON.parse` 失败 ⇒ 看板/白板
|
|
2525
|
+
// **整块空白**,写完才恢复(用户实测:「白板不见了 → 过了一会儿又出现了」)。时间戳可对:
|
|
2526
|
+
// 索引在 09-22 00:52:10 被整份重写,同一秒宿主诊断 `…-diagnose.log` 连出 6 条
|
|
2527
|
+
// `ctx-host degrade: index-not-ready(commit)` —— 就是这段「不可读窗口」。
|
|
2528
|
+
// rename 在同目录内是原子替换;Windows 上读侧句柄争用会抛 EPERM/EBUSY,
|
|
2529
|
+
// 故复用 #48 的 `retryRename`(有界退避,且不做 unlink/copy 回退以免破坏原子性)。
|
|
2530
|
+
const idxPath = path.join(sideDir, 'index.json')
|
|
2531
|
+
const tmpPath = idxPath + '.tmp'
|
|
2532
|
+
await writeFile(tmpPath, JSON.stringify(fresh, null, 2), 'utf8')
|
|
2533
|
+
await retryRename(tmpPath, idxPath)
|
|
2110
2534
|
return fresh
|
|
2111
2535
|
}
|
|
2112
2536
|
|
|
@@ -2129,7 +2553,17 @@ class MemoryEngine {
|
|
|
2129
2553
|
const sideDir = path.join(projectDir, 'handoff')
|
|
2130
2554
|
const docs = await this._collectWhiteboardDocsPre(projectDir).catch(() => [])
|
|
2131
2555
|
const index = rebuildSidecarIndexPre(this.wbWsKeyPre(projectDir), docs)
|
|
2132
|
-
try {
|
|
2556
|
+
try {
|
|
2557
|
+
await mkdir(sideDir, { recursive: true })
|
|
2558
|
+
// ★2026-09-22:与 `_writeSidecarIndexPre` 同一口径 —— **重建路径也必须原子替换**。
|
|
2559
|
+
// 这里同样是整份索引(实测 235 KB)裸写;而重建恰恰是在「索引缺失/损坏」时被触发的,
|
|
2560
|
+
// 此刻面板正等着读这份文件 ⇒ 裸写期间的读者拿到半截 JSON,白板依旧整块空白。
|
|
2561
|
+
// (本处由守卫 `smoke-test-board-index-atomic-pre.mjs` 抓出:它断言「全仓不得再有裸写 index.json」。)
|
|
2562
|
+
const rIdx = path.join(sideDir, 'index.json')
|
|
2563
|
+
const rTmp = rIdx + '.tmp'
|
|
2564
|
+
await writeFile(rTmp, JSON.stringify(index, null, 2), 'utf8')
|
|
2565
|
+
await retryRename(rTmp, rIdx)
|
|
2566
|
+
} catch (_) {}
|
|
2133
2567
|
return index
|
|
2134
2568
|
}
|
|
2135
2569
|
|
|
@@ -2228,10 +2662,17 @@ class MemoryEngine {
|
|
|
2228
2662
|
* 闸门: 仅 boardMode='graph' 返回看板数据; legacy 返回 { enabled:false, reason:'legacy-mode' }
|
|
2229
2663
|
* —— 前端据此保持旧文字白板(legacy 逐字节不变)。
|
|
2230
2664
|
* fail-soft: sidecar 缺失由 _loadSidecarIndexPre 自愈重建; 全程 try/catch 不阻塞面板。
|
|
2665
|
+
*
|
|
2666
|
+
* ★2026-09-17 解耦(L1, 终端用户报障): **移除 handoffEnabled 门**。
|
|
2667
|
+
* 原实现首行 `if (handoffEnabled === false) return {enabled:false, reason:'handoff-disabled'}`,
|
|
2668
|
+
* 导致关掉白板产物的用户看到「未启用或加载失败」, 而 `handoff/` 里的 PLAN.md 与账本**根本没被删**——
|
|
2669
|
+
* 数据还在, 只是被这道门挡在门外。
|
|
2670
|
+
* 判据(全批统一): 门控的是「渲染」还是「写入/取材」? `handoffEnabled` 的语义是**产物层**
|
|
2671
|
+
* (见 buildContinueCarry 头注释: 关时既不写、也不读) ⇒ 它管写入/取材; **看板是渲染**, 不该被它拦。
|
|
2672
|
+
* 保持者: boardMode 门(下方那条) —— 那是渲染形态开关, 且 legacy 须与旧行为逐字节相同(:3673 纪律)。
|
|
2231
2673
|
*/
|
|
2232
2674
|
async kanbanBoardData(sessionId, opts = {}) {
|
|
2233
2675
|
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2234
|
-
if (this.config.handoffEnabled === false) return { enabled: false, reason: 'handoff-disabled' }
|
|
2235
2676
|
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2236
2677
|
return { enabled: false, reason: 'legacy-mode' }
|
|
2237
2678
|
}
|
|
@@ -2310,12 +2751,19 @@ class MemoryEngine {
|
|
|
2310
2751
|
return { ok: false, gate: 'criteria', error: criteriaRefusalTextPre(crit), report: crit, criteria: crit }
|
|
2311
2752
|
}
|
|
2312
2753
|
}
|
|
2313
|
-
// ── ②
|
|
2754
|
+
// ── ② 共同保护门(**仅对 plan 生效**;见下方 L6 说明)──
|
|
2314
2755
|
// 锚点完备性属白板线 P1/P2,**不在此拦截**:现存 PLAN.md 一个锚点都没有,
|
|
2315
2756
|
// 硬判会让所有既有白板立刻写不进去(那会把"保护"变成"锁死")。
|
|
2316
2757
|
// 保护门在**能证明丢卡**时生效:即 before 侧解析出了卡片集合,而 after 侧少了。
|
|
2317
|
-
|
|
2318
|
-
|
|
2758
|
+
//
|
|
2759
|
+
// ★L6(2026-09-17)·注释与实现对齐:下面两行**不是**"账本被静默放行", 而是**显式不适用**——
|
|
2760
|
+
// 保护门判的是"同一目标被**重写**时是否丢卡", 而账本的写入路径恒为
|
|
2761
|
+
// `beforeText: ''`(每次新写一篇, 见 writeHandoffLedger 的调用), 无 before 侧可比
|
|
2762
|
+
// ⇒ 结构性谈不上丢卡。故此处**显式**提前返回, 并把理由写在这里。
|
|
2763
|
+
// ⚠️ 与 `:332` 模块注释及本函数 docblock 曾声称的"无条件生效"**措辞不一致**, 现以本处为准:
|
|
2764
|
+
// 保护门 = **plan 专属**; 账本/其他 target 走的是"不适用"而非"跳过检查"。
|
|
2765
|
+
if (target !== 'plan' && target !== 'handoff') return { ok: true, gate: 'not-applicable', reason: 'target-not-protected' }
|
|
2766
|
+
if (target === 'handoff') return { ok: true, gate: 'not-applicable', reason: 'ledger-append-only-no-before-side' }
|
|
2319
2767
|
if (!beforeText.trim()) return { ok: true }
|
|
2320
2768
|
const projB = parseWhiteboardPre(beforeText, { kind: 'plan' })
|
|
2321
2769
|
const projA = parseWhiteboardPre(afterText, { kind: 'plan' })
|
|
@@ -2354,6 +2802,17 @@ class MemoryEngine {
|
|
|
2354
2802
|
} catch (e) { return [] }
|
|
2355
2803
|
}
|
|
2356
2804
|
|
|
2805
|
+
/** ★L3.6(2026-09-17): 列出旧会话转写(名字字典序倒序=新→旧)。
|
|
2806
|
+
* 存在的意义: `listHandoffLedgers` 的正则**结构化地**排除 `prev-session-*`,
|
|
2807
|
+
* 导致这些转写在 scope='handoff' 检索里恒不可见(用户报障"有些文件接不过去"的一条)。
|
|
2808
|
+
* 单独给一个 lister 而不是放宽原正则 —— 原正则同时被账本/归档的白板血缘逻辑复用,
|
|
2809
|
+
* 放宽会改变那些语义(它们本来就只该看见账本与 PLAN 归档)。 */
|
|
2810
|
+
async listPrevSessionTranscripts(dir, limit = 8) {
|
|
2811
|
+
try {
|
|
2812
|
+
return (await readdir(dir)).filter((n) => /^prev-session-[\w-]+\.md$/.test(n)).sort().reverse().slice(0, limit)
|
|
2813
|
+
} catch (e) { return [] }
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2357
2816
|
/** M-CM2:交接白板语料检索(PLAN.md+账本+归档;词法直返,轻量)。 */
|
|
2358
2817
|
/**
|
|
2359
2818
|
* 白板语料检索。**P2-3(2026-09-16 接线)**:由「纯词法」升级为
|
|
@@ -2416,6 +2875,18 @@ class MemoryEngine {
|
|
|
2416
2875
|
}
|
|
2417
2876
|
}
|
|
2418
2877
|
await scan('白板 PLAN.md', path.join(p.handoffDir, 'PLAN.md'), 3)
|
|
2878
|
+
// ★L3.6(2026-09-17): 旧会话转写此前是**检索孤岛** —— listHandoffLedgers 的正则只认
|
|
2879
|
+
// `handoff-<ts>` / `PLAN-<ts>`, 不认 `prev-session-*` ⇒ scope='handoff' 永远看不见旧会话。
|
|
2880
|
+
// 这里显式纳入(最多 8 篇最新), 并**优先用文件头部的 L0 摘要行**做卡片, 让模型先看到
|
|
2881
|
+
// "这个会话在干什么"而不是被整篇正文淹没。fail-soft: 读不到就跳过这一篇, 不阻塞其余语料。
|
|
2882
|
+
// ⚠️ 必须用 typeof 守卫: 本仓老 smoke 用 new Function 从源码重建方法, 重建体里没有本方法
|
|
2883
|
+
// (它挂在类上、不在这段源码里) ⇒ 直接调用会 TypeError 把整条检索打断。
|
|
2884
|
+
// 守卫后语义正确: 没有该 lister 的宿主就跳过这一类来源, 其余语料照常返回(fail-soft)。
|
|
2885
|
+
const prevList = typeof this.listPrevSessionTranscripts === 'function' ? await this.listPrevSessionTranscripts(p.handoffDir, 8) : []
|
|
2886
|
+
for (const n of prevList) {
|
|
2887
|
+
if (hits.length >= limit) break
|
|
2888
|
+
await scan('旧会话转写/' + n, path.join(p.handoffDir, n), 2)
|
|
2889
|
+
}
|
|
2419
2890
|
for (const n of await this.listHandoffLedgers(p.handoffDir, 12)) {
|
|
2420
2891
|
if (hits.length >= limit) break
|
|
2421
2892
|
await scan('交接账本/' + n, path.join(p.handoffDir, n), 2)
|
|
@@ -2689,7 +3160,7 @@ class MemoryEngine {
|
|
|
2689
3160
|
// 旧实现硬截断到 1.5 → 任何超额水位都显示成 150%,掩盖真实占用(用户实测 761692/131072 被显示为 150%)。
|
|
2690
3161
|
// 现在如实上报,仅做防溢出的 99 倍上限。
|
|
2691
3162
|
const ratio = triggerWin > 0 && Number.isFinite(estTokens) ? Math.min(estTokens / triggerWin, 99) : 0
|
|
2692
|
-
const threshold = Math.max(Number(this.config.waterLevelThreshold) ||
|
|
3163
|
+
const threshold = Math.max(Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD, 0.1)
|
|
2693
3164
|
// 硬触发:官方已经压缩过/已撞过窗口墙 —— 这一刻必须接续,不依赖比例是否算得准。
|
|
2694
3165
|
// 2026-09-13 增加预测性硬墙:estTokens + reserve > 判定窗 ⇒ 下一次请求必被 provider 拒绝
|
|
2695
3166
|
// (消息实占 + 预留输出 > 上限,v2.4.1 之前的 400 事故就是这个等式)。
|
|
@@ -3018,7 +3489,7 @@ class MemoryEngine {
|
|
|
3018
3489
|
diag('auto-continue not armed: session model unknown (window derived from default model), waiting for a hard signal')
|
|
3019
3490
|
return
|
|
3020
3491
|
}
|
|
3021
|
-
if (!(wl.ratio >= (Number(this.config.autoContinueThreshold) ||
|
|
3492
|
+
if (!(wl.ratio >= (Number(this.config.autoContinueThreshold) || DEFAULT_AUTO_CONTINUE_THRESHOLD))) return
|
|
3022
3493
|
const st = this._autoContState || (this._autoContState = {})
|
|
3023
3494
|
const now = Date.now()
|
|
3024
3495
|
const cooldownMin = Number(this.config.autoContinueCooldownMinutes) || 30
|
|
@@ -3204,7 +3675,7 @@ class MemoryEngine {
|
|
|
3204
3675
|
// 2026-09-14 解耦:卡片可用性只看 autoContinueEnabled(与 armAutoContinue 同源同判);
|
|
3205
3676
|
// 旧实现的 `&& handoffEnabled !== false` 是同一处耦合的第二份副本(白板关⇒卡片报 disabled)。
|
|
3206
3677
|
enabled: this.config.autoContinueEnabled !== false,
|
|
3207
|
-
threshold: Number(this.config.autoContinueThreshold) ||
|
|
3678
|
+
threshold: Number(this.config.autoContinueThreshold) || DEFAULT_AUTO_CONTINUE_THRESHOLD,
|
|
3208
3679
|
armed: armedMine ? armedRaw : null,
|
|
3209
3680
|
executing: !!st.executing,
|
|
3210
3681
|
rejectedEdgeAt: st.rejectedEdgeAt || 0,
|
|
@@ -3269,6 +3740,13 @@ class MemoryEngine {
|
|
|
3269
3740
|
* 回退按会话日志 mtime 取最新,**只认 `session-` 前缀的普通会话目录**:裸 uuid 目录是子代理会话,
|
|
3270
3741
|
* 把刷新仪式注进子代理等于白做(还会污染它的上下文),所以宁可返回 '' 让上层明确报"无可刷新目标"。
|
|
3271
3742
|
* 5 秒缓存避免面板轮询反复扫盘。
|
|
3743
|
+
*
|
|
3744
|
+
* ★2026-09-21 补注(实测校准): 上述前缀规则**成立**, 但有一处需要说清 —— 带 `session-` 前缀的
|
|
3745
|
+
* 目录**不全是「顶层会话」**: 接续会话(由「一键接续/自动接续」派生)同样带此前缀, 且**带
|
|
3746
|
+
* parentSession**。它是**用户真实会话**(delegationDepth=0), 正是刷新仪式该注入的目标 ——
|
|
3747
|
+
* 故前缀规则无需改。⚠️ **切忌改用「有无 parentSession」来判断归属**: 那会把接续会话误判成
|
|
3748
|
+
* 子代理(restoreLastAgent 的历史 bug 即此, 详见 isSubAgentSession 的注释)。
|
|
3749
|
+
* 判据请统一用 isSubAgentSession()(看 origin / delegationDepth, 不看 parentSession)。
|
|
3272
3750
|
*/
|
|
3273
3751
|
recentSessionIdFallback() {
|
|
3274
3752
|
try {
|
|
@@ -3283,7 +3761,14 @@ class MemoryEngine {
|
|
|
3283
3761
|
const wsPath = path.join(root, wsDir.name)
|
|
3284
3762
|
let entries = []
|
|
3285
3763
|
try { entries = readdirSync(wsPath, { withFileTypes: true }) } catch (_) { continue }
|
|
3286
|
-
|
|
3764
|
+
// ★2026-09-21 核查结论(证据: tools/probe-session-kind.mjs 解压会话头部):
|
|
3765
|
+
// 「`session-` 前缀 = 用户会话 / 裸 uuid = 子代理」这条规则**经实测仍然成立**, 无需改:
|
|
3766
|
+
// · session-85e2b7e8…(接续会话, parentSession=aa9ba629) depth=0 origin=- ⇒ 用户会话 ✓
|
|
3767
|
+
// · session-aa9ba629…(顶层会话, 无 parent) depth=0 origin=- ⇒ 用户会话 ✓
|
|
3768
|
+
// · 3cf74a9f… / bae475f2… / c9e60d61…(裸 uuid) depth=1 origin=subagent ⇒ 子代理 ✗
|
|
3769
|
+
// 注意「接续会话也带 session- 前缀」——这是**期望行为**(它是用户会话, 正是刷新目标),
|
|
3770
|
+
// 但也说明**不能用 parentSession 有无来判断归属**(那正是 restoreLastAgent 那个 bug 的成因)。
|
|
3771
|
+
for (const ent of entries) {
|
|
3287
3772
|
if (!ent.isDirectory()) continue
|
|
3288
3773
|
if (!/^session-/.test(ent.name)) continue
|
|
3289
3774
|
let ms = 0
|
|
@@ -3532,6 +4017,20 @@ class MemoryEngine {
|
|
|
3532
4017
|
const msgs = folded.msgs
|
|
3533
4018
|
const p = await this.resolvePaths(undefined)
|
|
3534
4019
|
const NL = String.fromCharCode(10)
|
|
4020
|
+
// ★L3.6(2026-09-17): 稳定锚点 + L0 摘要行(让旧会话转写**可被检索到**, 见 prevSessionSidAnchorPre 注释)
|
|
4021
|
+
const PER_MSG = 2000
|
|
4022
|
+
const sidAnchor = prevSessionSidAnchorPre(sid, () => createHash('sha256'))
|
|
4023
|
+
const l0Line = prevSessionL0Pre(msgs, sid, { cap: 120 }) || '(无可摘要内容)'
|
|
4024
|
+
const slim = slimTranscriptPre(msgs, {
|
|
4025
|
+
perMsgChars: PER_MSG,
|
|
4026
|
+
totalChars: 60000,
|
|
4027
|
+
// L3.5 的附件内联行保留在**消息附近**(位置语义: 这张图是在说什么的时候投的)
|
|
4028
|
+
decorate: (m) => {
|
|
4029
|
+
if (!Array.isArray(m.attachments) || !m.attachments.length) return ''
|
|
4030
|
+
const inl = renderAttachmentLinesPre(m.attachments)
|
|
4031
|
+
return inl.length ? NL + inl.map((x) => ' - ' + x).join(NL) : ''
|
|
4032
|
+
},
|
|
4033
|
+
})
|
|
3535
4034
|
const stamp = new Date().toISOString().slice(11, 19).replace(/:/g, '')
|
|
3536
4035
|
const outPath = path.join(p.handoffDir, 'prev-session-' + sid.slice(0, 8) + '-' + stamp + '.md')
|
|
3537
4036
|
// 接续序号 v2(2026-09-13):先分配序号、再写包;写包失败回滚计数器(不跳号)。
|
|
@@ -3540,20 +4039,34 @@ class MemoryEngine {
|
|
|
3540
4039
|
try { contSeq = await this.allocContSeq(p.ws) } catch (eSeq) { diag('allocContSeq error: ' + ((eSeq && eSeq.message) || eSeq)) }
|
|
3541
4040
|
const body = [
|
|
3542
4041
|
'# 旧会话完整转写(' + sid + ')', '',
|
|
4042
|
+
'<!-- ' + sidAnchor + ' -->',
|
|
3543
4043
|
'- 旧会话 ID: ' + sid,
|
|
3544
4044
|
'- 工作区: ' + (cwd || p.ws || '(未知)'),
|
|
3545
4045
|
'- 模型: ' + (model || '(未记录)') + (provider ? ' @ ' + provider : ''),
|
|
3546
4046
|
'- agentPreset: ' + (preset || '(默认)'),
|
|
3547
|
-
'- 消息数: ' + msgs.length + '(
|
|
4047
|
+
'- 消息数: ' + msgs.length + '(已瘦身: 仅用户输入与助手最终输出;单条截断 ' + PER_MSG + ' 字符,总长上限 60000)',
|
|
4048
|
+
'- **L0**: ' + l0Line,
|
|
3548
4049
|
'- 原始持久化: ' + file + '(zstd 压缩帧,AI 的 read 工具读不了;本文件是插件解压后的可读转写)', '',
|
|
3549
4050
|
]
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
4051
|
+
// ★L3.5(2026-09-17): 附件清单 —— 把被丢弃的 attachment 字段还原成"去哪儿找"的路径。
|
|
4052
|
+
// 旧行为: 附件在 foldSessionLogEvents 里就不进 msgs ⇒ 接续会话完全不知道有这些材料。
|
|
4053
|
+
const allAtts = []
|
|
4054
|
+
for (const m of msgs) { if (Array.isArray(m.attachments)) for (const a of m.attachments) allAtts.push(a) }
|
|
4055
|
+
if (allAtts.length) {
|
|
4056
|
+
const attLines = renderAttachmentLinesPre(allAtts)
|
|
4057
|
+
body.push('## 附件清单(' + allAtts.length + ' 项,按出现顺序;同一 blob 只列一次)', '')
|
|
4058
|
+
for (const ln of attLines) body.push('- ' + ln)
|
|
4059
|
+
body.push('', '> 图片是内容寻址 blob,直接 read 上述路径即可看到原图;文件对象同理。', '')
|
|
4060
|
+
}
|
|
4061
|
+
// ★L3.6(2026-09-17): 正文改用**瘦身**结果 —— 只留用户输入与助手最终输出,
|
|
4062
|
+
// 工具调用/结果压成一行计数(实测工具事件是正文的 3 倍以上, 全量转写既费盘又淹没检索)。
|
|
4063
|
+
// 附件行仍按消息内联(见 renderAttachmentLinesPre), 位置语义不变。
|
|
4064
|
+
body.push(slim.body)
|
|
4065
|
+
if (slim.toolCalls || slim.toolResults) {
|
|
4066
|
+
body.push('', '> 本次转写已瘦身: 省略了 ' + slim.toolCalls + ' 次工具调用与 ' + slim.toolResults +
|
|
4067
|
+
' 条工具结果(共约 ' + slim.droppedChars + ' 字符)。完整原始事件见上方"原始持久化"所指的会话日志文件。')
|
|
4068
|
+
}
|
|
4069
|
+
if (slim.trimmed) body.push('', '(更早内容已截断——完整历史可试 memory_recall(scope=\'sessions\', query=\'关键词\') 检索;该检索需宿主侧开启会话内容检索,条件不满足时会明确报错,此时请改用 scope=\'handoff\' 或直接 read 本文件)')
|
|
3557
4070
|
try {
|
|
3558
4071
|
// P2 ALS 修复后 refresh 真正跑起来,与 handoff 写包并发创建同一 handoffDir;
|
|
3559
4072
|
// Windows 上并发 mkdir(recursive) 有 EEXIST 竞态(nodejs/node#31453 类)——目录已存在即达成目标,
|
|
@@ -3565,9 +4078,16 @@ class MemoryEngine {
|
|
|
3565
4078
|
if (contSeq) { try { this.rollbackContSeq(p.ws, contSeq) } catch (eR) {} }
|
|
3566
4079
|
throw eW
|
|
3567
4080
|
}
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
4081
|
+
// ★L3.5: 第2层近期线程也带上附件行(单行), 让"最近投过什么"在层2就可见
|
|
4082
|
+
const tail = msgs.slice(-20).map((m) => {
|
|
4083
|
+
const base = m.role + ': ' + m.text.slice(0, 700)
|
|
4084
|
+
if (!Array.isArray(m.attachments) || !m.attachments.length) return base
|
|
4085
|
+
const inl = renderAttachmentLinesPre(m.attachments)
|
|
4086
|
+
return inl.length ? base + NL + inl.map((x) => ' ' + x).join(NL) : base
|
|
4087
|
+
}).join(NL + '---' + NL)
|
|
4088
|
+
const attLines = allAtts.length ? renderAttachmentLinesPre(allAtts) : []
|
|
4089
|
+
diag('prev-session pack built: sid=' + sid + ' msgs=' + msgs.length + ' atts=' + allAtts.length + ' model=' + (model || '-') + '@' + (provider || '-') + ' effort=' + (reasoningEffort || '-') + ' contSeq=' + contSeq + ' -> ' + outPath)
|
|
4090
|
+
return { sessionId: sid, transcriptPath: outPath, contSeq, provider, model, reasoningEffort, agentPreset: preset, cwd: cwd || p.ws, tailText: tail, msgCount: msgs.length, attachmentCount: allAtts.length, attachmentLines: attLines }
|
|
3571
4091
|
} catch (e) { diag('buildPrevSessionPack error: ' + (e && e.message)); return null }
|
|
3572
4092
|
}
|
|
3573
4093
|
|
|
@@ -3612,13 +4132,19 @@ class MemoryEngine {
|
|
|
3612
4132
|
const prevSid = (pack && pack.sessionId) || this.currentSessionId()
|
|
3613
4133
|
const wsForSession = (pack && pack.cwd) || p.ws
|
|
3614
4134
|
const workspaceId = this.resolveWorkspaceIdForSession(prevSid, wsForSession)
|
|
3615
|
-
|
|
4135
|
+
// ★2026-09-17(L3): 材料改为**三段**结构, 由 assembleCarryPre 组装 ——
|
|
4136
|
+
// headParts(指令, 固定) / navParts(导航区, **永不截断**) / bulkParts(正文层, 可截断)。
|
|
4137
|
+
// 旧实现把导航区(第3层转写路径)**放在数组末尾**, 而 `join().slice(0,18000)` 从尾部砍
|
|
4138
|
+
// ⇒ 逃生通道第一个被砍(用户实测报障「有些文件接不过去」)。此处把导航区提前并钉住。
|
|
4139
|
+
const headParts = [
|
|
3616
4140
|
'接续上一会话的任务。材料已分层,请按需取用而非通读:先看第0层白板建立全局图景,再视需要看第1层账本(含已试方案与失败原因)与第2层近期线程;第3层完整转写仅在前三层不足以推进时才 read。恢复上下文后直接继续推进未完成事项,不要重新开始。',
|
|
3617
4141
|
'材料分层:第0层=指令+白板(全局图景);第1层=交接账本(四段式,最新);第2层=近期线程(最近 20 条,单条上限 700 字,保留角色与工具标记);第3层=完整转写(按需 read)。',
|
|
3618
4142
|
]
|
|
4143
|
+
const navParts = []
|
|
4144
|
+
const bulkParts = []
|
|
3619
4145
|
// 白板关(载体第0/1层缺失)时如实自述,避免新会话去找不存在的 PLAN.md/账本
|
|
3620
|
-
if (!withHandoff)
|
|
3621
|
-
if (plan)
|
|
4146
|
+
if (!withHandoff) headParts.push('注意:本次启用了自动接续但**未启用白板/账本**(handoffEnabled=false)——没有第0/1层材料,请以第2层近期线程与第3层完整转写恢复上下文,不要去找 PLAN.md 或交接账本。')
|
|
4147
|
+
if (plan) bulkParts.push('【第0层 · 白板 PLAN.md(节选)】更新于 ' + fmtMt(planMt) + staleNote + NL + plan.slice(0, 3000))
|
|
3622
4148
|
if (ledger) {
|
|
3623
4149
|
// P6(2026-09-09):账本权重化截断 —— 按账本自身四段标题赋权(失败原因.35>下一步.30>目标.20>任务状态.15),
|
|
3624
4150
|
// 预算不足从最低权重段开始截(段标题保留)。解析失败/动态 import 失败 fail-soft 回落位置截断,绝不阻塞接续(I4)。
|
|
@@ -3629,11 +4155,20 @@ class MemoryEngine {
|
|
|
3629
4155
|
const weighted = weightedTrimHandoffLedgerPre(ledger, 8000)
|
|
3630
4156
|
if (weighted != null) ledgerBody = weighted
|
|
3631
4157
|
} catch (eLedW) {}
|
|
3632
|
-
|
|
4158
|
+
bulkParts.push('【第1层 · 交接账本 ' + (ledgerName || ledgerFrom) + '】写于 ' + fmtMt(ledgerMt) + ' (' + ledgerFrom + ')' + NL + ledgerBody)
|
|
3633
4159
|
}
|
|
3634
4160
|
if (pack) {
|
|
3635
|
-
if (pack.tailText)
|
|
4161
|
+
if (pack.tailText) bulkParts.push('【第2层 · 近期线程(最近 ' + Math.min(20, Number(pack.msgCount) || 20) + ' 条 / 共 ' + (pack.msgCount || 0) + ' 条)】' + NL + pack.tailText)
|
|
4162
|
+
// ★L3.5(2026-09-17): 附件清单进 **bulk**(可截断的身体层) —— 用户批准的方案 A:
|
|
4163
|
+
// 清单是"材料", 可能很长; 而"去哪儿取"的**指令**进 nav(永不截断), 二者分离。
|
|
4164
|
+
if (Array.isArray(pack.attachmentLines) && pack.attachmentLines.length) {
|
|
4165
|
+
bulkParts.push('【第2.5层 · 旧会话附件清单(' + pack.attachmentCount + ' 项)】' + NL +
|
|
4166
|
+
pack.attachmentLines.map((x) => '- ' + x).join(NL))
|
|
4167
|
+
}
|
|
3636
4168
|
const guide = ['【第3层 · 完整转写与检索(按需)】']
|
|
4169
|
+
if (pack.attachmentCount > 0) {
|
|
4170
|
+
guide.push('- 旧会话里带有 **' + pack.attachmentCount + ' 个附件**(图片/文件)。它们**不在转写正文里**,而是内容寻址 blob,已在上方"附件清单"逐条列出绝对路径 —— 需要看图/看文件时直接 read 那一路径(图片是 PNG/JPEG 原文件,可直接读);不要因为正文里只有文字就以为用户没投过材料。')
|
|
4171
|
+
}
|
|
3637
4172
|
if (pack.transcriptPath) {
|
|
3638
4173
|
guide.push('- 旧会话(' + pack.sessionId + ')的完整对话转写已写入: ' + pack.transcriptPath)
|
|
3639
4174
|
guide.push('- 完整转写已归档,**不必在接续前通读**:仅在第0-2层不足以推进时再 read;也可用 memory_recall(scope=\'sessions\', query=\'关键词\') 定位片段(前提:宿主侧已开启会话内容检索;该调用返回不可用时改用 scope=\'handoff\' 或直接 read 本转写,不要反复重试)。')
|
|
@@ -3643,7 +4178,8 @@ class MemoryEngine {
|
|
|
3643
4178
|
guide.push('- 历史会话全文检索: memory_recall(scope=\'sessions\', query=\'关键词\') 可跨全部历史会话查证细节 —— **前提:宿主 DSH 侧已开启会话内容检索(出厂默认关闭)且历史日志格式与当前编解码器兼容**;不满足时该调用会明确报错(未部署/格式不兼容),此时改用 scope=\'handoff\' 或 read 转写文件,不要重复重试。')
|
|
3644
4179
|
if (pack.model) guide.push('- 状态沿用: 模型 ' + pack.model + (pack.provider ? ' @ ' + pack.provider : '') + (pack.reasoningEffort ? '(思考档位 ' + pack.reasoningEffort + ')' : '') + ' 已在新会话自动选回(失败则回退路由默认);工作区 ' + (workspaceId ? '按 workspaceId 绑定 ' + workspaceId : '按 cwd 绑定 ' + wsForSession) + ';插件配置(无人值守/水位/交接等)为全局配置,自动继承。')
|
|
3645
4180
|
if (pack.agentPreset) guide.push('- 旧会话 agentPreset: ' + pack.agentPreset + '(已随创建传递;注意 code→ptc 改名的历史会话需用户级兼容预设)。')
|
|
3646
|
-
|
|
4181
|
+
// ★L3: 导航区(逃生通道)入 navParts —— **永不截断**, 预算先扣。
|
|
4182
|
+
navParts.push(guide.join(NL))
|
|
3647
4183
|
}
|
|
3648
4184
|
// P5(2026-09-09):接续锚点表 —— 用 T1 的 L0 抽取生成「记忆条目地图」,供新会话按需下钻
|
|
3649
4185
|
// (notesPath/logPath 正是 `<!-- memory:mem_<32hex> -->` 锚点的载体;纯解析零 LLM;字节稳定:
|
|
@@ -3664,7 +4200,7 @@ class MemoryEngine {
|
|
|
3664
4200
|
anchorSectionFor('今日日志', p.logPath, 10),
|
|
3665
4201
|
])).filter(Boolean)
|
|
3666
4202
|
if (anchorSections.length) {
|
|
3667
|
-
|
|
4203
|
+
bulkParts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
|
|
3668
4204
|
}
|
|
3669
4205
|
} catch (eAnchor) {}
|
|
3670
4206
|
// ── P2-4(2026-09-16 接线):注入端白板 tag 导航层 ─────────────────────────
|
|
@@ -3675,17 +4211,17 @@ class MemoryEngine {
|
|
|
3675
4211
|
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
3676
4212
|
try {
|
|
3677
4213
|
const tagMap = await this.whiteboardTagMapPre(null, p)
|
|
3678
|
-
if (tagMap)
|
|
4214
|
+
if (tagMap) bulkParts.push('【白板 tag 地图(结构化导航)】' + tagMap + NL + ' · 用 memory_expand(tag) 按 tag 展开条目;用 memory_trace(id) 回溯某条的来源与版本链。')
|
|
3679
4215
|
} catch (eTagMap) {}
|
|
3680
4216
|
}
|
|
3681
4217
|
// ── P3-2(2026-09-16 接线):第3层唤醒句 ───────────────────────────────────
|
|
3682
|
-
// 规划 §5 P3-2 原文:第3层 guide 加一句「白板已结构化:可用
|
|
4218
|
+
// 规划 §5 P3-2 原文:第3层 guide 加一句「白板已结构化:可用 memory_expand/memory_trace
|
|
3683
4219
|
// 按 tag 主动重建,先于通读第3层」。此前只在工具描述里写了用途,接续首条消息里从未提示 ⇒
|
|
3684
4220
|
// 模型不会主动去用(存量行为等同工具不存在)。同样只在 graph 档注入。
|
|
3685
4221
|
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
3686
|
-
|
|
4222
|
+
bulkParts.push('【白板结构化检索(优先于通读第3层)】' + NL +
|
|
3687
4223
|
'- 白板/账本已建结构化索引(handoff/index.json:按 tag 倒排 + 条目 id + 归档版本链)。' + NL +
|
|
3688
|
-
'- **先按 tag 主动重建,不要直接通读第3层转写**:
|
|
4224
|
+
'- **先按 tag 主动重建,不要直接通读第3层转写**:memory_expand(tag=\'type:dead-end\') 列出所有失败方案;memory_expand(tag=\'*\') 看全部条目;条目 id 可用 memory_trace(id) 回溯其来源(source 文件+行)、同 tag 邻居与归档版本链。' + NL +
|
|
3689
4225
|
'- 仅当索引查不到所需内容时,再回落 read 第3层转写或 memory_recall(scope=\'handoff\')。')
|
|
3690
4226
|
}
|
|
3691
4227
|
// 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器,全局单调(跨工作区不重号)。
|
|
@@ -3699,9 +4235,15 @@ class MemoryEngine {
|
|
|
3699
4235
|
}
|
|
3700
4236
|
if (!contSeq) contSeq = 1
|
|
3701
4237
|
const wsBase = String((pack && pack.cwd) || p.ws || '').split(/[\\/]/).filter(Boolean).pop() || ''
|
|
4238
|
+
// ★L3(2026-09-17): 用 assembleCarryPre 组装 —— headParts/navParts 永不截断, bulkParts 可截断,
|
|
4239
|
+
// 且截断时**显式告知未包含哪些层**。旧实现 `parts.join().slice(0,18000)` 从尾部砍,
|
|
4240
|
+
// 首先砍掉第3层转写路径(模型逃生通道) ⇒ 用户实测"有些文件接不过去"。
|
|
4241
|
+
const carry = assembleCarryPre({ head: headParts, nav: navParts, bulk: bulkParts, budget: 18000 })
|
|
3702
4242
|
return {
|
|
3703
4243
|
ok: true,
|
|
3704
|
-
carryText:
|
|
4244
|
+
carryText: carry.text,
|
|
4245
|
+
carryTruncated: carry.truncated,
|
|
4246
|
+
carryDropped: carry.dropped,
|
|
3705
4247
|
planPath: p.planPath,
|
|
3706
4248
|
ws: (pack && pack.cwd) || p.ws,
|
|
3707
4249
|
workspaceId: workspaceId,
|
|
@@ -3726,7 +4268,12 @@ class MemoryEngine {
|
|
|
3726
4268
|
// 配置加载守卫(2026-09-13):handoffEnabled 出厂默认 false,若宿主刚重启、面板先于任何会话活动
|
|
3727
4269
|
// 打开,旧实现按未加载的默认值误报「白板未启用」——与 resolvePaths 同款守卫。
|
|
3728
4270
|
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
3729
|
-
|
|
4271
|
+
// ★2026-09-17 解耦(L1, 与 kanbanBoardData 同批): **移除 handoffEnabled 门**,并**补上 reason**
|
|
4272
|
+
// (旧实现 `return { enabled:false }` 连原因都不给,前端只能猜,与看板那个误导提示同源)。
|
|
4273
|
+
// 判据同 kanbanBoardData: 面板是**渲染**, `handoffEnabled` 管的是**写入/取材**, 不该互相牵连。
|
|
4274
|
+
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
4275
|
+
return { enabled: false, reason: 'legacy-mode' }
|
|
4276
|
+
}
|
|
3730
4277
|
// 2026-09-13(工作区切换 bug·终端用户实证):有 sessionId 时按会话解析路径 —— 旧实现白板/账本
|
|
3731
4278
|
// 恒用全局单值 state.*(最近活跃会话),查看非活跃会话时张冠李戴;会话解析不出身份时如实返回
|
|
3732
4279
|
// wsBound:false,不再拿 dsh 启动目录(process.cwd() 回退)冒充工作区。
|
|
@@ -3749,13 +4296,13 @@ class MemoryEngine {
|
|
|
3749
4296
|
water = {
|
|
3750
4297
|
ratio: Number(rec.ratio) || 0, tokens: Number(rec.tokens) || 0, window: Number(rec.window) || 0,
|
|
3751
4298
|
source: rec.source || '', meter: rec.meter || '', model: rec.model || '',
|
|
3752
|
-
threshold: Number(this.config.waterLevelThreshold) ||
|
|
4299
|
+
threshold: Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD, at: Number(rec.at) || 0, live: true,
|
|
3753
4300
|
}
|
|
3754
4301
|
} else {
|
|
3755
4302
|
const wi = await this.waterWindowForSession(sessionId)
|
|
3756
4303
|
water = {
|
|
3757
4304
|
ratio: 0, tokens: 0, window: Number(wi.window) || 0, source: wi.source || '', meter: '',
|
|
3758
|
-
model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) ||
|
|
4305
|
+
model: wi.model || '', threshold: Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD, at: 0, live: false,
|
|
3759
4306
|
}
|
|
3760
4307
|
}
|
|
3761
4308
|
} else {
|
|
@@ -3774,7 +4321,7 @@ class MemoryEngine {
|
|
|
3774
4321
|
source: this.state.waterLevelSource || '',
|
|
3775
4322
|
meter: this.state.waterLevelMeter || '',
|
|
3776
4323
|
model: this.state.waterLevelModel || '',
|
|
3777
|
-
threshold: Number(this.config.waterLevelThreshold) ||
|
|
4324
|
+
threshold: Number(this.config.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD,
|
|
3778
4325
|
at: Number(this.state.waterLevelAt) || 0,
|
|
3779
4326
|
live: Number(this.state.waterLevelAt) > 0,
|
|
3780
4327
|
}
|
|
@@ -4418,7 +4965,19 @@ class MemoryEngine {
|
|
|
4418
4965
|
// "无可回收",而文件其实只差几十字符就超限(2026-09-10 实机踩到,报错文案即"可回收内容为空")。
|
|
4419
4966
|
const needChars = Number(opts.needChars || 0) || (curChars + 1)
|
|
4420
4967
|
const deficit = Math.max(1, needChars - limit)
|
|
4421
|
-
|
|
4968
|
+
// ★R4(2026-09-18)修缺陷:保留量目标必须按**当前长度 − 缺口**算,**不能用 limit**。
|
|
4969
|
+
//
|
|
4970
|
+
// 旧写法 `Math.max(COMPACT_PROTECT_RECENT_CHARS, limit - deficit - 1)` 有两处方向性错误:
|
|
4971
|
+
// ① 代入 deficit = curChars + add − limit 后展开为 `2*limit − curChars − add − 1`
|
|
4972
|
+
// ⇒ **保留目标随额度单调递增**(额度调大 → 目标变大 → 越"没东西可回收");
|
|
4973
|
+
// ② 被 `COMPACT_PROTECT_RECENT_CHARS`(2000) 抬起 ⇒ **软**保护窗口变成了**硬**下限,
|
|
4974
|
+
// 文件本身不足 2000 字符时"可回收"恒为空 —— 与锚点路径
|
|
4975
|
+
// (compactAnchoredLayer 的注释明写"保护窗口是**软**下限,唯一硬底线是最新 1 条")语义打架。
|
|
4976
|
+
// 后果实测:额度落在 [L2, L1) 区间时写入被拒(no-removable),而更小或更大的额度都能写
|
|
4977
|
+
// ⇒ **非单调**,用户看到的正是「把额度加大成两倍反而锁死」。回归见
|
|
4978
|
+
// tests/smoke/smoke-test-r4-budget-lockup-pre.mjs。
|
|
4979
|
+
// 现在:保留量 = 恰好能放下本次写入的量;保护窗口仍由"从尾往前保留 + 最新段硬底线"实现。
|
|
4980
|
+
const keepBudget = Math.max(0, curChars - deficit - 1)
|
|
4422
4981
|
|
|
4423
4982
|
const store0 = this.docStore
|
|
4424
4983
|
if (store0) {
|
|
@@ -4493,6 +5052,20 @@ class MemoryEngine {
|
|
|
4493
5052
|
const archText = oldSegs.map(seqOf).filter(Boolean).join('\n')
|
|
4494
5053
|
if (archText) await this.appendText(archiveFile, '\n' + archText)
|
|
4495
5054
|
}
|
|
5055
|
+
// ★R4(2026-09-18)修缺陷:**回写量护栏** —— 与锚点路径同一纪律(见 compactAnchoredLayer 的
|
|
5056
|
+
// `if (folded && folded.length > Math.max(0, removed - deficit)) folded = ''`)。
|
|
5057
|
+
//
|
|
5058
|
+
// 旧实现缺这道护栏 ⇒ 上面那条"AI 不可用"分支会把**刚归档掉的老段落原样写回主文件**
|
|
5059
|
+
// (`folded = remainText.slice(0, keepBudget)`,而 keepBudget 够大时它等于全部老段落)⇒
|
|
5060
|
+
// **归档确实发生了、空间却没腾出来**(实测 `[compacted] note: 1729 -> 1730 chars`,
|
|
5061
|
+
// 不降反升)。后果:ensureBudget 三轮整理后仍超限 ⇒ 返回 `still-over-capacity` ⇒
|
|
5062
|
+
// 用户看到"写不进去",而日志里明明写着整理成功 —— 正是最难排查的那种静默失效。
|
|
5063
|
+
// 判据:回写量不得超过"本次腾出的空间 − 还需的缺口"。
|
|
5064
|
+
// 注:本函数签名没有 `deficit`(只收 keepBudget),但 keepBudget 恒等于 `cur.length - deficit - 1`
|
|
5065
|
+
// 的推导目标 ⇒ 用 `cur.length - keepBudget` 等价还原缺口,无需改签名(不动调用契约)。
|
|
5066
|
+
const reclaimed = oldText.length
|
|
5067
|
+
const deficitHere = Math.max(0, cur.length - keepBudget)
|
|
5068
|
+
if (folded && folded.length > Math.max(0, reclaimed - deficitHere)) folded = ''
|
|
4496
5069
|
const body = [folded, keptText].filter(Boolean).join('\n\n')
|
|
4497
5070
|
await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
|
|
4498
5071
|
if (layer === 'user') this.state.userText = body
|
|
@@ -4794,6 +5367,10 @@ class MemoryEngine {
|
|
|
4794
5367
|
excludedSources: res.excluded || { count: 0, total: 0, patterns: [] },
|
|
4795
5368
|
at: Date.now(),
|
|
4796
5369
|
}
|
|
5370
|
+
// ★R4(2026-09-18):把本轮配额切片喂给探针(观测面,不参与任何判定/行为)。
|
|
5371
|
+
// 采集点紧贴 tier0Meta 赋值 ⇒ 采到的就是本轮**真实生效**的配额与丢弃数。
|
|
5372
|
+
// 与降级台账**判据并列不混**(见 _quotaProbe 的创建注释)。
|
|
5373
|
+
try { if (this._quotaProbe) this._quotaProbe.observe(s.tier0Meta) } catch (_) {}
|
|
4797
5374
|
return s.tier0LayerText
|
|
4798
5375
|
} catch (e) {
|
|
4799
5376
|
// C5 fail-open:目录层出任何异常都不拖垮记忆快照(I7:但必须留下可见痕迹,不静默)
|
|
@@ -5091,13 +5668,15 @@ class MemoryEngine {
|
|
|
5091
5668
|
// 防御式调用:`renderMemoryDynamic` 是源码抽取式测试的被测对象(`new Function` 里 `this` 是 fake),
|
|
5092
5669
|
// 宿主方法在沙箱里可能不存在 ⇒ 用 typeof 兜底,避免 ReferenceError 被外层 catch 吞成**整块空串**。
|
|
5093
5670
|
const planAsk = (typeof this.renderPlanUpdateRequest === 'function') ? this.renderPlanUpdateRequest() : ''
|
|
5094
|
-
|
|
5671
|
+
// ★2026-09-20 移植(issue #88 / PR #95):pushPart 只收 4 参(第 4 参才是 priority)——
|
|
5672
|
+
// 旧调用传了 5 个实参,'must' 被 JS 静默丢弃 ⇒ 该段落成 normal,预算超限时可被整段丢弃。
|
|
5673
|
+
if (planAsk) pushPart('otherDynamic', 'plan-update-request', planAsk, 'must')
|
|
5095
5674
|
// 新bug修复①(2026-09-08):工作区未绑定/项目记忆为空时,注入全局最近账本的绝对路径指针(模型可直接读取续命)
|
|
5096
5675
|
if (cfg.handoffEnabled !== false && !s.planText && !s.latestHandoffText && s.globalLedgerPath) {
|
|
5097
5676
|
pushPart('otherDynamic', 'handoff-pointer', '\n[交接续命] 当前会话未绑定工作区(项目记忆不可用)。全局最近交接账本: ' + s.globalLedgerPath + ' —— 需要续接上次任务时,直接读取该文件恢复上下文;工作区绑定后白板/账本即恢复正常注入。')
|
|
5098
5677
|
}
|
|
5099
5678
|
// M-CM4/M-CM5 水位建议:越阈注入交接+派子代理建议(advisory;10 分钟新鲜度;无人值守静默)
|
|
5100
|
-
if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) ||
|
|
5679
|
+
if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || DEFAULT_WATER_LEVEL_THRESHOLD) && Date.now() - (s.waterLevelAt || 0) < 600000) {
|
|
5101
5680
|
pushPart('otherDynamic', 'water-advisory', '\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
|
|
5102
5681
|
}
|
|
5103
5682
|
// 2026-09-14 T0-3 修复(P0):原实现把"目录可扣额度"硬封顶为总预算的 35%,
|
|
@@ -5164,7 +5743,10 @@ class MemoryEngine {
|
|
|
5164
5743
|
pushPart('otherDynamic', 'welcome-title', '\n' + L('snapshotWelcomeTitle'))
|
|
5165
5744
|
pushPart('otherDynamic', 'welcome-body', L('snapshotWelcomeBody'))
|
|
5166
5745
|
}
|
|
5167
|
-
|
|
5746
|
+
// ★T5(2026-09-20 用户拍板):加 'must' 保护 —— 本段是**收尾自检位**(recency 最高),
|
|
5747
|
+
// 额度紧张时不许被「按优先级丢弃」静默砍掉(那是 M-CM4 期的段丢弃规则)。
|
|
5748
|
+
// 对照:紧随其后的 frame-tail 一直是 'must',而本段此前裸奔 ⇒ 保护等级反而低于它的结束标记。
|
|
5749
|
+
pushPart('otherDynamic', 'frame-inscription', '\n' + L('snapshotInscription', { date: this.memToday() }), 'must') // 动态快照追加在历史尾部,变化只 miss 快照本身;秒级时间戳也不再击穿 system prompt 前缀
|
|
5168
5750
|
pushPart('otherDynamic', 'frame-tail', L('snapshotTail') || '</memory_system>', 'must')
|
|
5169
5751
|
// 0.1.39 兜底:整个动态快照出插件前统一中和模板变量(覆盖反思摘要/日历/外部/层覆盖文案等所有支路)
|
|
5170
5752
|
// —— T0-3 后改为**逐段**中和:双花括号→全角双花括号 是等长替换,逐段与整篇结果逐字节相同,
|
|
@@ -5230,7 +5812,24 @@ class MemoryEngine {
|
|
|
5230
5812
|
lines.push('- progress 与 memory 一起写:写日志的同时,把有跨会话长期价值的内容一并写入记忆——跨项目规则 → memory_user,仅本项目 → memory_note;两者在同一轮完成,互不冲突、不遗漏。')
|
|
5231
5813
|
lines.push('- **交接与白板(长任务续命)**:阶段产出或方向变化时,调用 memory_note(kind=handoff) 写四段式交接账本——任务状态/目标/已试方案与失败原因/进度与下一步,给下一个上下文窗口续命;对项目全貌的理解发生实质变化时,用 memory_note(kind=plan) 重写白板 PLAN.md(人能读的项目规划图,旧版自动归档,用户在面板实时可见)。账本质量纪律:每段 ≤5 行;失败项写成「方案→失败原因」并保留关键报错词;下一步必须是可直接执行的第一步(带文件路径或命令);不写临时信息。两者会注入到你的动态快照首位,是跨窗口交接的凭据。')
|
|
5232
5814
|
lines.push('- 只记录有跨会话长期价值的;不记临时信息(搜索结果、临时路径、工具报错)。')
|
|
5233
|
-
|
|
5815
|
+
// ★T6(2026-09-20 用户拍板):**看板分列机制** —— 模型此前完全不知道有 tag 这回事。
|
|
5816
|
+
// 根因:wb-sidecar.js:594-597 的 WB_KANBAN_LANES_V1 靠 matchTags 匹配
|
|
5817
|
+
// `type:goal` / `type:state` / `type:dead-end` / `type:progress`;不写 tag 就只能靠
|
|
5818
|
+
// matchTitle 正则(标题含"目标"/"任务状态"/"失败"/"进度")兜底 ⇒ 模型自拟标题时看板常空列。
|
|
5819
|
+
lines.push('- **看板分列(白板与账本的可视化归类)**:面板看板按 **5 条泳道**分列 —— `目标` / `进行中` / `失败与弯路` / `进度与下一步` / `版本归档`。'
|
|
5820
|
+
+ '**要让内容落进对应泳道,须在小节标题行或正文里写 tag**:`type:goal`(目标)、`type:state`(进行中)、`type:dead-end`(失败与弯路)、`type:progress`(进度与下一步)。'
|
|
5821
|
+
+ '不写 tag 时系统会退回**按标题文字猜**(含"目标"/"任务状态"/"失败"/"进度"等词),猜不中就等于看板上是空的。'
|
|
5822
|
+
+ '注意:这是**白板/账本**的分列规则,与 `memory_note` 的 `kind=plan/handoff/note` 是两回事 —— 后者决定"写到哪个文件",前者决定"面板上排到哪列"。')
|
|
5823
|
+
// ★ T4(2026-09-19 用户拍板):**procedure memory 的模型直写通路**。
|
|
5824
|
+
// 为什么必须显式提示:此前 procedural 线**只有机械生成**(crossFeed 把 episode 的
|
|
5825
|
+
// actions=['user','user','user'] 切成候选),产出 14 条里 13 条是空壳;清洗器无法
|
|
5826
|
+
// 把机械切片变成有价值的流程(输入本就不含流程信息)。模型不主动写,这条路就是空的。
|
|
5827
|
+
// 用户原话:「如果有值得介入 procedure memory 的东西,那就接入写进审批列表。」
|
|
5828
|
+
lines.push('- **★技能库(procedure memory)直写**:你刚跑通一个**多步骤、可重复、下次遇到类似场景能照做**的流程(或踩坑后总结出正确做法)时,调 `memory_procedure` 把它写进去——**这是技能库的唯一模型入口,不写就没有**。'
|
|
5829
|
+
+ '默认 `action=write` 进审批列表(保守,推荐先这样);**若你确信它稳定可复用**,用 `action=activate` 一步激活(可被自动召回、并自动导出 SKILL.md)。'
|
|
5830
|
+
+ '**必填** title + steps(steps 一行一步);**强烈建议填 successCriteria**(怎么算跑通)——**没有 successCriteria 的条目结构上永远无法晋升**。'
|
|
5831
|
+
+ '**什么值得写**:可复用的操作序列、正确的排查顺序、被验证过的配置步骤。**什么不值得**:一次性的问答、纯信息查询、还没跑通的尝试。判据是「下次我遇到类似场景,会不会想照做」。')
|
|
5832
|
+
lines.push('- 记忆容量(设置项 noteCapacityChars/userCapacityChars,默认各 **24000** 字符;2026-09-18 由 12000 上调,老配置仍是 12000 的会自动抬到 24000 一次):项目笔记与用户级记忆各有容量上限;超出时框架自动整理——先把较早内容交给 AI 折叠成要点、失败则退回整条归档(原文进 archive,信息不丢),整理后仍超才拒绝写入,所以**新记忆正常不会被堵在外面**。整理同一层 10 分钟内只做一次;每日日志无容量限制。注意这与「注入预算」不同:注入预算管每轮往上下文塞多少摘要,容量上限管文件本体大小。')
|
|
5234
5833
|
lines.push('- **记忆操作必须在正文可见(摘要链)**:调用 memory_log/note/user/reflect 更新记忆后,必须把结果写进本轮回复的正文文本(用户直接看到的那段文字,不是工具调用区),并在**回复末尾**用加粗或换行使其醒目(如"**已更新今日日志**\n新增:修复了XXX");调用 memory_recall/memory_external 检索时,在正文开头写明"我查了记忆,发现..."。工具返回值只是辅助,正文转述是强制要求。')
|
|
5235
5834
|
lines.push('- 用户明确要求长期记住:跨项目规则 → memory_user;仅本项目 → memory_note。')
|
|
5236
5835
|
lines.push('- 定期调用 memory_maintain 做 30 天蒸馏:AI 提炼旧日志要点进项目笔记,原文保底归档;不存密钥,除非用户明确要求。')
|
|
@@ -5374,6 +5973,13 @@ class MemoryEngine {
|
|
|
5374
5973
|
|
|
5375
5974
|
async appendText(p, text) {
|
|
5376
5975
|
if (process.env.DSH_F1_DEBUG) console.error('[f1-diag] appendText -> ' + p + ' len=' + String(text || '').length)
|
|
5976
|
+
// ★ T3-1(2026-09-19):**新增守卫,一行收口**。
|
|
5977
|
+
// 只做卫生检查(拦乱码/复读/raw-json/base64/重复行),**不做任何体量截断** ——
|
|
5978
|
+
// 故 archive 全文保底(:7716 走 writeFull)与本处的大文本内联(:7733)不受影响。
|
|
5979
|
+
// 拒绝时**抛出**:写入原语的既有契约就是"失败即抛"(见下方 memoryWriteError),
|
|
5980
|
+
// 调用方已有 try/catch 兜底(如 maintain 的 per-log catch、hubFlushTick 的 eFlush 分支)。
|
|
5981
|
+
const _hg = hygieneGateForPrimitive(text)
|
|
5982
|
+
if (!_hg.ok) throw memoryWriteError('hygiene', _hg)
|
|
5377
5983
|
// M3b-3 分流:anchor 开启 → 记忆文档走原子写入事务(稳定 marker/ID);关闭 → 原逻辑逐字节不变。
|
|
5378
5984
|
const store = this.docStore
|
|
5379
5985
|
if (store) {
|
|
@@ -5423,6 +6029,87 @@ class MemoryEngine {
|
|
|
5423
6029
|
await writeFile(p, text, 'utf8')
|
|
5424
6030
|
}
|
|
5425
6031
|
|
|
6032
|
+
/**
|
|
6033
|
+
* ★G3(2026-09-19)结论层状态写入 —— 给指定条目落 `status`,**只在显式传参时被调用**。
|
|
6034
|
+
*
|
|
6035
|
+
* 纪律(逐条对应设计稿,违反即回滚):
|
|
6036
|
+
* ① **不新建状态源**(S10.4):状态写在**条目自身**正文末尾,走既有写盘通道。
|
|
6037
|
+
* ② **不绕过写入纪律**:读原文 → 纯函数生成新文 → `writeFull`(备份/校验/无 BOM 全由既有事务负责)。
|
|
6038
|
+
* ③ **只动目标条目**:`note-status-apply-pre` 保证其余字节不变(含 CRLF),避免无关条目 digest 漂移。
|
|
6039
|
+
* ④ **留痕**:每次状态变更都写一行到既有日志通道旁(不新建状态源),否则又是"静默改写"。
|
|
6040
|
+
* ⑤ **fail-soft**:任何一步失败只返回说明并记降级台账,**绝不抛出**(调用方已成功写入笔记)。
|
|
6041
|
+
*
|
|
6042
|
+
* @param {string} notesPath 项目笔记路径
|
|
6043
|
+
* @param {{supersedes?:string[], retract?:string[], restore?:string[], reason?:string}} plan
|
|
6044
|
+
* @param {string} [fallbackText] 调用方刚写入的全文(读取失败时兜底)
|
|
6045
|
+
* @returns {Promise<string>} 追加到工具返回值末尾的说明(无动作时为空串)
|
|
6046
|
+
*/
|
|
6047
|
+
async applyNoteStatusPre(notesPath, plan, fallbackText) {
|
|
6048
|
+
try {
|
|
6049
|
+
const { applyStatusToRecordPre, readRecordStatusPre } = await import('./note-status-apply.js')
|
|
6050
|
+
let text = ''
|
|
6051
|
+
try { text = await this.readTextSafe(notesPath) } catch (_) { text = '' }
|
|
6052
|
+
if (!text) text = String(fallbackText || '')
|
|
6053
|
+
if (!text) return '\n(状态写入跳过:笔记为空)'
|
|
6054
|
+
|
|
6055
|
+
const done = []
|
|
6056
|
+
const skipped = []
|
|
6057
|
+
// 指向:用本次**新写入内容**里最后一个锚点 id(即"新结论")作为 supersededBy。
|
|
6058
|
+
const newIds = (String(fallbackText || '').match(/mem_[0-9a-f]{32}/g) || [])
|
|
6059
|
+
const byId = newIds.length ? newIds[newIds.length - 1] : ''
|
|
6060
|
+
|
|
6061
|
+
for (const id of (plan.supersedes || [])) {
|
|
6062
|
+
const cur = readRecordStatusPre(text, id)
|
|
6063
|
+
if (!cur) { skipped.push(id.slice(4, 12) + '(未找到)'); continue }
|
|
6064
|
+
if (cur.status === 'superseded') { skipped.push(id.slice(4, 12) + '(已作废)'); continue }
|
|
6065
|
+
const next = applyStatusToRecordPre(text, id, 'superseded', byId ? { supersededBy: byId } : {})
|
|
6066
|
+
if (!next) { skipped.push(id.slice(4, 12) + '(不可应用)'); continue }
|
|
6067
|
+
text = next
|
|
6068
|
+
done.push(id.slice(4, 12) + '→superseded')
|
|
6069
|
+
}
|
|
6070
|
+
// ★T6(2026-09-20 用户拍板):**retracted 通道**。
|
|
6071
|
+
// 为什么必须有:三态枚举(note-status-pre 的 NOTE_STATUSES_V1)含 retracted,renderStatusLinePre
|
|
6072
|
+
// 也支持渲染它,L0 侧还专门有 L0_RETRACTED_MARK_V1='⚠已撤回' 的呈现后缀 —— 但此前
|
|
6073
|
+
// memory_note 只有 `supersedes` 一个通道且硬编码映射到 superseded ⇒ **retracted 模型写不了**。
|
|
6074
|
+
// 用户 2026-09-18 裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 教训通路必须可写。
|
|
6075
|
+
// 语义分工(写进 tool description,模型据此选):
|
|
6076
|
+
// supersedes = 被**更新的结论取代**(有后继,可追 mem_id)
|
|
6077
|
+
// retract = **做错了、撤回**(无后继,本身就是教训;可带 reason 说明错在哪)
|
|
6078
|
+
for (const id of (plan.retract || [])) {
|
|
6079
|
+
const cur = readRecordStatusPre(text, id)
|
|
6080
|
+
if (!cur) { skipped.push(id.slice(4, 12) + '(未找到)'); continue }
|
|
6081
|
+
if (cur.status === 'retracted') { skipped.push(id.slice(4, 12) + '(已撤回)'); continue }
|
|
6082
|
+
const next = applyStatusToRecordPre(text, id, 'retracted', plan.reason ? { reason: String(plan.reason) } : {})
|
|
6083
|
+
if (!next) { skipped.push(id.slice(4, 12) + '(不可应用)'); continue }
|
|
6084
|
+
text = next
|
|
6085
|
+
done.push(id.slice(4, 12) + '→retracted')
|
|
6086
|
+
}
|
|
6087
|
+
for (const id of (plan.restore || [])) {
|
|
6088
|
+
const cur = readRecordStatusPre(text, id)
|
|
6089
|
+
if (!cur) { skipped.push(id.slice(4, 12) + '(未找到)'); continue }
|
|
6090
|
+
if (cur.status === 'current') { skipped.push(id.slice(4, 12) + '(已是 current)'); continue }
|
|
6091
|
+
const next = applyStatusToRecordPre(text, id, 'current')
|
|
6092
|
+
if (!next) { skipped.push(id.slice(4, 12) + '(不可应用)'); continue }
|
|
6093
|
+
text = next
|
|
6094
|
+
done.push(id.slice(4, 12) + '→current(撤销)')
|
|
6095
|
+
}
|
|
6096
|
+
|
|
6097
|
+
if (!done.length) return skipped.length ? '\n(状态未变更:' + skipped.join(', ') + ')' : ''
|
|
6098
|
+
|
|
6099
|
+
await this.writeFull(notesPath, text)
|
|
6100
|
+
this.state.notesText = text; this.state.loadedAt = Date.now()
|
|
6101
|
+
// ④ 留痕:与笔记同目录(.dsh-memory/),append-only
|
|
6102
|
+
try {
|
|
6103
|
+
await this.appendText(path.join(path.dirname(notesPath), 'STATUS-CHANGES.log'),
|
|
6104
|
+
'[' + new Date().toISOString() + '] ' + done.join(', ') + (skipped.length ? ' | skipped: ' + skipped.join(', ') : '') + '\n')
|
|
6105
|
+
} catch (_) { /* 留痕失败不影响主流程 */ }
|
|
6106
|
+
return '\n状态已更新:' + done.join(', ') + (skipped.length ? '\n(跳过:' + skipped.join(', ') + ')' : '')
|
|
6107
|
+
} catch (e) {
|
|
6108
|
+
try { if (this._degradePre && typeof this._degradePre.record === 'function') this._degradePre.record('note-status', String((e && e.message) || e)) } catch (_) {}
|
|
6109
|
+
return '\n(状态写入失败,笔记已正常保存:' + String((e && e.message) || e) + ')'
|
|
6110
|
+
}
|
|
6111
|
+
}
|
|
6112
|
+
|
|
5426
6113
|
// ---------- 检索 ----------
|
|
5427
6114
|
async recall(query, limit = 8, agent, scope = 'all', opts = null) {
|
|
5428
6115
|
// P4(2026-09-09):按需展开入口 —— opts.expand 指定 mem_<32hex> 时跳过检索,按锚点字节区间直接返回该条原文。
|
|
@@ -5453,6 +6140,13 @@ class MemoryEngine {
|
|
|
5453
6140
|
}
|
|
5454
6141
|
const out = []
|
|
5455
6142
|
const hits = []
|
|
6143
|
+
// ★G3 读侧(2026-09-19):磁盘状态行解析器。**单一解析点** —— 写侧与读侧共用
|
|
6144
|
+
// `note-status-apply-pre` / `note-status-pre` 的同一套语法,避免读写口径漂移。
|
|
6145
|
+
// ★声明位置必须在**方法体顶层**:两个消费点分属互斥分支
|
|
6146
|
+
// (词法臂在 `if (l0Mode)`、语义臂在 `if (... && !l0Mode)`),
|
|
6147
|
+
// 声明若放进任一分支,另一分支引用即 ReferenceError 并被下游 fail-soft 吞掉
|
|
6148
|
+
// ⇒ **静默降级、语义臂整体失效**(本仓「标识符作用域」类缺陷,写前必核)。
|
|
6149
|
+
const { readRecordStatusPre: statusOfNotePre } = await import('./note-status-apply.js')
|
|
5456
6150
|
const scanFile = async (label, filePath, maxMatches = 3, target = hits) => {
|
|
5457
6151
|
const text = await this.readTextSafe(filePath)
|
|
5458
6152
|
if (!text) return
|
|
@@ -5497,20 +6191,48 @@ class MemoryEngine {
|
|
|
5497
6191
|
// 用户级/项目笔记在日志很多时**永远进不了候选**,表现为"够不到 ~5 天前的记录"。)
|
|
5498
6192
|
if (l0Mode) {
|
|
5499
6193
|
const { buildL0IndexPre, isCurrentPre } = await import('./l0-extract.js')
|
|
6194
|
+
// ★R4-B(2026-09-18):分层呈现的分组函数 —— **单独一行 import**,刻意不改上面那行的
|
|
6195
|
+
// 字面形态:`smoke-test-three-layer-pre` 的「三层契约 I5 检索侧接线」断言以**字面量**
|
|
6196
|
+
// 锁定它(`const { buildL0IndexPre, isCurrentPre } = await import(...)`),
|
|
6197
|
+
// 合并 import 会让该契约守卫假红。合并是"更漂亮",但契约守卫的价值高于排版。
|
|
6198
|
+
const { groupL0ByLayerPre: groupL0 } = await import('./l0-extract.js')
|
|
6199
|
+
// ★R4-A 落地(2026-09-19):检索侧改用**准入谓词** isRetrievablePre(三态一律放行),
|
|
6200
|
+
// 并在输出处附 supersededMarkPre 的标记后缀。「返回但标记」是用户两次修正后的定稿:
|
|
6201
|
+
// retracted 不是垃圾,**它是教训**(「不记住这个教训你还会再踩」)。
|
|
6202
|
+
// 注入侧仍用 isCurrentPre(常驻 800 token 不装过时条目)—— 两处判据不同是有意为之。
|
|
6203
|
+
const { isRetrievablePre: retrievableL0, supersededMarkPre: markL0 } = await import('./l0-extract.js')
|
|
5500
6204
|
const l0Corpus = []
|
|
5501
6205
|
// C2(2026-09-14,三层契约):语料条目带 layer/status —— 层名由**来源路径**判定(classifyLayerPre);
|
|
5502
|
-
//
|
|
6206
|
+
// R4-A(2026-09-19):非 current 的三态条目**不再剔除**,改为标记后一并返回(见上)。
|
|
5503
6207
|
const pushL0 = (label, text, srcPath) => {
|
|
5504
6208
|
if (!text) return
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
6209
|
+
// ★G3 读侧接线(2026-09-19):把**磁盘上的状态行**解析出来经 statusOf 注入。
|
|
6210
|
+
// 没有这一步,写进 MEMORY.md 的 `<!-- dsh-status: ... -->` 永远读不回来
|
|
6211
|
+
// ⇒ G3 只会"写得很热闹、检索侧毫无变化"(正是本仓最忌的"接线正确但功能不存在")。
|
|
6212
|
+
// 解析失败 ⇒ statusOf 返回 current(fail-soft,与索引层「缺失即默认」口径一致)。
|
|
6213
|
+
const opts = srcPath ? { layer: srcPath, statusOf: (id) => statusOfNotePre(text, id) } : undefined
|
|
6214
|
+
for (const it of buildL0IndexPre(text, opts)) {
|
|
6215
|
+
if (!retrievableL0(it)) continue
|
|
6216
|
+
l0Corpus.push({ id: it.id, l0: it.l0, label, layer: it.layer || 'log', status: it.status || 'current', mark: markL0(it) })
|
|
5508
6217
|
}
|
|
5509
6218
|
}
|
|
5510
6219
|
for (const log of logs) { const f = path.join(p.projectDir, log.name); pushL0(log.name, await this.readTextSafe(f), f) }
|
|
5511
6220
|
for (const rf of reflections) { const f = path.join(p.reflectDir, rf.name); pushL0('reflections/' + rf.name, await this.readTextSafe(f), f) }
|
|
5512
6221
|
pushL0(p.projectDir + '/MEMORY.md', await this.readTextSafe(p.notesPath), p.notesPath)
|
|
5513
6222
|
pushL0('~' + p.userFile.slice(homedir().length), await this.readTextSafe(p.userFile), p.userFile)
|
|
6223
|
+
// ★L4(2026-09-17)·白板进检索 —— 缺口 2 的**读取侧**接续。
|
|
6224
|
+
// 此前白板只进"注入"(:4706 一线), **不进检索** ⇒ 模型问"上次那个失败方案是啥"时,
|
|
6225
|
+
// 检索臂扫不到 PLAN/账本, 只能靠注入的目录层碰运气。这里补上检索侧的两个来源。
|
|
6226
|
+
// 闸门口径: 白板是**产物层**, 归 `handoffEnabled` 管(关时既不写也不读, 见 buildContinueCarry 头注释);
|
|
6227
|
+
// **不是** boardMode 渲染门 —— 故此处只判 handoffEnabled, 不判 boardMode(与 L5 判据同源)。
|
|
6228
|
+
const withHandoffL4 = (this.config || {}).handoffEnabled !== false
|
|
6229
|
+
if (withHandoffL4) {
|
|
6230
|
+
try {
|
|
6231
|
+
pushL0('handoff/PLAN.md', await this.readTextSafe(p.planPath), p.planPath)
|
|
6232
|
+
const ledgerNameL4 = await this.latestLedgerNamePre(p.handoffDir)
|
|
6233
|
+
if (ledgerNameL4) pushL0('handoff/' + ledgerNameL4, await this.readTextSafe(path.join(p.handoffDir, ledgerNameL4)), path.join(p.handoffDir, ledgerNameL4))
|
|
6234
|
+
} catch (eL4) { /* fail-soft: 白板取不到就跳过这两个来源, 绝不阻塞检索 */ }
|
|
6235
|
+
}
|
|
5514
6236
|
// #45:不再在此处按 256 截断 —— 截断会使追加顺序靠后的来源(笔记/用户级)永久失去候选资格。
|
|
5515
6237
|
// 输出量由下游 rank + limit 控制,不牺牲召回覆盖。
|
|
5516
6238
|
for (const c of l0Corpus) {
|
|
@@ -5532,7 +6254,11 @@ class MemoryEngine {
|
|
|
5532
6254
|
if (typeof this._semanticRankBest === 'function') rank = await this._semanticRankBest(rankSnap, query)
|
|
5533
6255
|
else if (typeof this._jsSemanticRank === 'function') rank = await this._jsSemanticRank(rankSnap, query)
|
|
5534
6256
|
if (rank && rank.scores && rank.scores.size) semScores = rank.scores
|
|
5535
|
-
} catch (eBest) {
|
|
6257
|
+
} catch (eBest) {
|
|
6258
|
+
// R3:预期外失败 —— 引擎/worker 抛错导致回退词法,用户应当能知道(不只是 diag 一行)。
|
|
6259
|
+
try { diag('recall 语义臂择优降级: ' + String((eBest && eBest.message) || eBest).slice(0, 120)) } catch (_) {}
|
|
6260
|
+
try { if (this._degradeSink) this._degradeSink.record('semantic-arm', String((eBest && eBest.message) || eBest).slice(0, 160)) } catch (_) {}
|
|
6261
|
+
}
|
|
5536
6262
|
if (semScores && semScores.size) {
|
|
5537
6263
|
for (const c of l0Corpus) {
|
|
5538
6264
|
const sc = semScores.get(c.id)
|
|
@@ -5543,16 +6269,35 @@ class MemoryEngine {
|
|
|
5543
6269
|
// 复用 :6876 证据读取范式);中性 0.5 → 因子 0.75 全体一致缩放=排序不变;correction 重则因子降至 0.5。
|
|
5544
6270
|
// 任何失败 → impMap 空 → 全体中性,绝不阻塞检索。importance 仅为加权因子之一,lex 臂不受影响。
|
|
5545
6271
|
const impMap = new Map()
|
|
6272
|
+
// ★R2-E1(2026-09-18):读侧守卫 —— 与写侧「懒建」契约对齐。
|
|
6273
|
+
// 写侧 `evidence-store.js:126` 的 mkdirSync 只在**首次成功 append** 时建目录,
|
|
6274
|
+
// 且 persistEvidence 的两个调用点都带内容前置条件(context-host.js:653/:699)
|
|
6275
|
+
// ⇒ **「目录不存在」在写侧是合法状态**(新装 / 用法未触发写入的用户永远没有它)。
|
|
6276
|
+
// 原实现读侧无条件 readdirSync ⇒ ENOENT 被下方 catch 吞掉 ⇒ impMap 恒空
|
|
6277
|
+
// ⇒ importance 全体中性 ⇒ dense 臂因子恒 0.75 ⇒ **该用户每次 recall 都静默失去 importance 加权**。
|
|
6278
|
+
// 定性:契约缺口(读写对「目录可能不存在」无共识),非容错不足。
|
|
6279
|
+
// 处置:此处**静默跳过**(预期内分支,不算降级、不写 diag,避免刷屏);
|
|
6280
|
+
// 「该臂整体未生效」的可见性交给 R3 留痕层。
|
|
5546
6281
|
try {
|
|
5547
|
-
const { scanEvidenceEventsPre, aggregateEvidenceEventsPre } = await import('./evidence-agg.js')
|
|
5548
|
-
const { computeImportancePre } = await import('./memory-importance.js')
|
|
5549
6282
|
const evDir = path.join(dshHome(), 'memory', 'evidence', 'events')
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
}
|
|
5554
|
-
|
|
5555
|
-
|
|
6283
|
+
if (!existsSync(evDir)) {
|
|
6284
|
+
// 目录不存在 = 从未产生过证据事件 = 合法状态 ⇒ 保持 impMap 空(全体中性),直接跳过。
|
|
6285
|
+
// 不抛异常、不写 diag:这不是故障,是「这条臂暂无输入」。
|
|
6286
|
+
} else {
|
|
6287
|
+
const { scanEvidenceEventsPre, aggregateEvidenceEventsPre } = await import('./evidence-agg.js')
|
|
6288
|
+
const { computeImportancePre } = await import('./memory-importance.js')
|
|
6289
|
+
const agg = aggregateEvidenceEventsPre(scanEvidenceEventsPre({
|
|
6290
|
+
listFiles: () => readdirSync(evDir).filter((x) => x.endsWith('.jsonl')),
|
|
6291
|
+
readFile: (name) => readFileSync(path.join(evDir, name), 'utf8'),
|
|
6292
|
+
}, {}))
|
|
6293
|
+
for (const [mid, a] of agg) impMap.set(mid, computeImportancePre(a).importance)
|
|
6294
|
+
}
|
|
6295
|
+
} catch (eImp) {
|
|
6296
|
+
// R3:预期外失败才记。目录不存在已被上面的 existsSync 守卫拦下(预期内分支,静默);
|
|
6297
|
+
// 走到这里的都是真异常(聚合/读文件/import 失败)⇒ 应当留痕。
|
|
6298
|
+
try { diag('evidence-agg 降级为中性(impMap 空): ' + String((eImp && eImp.message) || eImp).slice(0, 140)) } catch (_) {}
|
|
6299
|
+
try { if (this._degradeSink) this._degradeSink.record('evidence-arm', String((eImp && eImp.message) || eImp).slice(0, 160)) } catch (_) {}
|
|
6300
|
+
}
|
|
5556
6301
|
const l0Hits = l0Corpus.filter((c) => c.lex > 0 || (typeof c.sem === 'number' && c.sem >= 0.5))
|
|
5557
6302
|
// 时间臂(2026-09-09):查询含中文时间表达 → 解析 [startMs,endMs),候选 label 中的日志日期
|
|
5558
6303
|
// (YYYY-MM-DD)命中 → temp=1 软提升;非日期来源(MEMORY.md/~userfile)不给 temp(中性)。
|
|
@@ -5582,7 +6327,7 @@ class MemoryEngine {
|
|
|
5582
6327
|
// P3(2026-09-16) R1 双显示:RRF 融合输出的 finalRank(输出序=注入/展示序)保留到候选对象上,
|
|
5583
6328
|
// 与 denseScore(绝对,决策用)并存 —— 相似度在前、融合序号在后,不拿融合分冒充相似度。
|
|
5584
6329
|
// finalRank 口径 = 本次融合的最终输出序(1 起);决策仍走 sem>=0.5 绝对阈值(R2 维持)。
|
|
5585
|
-
const fusion = rankFusionRRFPre(l0Hits.map((c) => ({ memoryId: c.id, dense: typeof c.sem === 'number' ? c.sem * (0.5 + 0.5 * (impMap.get(c.id) != null ? impMap.get(c.id) : 0.5)) : null, lex: c.lex, temp: tempFieldOf(c) })))
|
|
6330
|
+
const fusion = rankFusionRRFPre(l0Hits.map((c) => ({ memoryId: c.id, dense: typeof c.sem === 'number' ? c.sem * (0.5 + 0.5 * (impMap.get(c.id) != null ? impMap.get(c.id) : 0.5)) : null, lex: c.lex, temp: tempFieldOf(c), layer: c.layer })))
|
|
5586
6331
|
fusion.forEach((f, i) => {
|
|
5587
6332
|
const c = byId.get(f.memoryId)
|
|
5588
6333
|
if (!c) return
|
|
@@ -5601,12 +6346,21 @@ class MemoryEngine {
|
|
|
5601
6346
|
}
|
|
5602
6347
|
if (l0Top.length) {
|
|
5603
6348
|
out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
6349
|
+
// ★R4-B(2026-09-18)分层**呈现**:把已排好序的命中**按层分组**后展示。
|
|
6350
|
+
// ★排序零改动:分组只重排"行",不改 `l0Top` 的融合/词法序;组内保持原相对顺序,
|
|
6351
|
+
// 且每行仍带 `#finalRank` ⇒ 全局序完全可由读者还原。**只有多于一层时才打标题**
|
|
6352
|
+
// ⇒ 单层命中(如纯日志)输出与旧版**逐字节相同**(回滚安全)。
|
|
6353
|
+
const groups0 = groupL0(l0Top, (c) => c.layer)
|
|
6354
|
+
const multi = groups0.length > 1
|
|
6355
|
+
for (const g of groups0) {
|
|
6356
|
+
if (multi) out.push('【' + g.label + '】')
|
|
6357
|
+
for (const c of g.items) {
|
|
6358
|
+
// R1 双显示(2026-09-16):绝对分(决策用)在前;融合序号 #N(排序用,即 finalRank 口径)在后。
|
|
6359
|
+
// legacy 路径/RRF 降级时无 finalRank,显示保持旧格式不变(回滚=opts.fusion:'legacy')。
|
|
6360
|
+
const sc = typeof c.sem === 'number' ? c.sem.toFixed(2) : String(c.lex)
|
|
6361
|
+
const fr = Number.isInteger(c.finalRank) ? ' #' + c.finalRank : ''
|
|
6362
|
+
out.push('· [' + c.id + '] ×' + sc + fr + ' ' + (c.reason || '语义') + ' ' + c.label + ' — ' + c.l0 + (c.mark || ''))
|
|
6363
|
+
}
|
|
5610
6364
|
}
|
|
5611
6365
|
}
|
|
5612
6366
|
} else {
|
|
@@ -5671,20 +6425,28 @@ class MemoryEngine {
|
|
|
5671
6425
|
if (scope === 'all' && !l0Mode && typeof this._jsSemanticRank === 'function') {
|
|
5672
6426
|
try {
|
|
5673
6427
|
const { buildL0IndexPre } = await import('./l0-extract.js')
|
|
5674
|
-
|
|
6428
|
+
// ★R4-A 落地(2026-09-19):语义臂与词法臂**同源同判** —— 检索侧三态一律放行 + 标记。
|
|
6429
|
+
const { isRetrievablePre: retrievableSem, supersededMarkPre: markSem } = await import('./l0-extract.js')
|
|
6430
|
+
// R4-B:分层呈现用的分组函数(单独 import,**不动上面两行的字面形态** —— 既有套件
|
|
6431
|
+
// smoke-test-p2 以源码字面量锁定它们,改形态会造成假红)。
|
|
6432
|
+
const { groupL0ByLayerPre: groupSem } = await import('./l0-extract.js')
|
|
5675
6433
|
const { createHash } = await import('node:crypto')
|
|
5676
|
-
// C2(三层契约 I5
|
|
5677
|
-
// 那时闭包变量不可见,故这里给**等价内联兜底**(行为与 l0-extract-pre 的
|
|
6434
|
+
// C2(三层契约 I5『检索侧』准入):谓词经 env 传入 —— semanticArm 会被 smoke 套件"抽源码单独求值",
|
|
6435
|
+
// 那时闭包变量不可见,故这里给**等价内联兜底**(行为与 l0-extract-pre 的 isRetrievablePre 一致,
|
|
6436
|
+
// 即:已知三态一律放行、只对未知值 fail-closed,勿改语义)。
|
|
5678
6437
|
const semanticArm = async (env) => {
|
|
5679
|
-
const cur = typeof env.
|
|
6438
|
+
const cur = typeof env.isRetrievablePre === 'function' ? env.isRetrievablePre : ((r) => !r || !r.status || ['current', 'superseded', 'retracted'].includes(r.status))
|
|
6439
|
+
const mk = typeof env.supersededMarkPre === 'function' ? env.supersededMarkPre : (() => '')
|
|
6440
|
+
// ★G3 读侧:语义臂与词法臂**同源同法**(都在 statusOf 处读磁盘状态行)。
|
|
6441
|
+
const st = typeof env.statusOf === 'function' ? env.statusOf : (() => undefined)
|
|
5680
6442
|
const corpus = []
|
|
5681
6443
|
for (const src of env.sources) {
|
|
5682
6444
|
const text = await this.readTextSafe(src.path)
|
|
5683
6445
|
if (!text) continue
|
|
5684
|
-
const items = env.buildL0IndexPre(text, { layer: src.path })
|
|
6446
|
+
const items = env.buildL0IndexPre(text, { layer: src.path, statusOf: (id) => st(text, id) })
|
|
5685
6447
|
for (const it of items) {
|
|
5686
6448
|
if (!cur(it)) continue
|
|
5687
|
-
corpus.push({ memoryId: it.id, text: it.l0, label: src.label, layer: it.layer || 'log' })
|
|
6449
|
+
corpus.push({ memoryId: it.id, text: it.l0, label: src.label, layer: it.layer || 'log', mark: mk(it) })
|
|
5688
6450
|
}
|
|
5689
6451
|
}
|
|
5690
6452
|
if (!corpus.length) return []
|
|
@@ -5702,7 +6464,9 @@ class MemoryEngine {
|
|
|
5702
6464
|
.slice(0, Math.max(2, env.limit))
|
|
5703
6465
|
.map(([id, sc]) => {
|
|
5704
6466
|
const rec = corpus.find((c) => c.memoryId === id) || {}
|
|
5705
|
-
|
|
6467
|
+
// R4-B:把层带出去(分组在调用点做)。★本闭包会被 smoke 套件用 `new Function`
|
|
6468
|
+
// 抽源码单独求值,故**只能依赖 env/自身**:这里不做任何分组、不引用外部符号。
|
|
6469
|
+
return { label: rec.label || '', id8: String(id).slice(4, 12), score: sc, l0: rec.text || '', layer: rec.layer || 'log', mark: rec.mark || '' }
|
|
5706
6470
|
})
|
|
5707
6471
|
}
|
|
5708
6472
|
const semSources = []
|
|
@@ -5710,11 +6474,27 @@ class MemoryEngine {
|
|
|
5710
6474
|
for (const rf of reflections) semSources.push({ label: 'reflections/' + rf.name, path: path.join(p.reflectDir, rf.name) })
|
|
5711
6475
|
semSources.push({ label: p.projectDir + '/MEMORY.md', path: p.notesPath })
|
|
5712
6476
|
semSources.push({ label: '~' + p.userFile.slice(homedir().length), path: p.userFile })
|
|
6477
|
+
// ★L4: 语义臂同样接上白板(与上方 pushL0 同源同闸; fail-soft 取不到就跳过)
|
|
6478
|
+
if ((this.config || {}).handoffEnabled !== false) {
|
|
6479
|
+
try {
|
|
6480
|
+
semSources.push({ label: 'handoff/PLAN.md', path: p.planPath })
|
|
6481
|
+
const ledgerNameL4s = await this.latestLedgerNamePre(p.handoffDir)
|
|
6482
|
+
if (ledgerNameL4s) semSources.push({ label: 'handoff/' + ledgerNameL4s, path: path.join(p.handoffDir, ledgerNameL4s) })
|
|
6483
|
+
} catch (eL4s) {}
|
|
6484
|
+
}
|
|
5713
6485
|
// #45:不再传 maxRecords(=256) —— 语料截断即召回截断,会让靠后的来源永远够不到。
|
|
5714
|
-
const semHits = await semanticArm({ sources: semSources, buildL0IndexPre,
|
|
6486
|
+
const semHits = await semanticArm({ sources: semSources, buildL0IndexPre, isRetrievablePre: retrievableSem, supersededMarkPre: markSem, statusOf: statusOfNotePre, createHash, query, limit, minScore: 0.5 })
|
|
5715
6487
|
if (semHits.length) {
|
|
5716
6488
|
out.push('== 语义命中(L0 摘要,按相关度;可按锚点下钻) ==')
|
|
5717
|
-
|
|
6489
|
+
// ★R4-B(2026-09-18)分层**呈现**(与上方 L0 命中段同源同法):只重排"行"、不改分数序,
|
|
6490
|
+
// 组内保持原相对序(原行无序号,故组内相对序即原排序序)。**只有一层时不打标题**
|
|
6491
|
+
// ⇒ 单层输出与旧版逐字节相同(回滚安全)。
|
|
6492
|
+
const groupsS = groupSem(semHits, (s) => s.layer)
|
|
6493
|
+
const multiS = groupsS.length > 1
|
|
6494
|
+
for (const g of groupsS) {
|
|
6495
|
+
if (multiS) out.push('【' + g.label + '】')
|
|
6496
|
+
for (const s of g.items) out.push('· ' + s.label + ' [' + s.id8 + '] ×' + s.score.toFixed(2) + ' ' + s.l0 + (s.mark || ''))
|
|
6497
|
+
}
|
|
5718
6498
|
}
|
|
5719
6499
|
} catch (eSem) {}
|
|
5720
6500
|
}
|
|
@@ -5864,28 +6644,44 @@ class MemoryEngine {
|
|
|
5864
6644
|
|
|
5865
6645
|
/** 扫描 ~/.dsh/sessions 下所有会话,提取去重后的工作区路径。 */
|
|
5866
6646
|
async discoverWorkspaces() {
|
|
5867
|
-
|
|
6647
|
+
// ★#102(2026-09-22):解除「硬编码 30 且静默截断」。
|
|
6648
|
+
// 旧实现:`if (out.size >= 30) return` —— 上限写死、按**目录遍历顺序**取前 30,
|
|
6649
|
+
// 第 31 个工作区起被**静默丢弃**(跨区检索 / 注入索引 / 工作区总览一起少项,且无任何信号)。
|
|
6650
|
+
// 现在:① 上限走配置 `workspaceDiscoverMax`(默认 200,非法值回落默认);
|
|
6651
|
+
// ② 以「该工作区最近一次会话文件的 mtime」为活跃度,按新→旧排序后再截断 ——
|
|
6652
|
+
// 即便真到上限,丢掉的也是**最久没用过**的,而不是碰巧排在后面的;
|
|
6653
|
+
// ③ 仅保留一个 5×上限的遍历安全阀,防止 sessions 目录异常膨胀时无限走盘。
|
|
6654
|
+
const capRaw = Number(this.config && this.config.workspaceDiscoverMax)
|
|
6655
|
+
const cap = (Number.isFinite(capRaw) && capRaw >= 1) ? Math.floor(capRaw) : 200
|
|
6656
|
+
const walkLimit = cap * 5
|
|
6657
|
+
const out = new Map() // cwd → 最近会话文件 mtimeMs
|
|
5868
6658
|
const sessionsDir = path.join(dshHome(), 'sessions')
|
|
5869
6659
|
const walk = async (dir, depth) => {
|
|
5870
6660
|
if (depth > 4) return
|
|
5871
6661
|
let entries
|
|
5872
6662
|
try { entries = await readdir(dir, { withFileTypes: true }) } catch (e) { return }
|
|
5873
6663
|
for (const en of entries) {
|
|
5874
|
-
if (out.size >=
|
|
5875
|
-
if (en.isDirectory()) await walk(path.join(dir, en.name), depth + 1)
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
6664
|
+
if (out.size >= walkLimit) return
|
|
6665
|
+
if (en.isDirectory()) { await walk(path.join(dir, en.name), depth + 1); continue }
|
|
6666
|
+
if (!en.isFile() || !(en.name.endsWith('.jsonl.zstd') || en.name.endsWith('.jsonl'))) continue
|
|
6667
|
+
const full = path.join(dir, en.name)
|
|
6668
|
+
try {
|
|
6669
|
+
const first = await this.readFirstLine(full)
|
|
6670
|
+
if (!first) continue
|
|
6671
|
+
const h = JSON.parse(first)
|
|
6672
|
+
if (!h || typeof h.cwd !== 'string' || !h.cwd) continue
|
|
6673
|
+
let mt = 0
|
|
6674
|
+
try { mt = (await stat(full)).mtimeMs } catch (e) {}
|
|
6675
|
+
const prev = out.get(h.cwd) || 0
|
|
6676
|
+
if (mt > prev) out.set(h.cwd, mt)
|
|
6677
|
+
} catch (e) {}
|
|
5885
6678
|
}
|
|
5886
6679
|
}
|
|
5887
6680
|
await walk(sessionsDir, 0)
|
|
5888
|
-
return Array.from(out.
|
|
6681
|
+
return Array.from(out.entries())
|
|
6682
|
+
.sort((a, b) => b[1] - a[1]) // 活跃度降序(mtime 新→旧)
|
|
6683
|
+
.slice(0, cap)
|
|
6684
|
+
.map((e) => e[0])
|
|
5889
6685
|
}
|
|
5890
6686
|
|
|
5891
6687
|
/** 读取某工作区的近期记忆(最近5天日志 + 项目笔记头部),无 .dsh-memory 返回 null。 */
|
|
@@ -5975,6 +6771,124 @@ class MemoryEngine {
|
|
|
5975
6771
|
return { workspaces, graph, cached: false, generatedAt: result.generatedAt }
|
|
5976
6772
|
}
|
|
5977
6773
|
|
|
6774
|
+
// ---------- R3-②(2026-09-18):降级台账视图 + 落盘 ----------
|
|
6775
|
+
/**
|
|
6776
|
+
* 返回降级台账最小投影,并**顺带落盘**为可查询状态文件。
|
|
6777
|
+
*
|
|
6778
|
+
* 形态:**读驱动写** —— 只在 `debugInfo()`(诊断端点)被调用时写一次,
|
|
6779
|
+
* 不引入定时器、不新增常驻任务、不产生空闲期 IO。
|
|
6780
|
+
*
|
|
6781
|
+
* ★ 元规则:**落盘失败绝不可影响 debugInfo** —— 全程 try/catch,失败仅返回快照。
|
|
6782
|
+
*/
|
|
6783
|
+
_degradeViewSnapshot() {
|
|
6784
|
+
let snap
|
|
6785
|
+
try {
|
|
6786
|
+
snap = this._degradeSink
|
|
6787
|
+
? this._degradeSink.snapshot()
|
|
6788
|
+
: { schemaVersion: 'degrade_v1', updatedAt: null, counts: {}, recent: [], evicted: 0, cap: 0 }
|
|
6789
|
+
} catch (_) {
|
|
6790
|
+
snap = { schemaVersion: 'degrade_v1', updatedAt: null, counts: {}, recent: [], evicted: 0, cap: 0 }
|
|
6791
|
+
}
|
|
6792
|
+
// 落盘(best-effort)。失败不影响返回值,也不抛 —— 但**必须可见**。
|
|
6793
|
+
// ★ 本文件 `import path from 'node:path'` 是**默认导入**,没有裸 `join` 可用。
|
|
6794
|
+
// 曾误写裸 join ⇒ ReferenceError 被本 catch 静默吞掉 ⇒ 落盘长期失效而无人知晓
|
|
6795
|
+
// (同族事故第三次:dshHome / evDir / join 均属「闭包或导入不可见」类)。
|
|
6796
|
+
// ⇒ 因此返回值带 `persisted` 字段:失败在诊断面板/响应里**一眼可见**,
|
|
6797
|
+
// 符合本仓铁律「静默降级视为结构性缺陷」。
|
|
6798
|
+
// ★R4(2026-09-18):改为**合并写入** —— 同一文件同时带降级台账与配额测量两个键。
|
|
6799
|
+
// 保持向后兼容:degrade 的原有键(schemaVersion/counts/recent/…)位置与含义不变,
|
|
6800
|
+
// 仅**追加** `quota` 键 ⇒ 既有读者(面板/排障脚本)零改动。
|
|
6801
|
+
let persisted = false
|
|
6802
|
+
try {
|
|
6803
|
+
const quota = this._quotaViewSnapshot()
|
|
6804
|
+
persisted = this._persistObservabilityPre(snap, quota)
|
|
6805
|
+
} catch (_) { /* fail-soft:台账持久化不得打断诊断 */ }
|
|
6806
|
+
return Object.assign({}, snap, { persisted })
|
|
6807
|
+
}
|
|
6808
|
+
|
|
6809
|
+
// ---------- R4(2026-09-18):配额测量视图 ----------
|
|
6810
|
+
/**
|
|
6811
|
+
* 返回**配额测量**视图:采样快照 + 判定结论,并**顺带落盘**到与降级台账**同一个文件**
|
|
6812
|
+
* (多一个 `quota` 键 —— 不新增文件、不新增配置键、不新增常驻任务,遵守 S10.4)。
|
|
6813
|
+
*
|
|
6814
|
+
* 与 `_degradeViewSnapshot` 同一形态(读驱动写)与同一元规则(落盘失败绝不影响诊断),
|
|
6815
|
+
* 但**判据并列不混**:那边只记预期外失败,这边是常规业务观测。
|
|
6816
|
+
*/
|
|
6817
|
+
_quotaViewSnapshot() {
|
|
6818
|
+
let snap
|
|
6819
|
+
try {
|
|
6820
|
+
snap = this._quotaProbe
|
|
6821
|
+
? this._quotaProbe.snapshot()
|
|
6822
|
+
: { schemaVersion: 'quota_probe_v1', updatedAt: null, samples: [], evicted: 0, cap: 0 }
|
|
6823
|
+
} catch (_) {
|
|
6824
|
+
snap = { schemaVersion: 'quota_probe_v1', updatedAt: null, samples: [], evicted: 0, cap: 0 }
|
|
6825
|
+
}
|
|
6826
|
+
let verdict
|
|
6827
|
+
try {
|
|
6828
|
+
verdict = deriveQuotaVerdictPre(snap)
|
|
6829
|
+
} catch (_) {
|
|
6830
|
+
verdict = { version: 'quota_verdict_v1', verdict: 'insufficient-data', samples: 0, dropRate: 0, usage: 0, perLayer: {}, reasons: ['判定异常'] }
|
|
6831
|
+
}
|
|
6832
|
+
return Object.assign({}, snap, { verdict })
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
// ---------- ★#110(2026-09-22):hub 持久化 IO 健康度视图 ----------
|
|
6836
|
+
/**
|
|
6837
|
+
* 返回三层记忆(episodes/facts/procedures)落盘 IO 的健康度投影。
|
|
6838
|
+
*
|
|
6839
|
+
* **为什么需要**:hub 三店的 `io` 由 `hubIo()` 提供,旧实现 save/clear 内 `catch (_) {}`
|
|
6840
|
+
* 把异常吞在这一层 ⇒ 上层三店(A-8 之后)的 `try { io.save() } catch` 永远不触发、
|
|
6841
|
+
* 返回值恒为 `{ok:true, persisted:true}` ⇒ 「写不进去」在**任何**出口都不可见。
|
|
6842
|
+
* 现在失败会照抛并同时记进 `_hubIoHealth`,本方法就是它的只读投影。
|
|
6843
|
+
*
|
|
6844
|
+
* 纪律:与 `_degradeViewSnapshot` / `_quotaViewSnapshot` 同款 —— 只出计数 + 人话原因 + 时间戳,
|
|
6845
|
+
* **无路径、无原文**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回空计数)。
|
|
6846
|
+
*/
|
|
6847
|
+
_hubIoViewSnapshot() {
|
|
6848
|
+
return hubIoHealthSnapshotPre(this._hubIoHealth)
|
|
6849
|
+
}
|
|
6850
|
+
/**
|
|
6851
|
+
* 事实保留上限淘汰台账的只读投影(★P0-3 / 2026-09-22)。
|
|
6852
|
+
*
|
|
6853
|
+
* **为什么不能只用 stats**:`getStats()`(fact-store.js:824)返回的是累计计数,
|
|
6854
|
+
* 而 `lastPrune` 是闭包变量、只经 `getLastPrune()`(:832)单独暴露 ⇒
|
|
6855
|
+
* 「最近一次淘汰发生在什么时候、当时上限多少、超出多少」只有它能给。
|
|
6856
|
+
*
|
|
6857
|
+
* 纪律:与 `_hubIoViewSnapshot` / `_degradeViewSnapshot` 同款 —— 只出聚合数 + 时间戳,
|
|
6858
|
+
* **无事实正文、无路径**;任何异常都不得打断 debugInfo(全程 try/catch,失败返回 null)。
|
|
6859
|
+
* `protected > 0` 表示已轮到删「重要项」⇒ 上限设得过低,前端据此给告警色与指引。
|
|
6860
|
+
*/
|
|
6861
|
+
_factsPruneViewSnapshot() {
|
|
6862
|
+
try {
|
|
6863
|
+
const st = this._factStore
|
|
6864
|
+
if (!st) return null
|
|
6865
|
+
const last = typeof st.getLastPrune === 'function' ? st.getLastPrune() : null
|
|
6866
|
+
const limit = typeof st.retentionLimit === 'function' ? st.retentionLimit() : null
|
|
6867
|
+
if (!last && limit === null) return null
|
|
6868
|
+
return {
|
|
6869
|
+
pruned: Number(last && last.pruned) || 0,
|
|
6870
|
+
protected: Number(last && last.protected) || 0,
|
|
6871
|
+
limit: limit === null || limit === undefined
|
|
6872
|
+
? (last && last.limit !== undefined ? last.limit : null)
|
|
6873
|
+
: limit,
|
|
6874
|
+
over: Number(last && last.over) || 0,
|
|
6875
|
+
oldestAt: (last && last.oldestAt) || null,
|
|
6876
|
+
at: (last && last.at) || null,
|
|
6877
|
+
}
|
|
6878
|
+
} catch (_) { return null }
|
|
6879
|
+
}
|
|
6880
|
+
|
|
6881
|
+
/** 把降级台账与配额测量**合并写入同一状态文件**(读驱动写;失败返回 false,不抛)。 */
|
|
6882
|
+
_persistObservabilityPre(snap, quota) {
|
|
6883
|
+
let persisted = false
|
|
6884
|
+
try {
|
|
6885
|
+
const file = path.join(dshHome(), 'memory', 'degrade-pre', 'latest.json')
|
|
6886
|
+
// 合并形态:保留 degrade 的原有键(向后兼容既有读者),追加 quota 键。
|
|
6887
|
+
persisted = persistDegradeLedgerPre({ file, snapshot: Object.assign({}, snap, { quota }), mkdirSync, writeFileSync }) === true
|
|
6888
|
+
} catch (_) { /* fail-soft:观测面持久化不得打断诊断 */ }
|
|
6889
|
+
return persisted
|
|
6890
|
+
}
|
|
6891
|
+
|
|
5978
6892
|
// ---------- 调试中心(为提 issue 提供诊断信息) ----------
|
|
5979
6893
|
async debugInfo() {
|
|
5980
6894
|
const p = await this.resolvePaths(undefined)
|
|
@@ -6062,6 +6976,21 @@ class MemoryEngine {
|
|
|
6062
6976
|
return Object.assign({ enabled: this.config.pythonBackendEnabled === true }, c ? c.debugView() : {})
|
|
6063
6977
|
})(),
|
|
6064
6978
|
indexSyncHost: this._indexSyncHost ? this._indexSyncHost.debugView() : { enabled: false },
|
|
6979
|
+
// R3-②(2026-09-18):降级台账最小投影。与各 host 的 debugView 走**同一出口**,不新增通道;
|
|
6980
|
+
// 只暴露 counts/recent/evicted(无原文、无路径)—— 与既有「§17 最小投影」纪律一致。
|
|
6981
|
+
degrade: this._degradeViewSnapshot(),
|
|
6982
|
+
// ★R4(2026-09-18):配额测量视图 —— 与降级台账**同一出口、同一落盘文件**(多一个 `quota` 键)。
|
|
6983
|
+
// 回答用户那两个问题:「配额太少(内容进不来)」还是「配额多了(白花 token)」。
|
|
6984
|
+
// 判定默认 `insufficient-data`(样本不足**不猜**),符合用户「基于长期观察」的要求。
|
|
6985
|
+
quota: this._quotaViewSnapshot(),
|
|
6986
|
+
// ★#110(2026-09-22):hub 三层记忆持久化 IO 健康度。与既有最小投影同一出口、同一纪律
|
|
6987
|
+
// (只出计数 + 人话原因 + 时间戳,**无路径、无原文**)。回答的问题:
|
|
6988
|
+
// 「记忆到底写进磁盘了没有」——此前 save 失败被静默吞掉,面板/日志/返回值三处都看不出来。
|
|
6989
|
+
hubIo: this._hubIoViewSnapshot(),
|
|
6990
|
+
// ★P0-3(2026-09-22):事实保留上限淘汰台账 —— 与既有最小投影同一出口、同一纪律
|
|
6991
|
+
// (只出聚合计数与时间戳,**无事实正文、无路径**)。此前 getLastPrune/retentionLimit
|
|
6992
|
+
// 在双侧零调用方 ⇒「事实被悄悄淘汰了」在界面上无处可查。
|
|
6993
|
+
factsPrune: this._factsPruneViewSnapshot(),
|
|
6065
6994
|
runtimes: this.runtimes.values().map((rt) => ({
|
|
6066
6995
|
key: rt.key,
|
|
6067
6996
|
sessionId: rt.sessionId,
|
|
@@ -6401,25 +7330,46 @@ class MemoryEngine {
|
|
|
6401
7330
|
try {
|
|
6402
7331
|
const sessionsSvc = this._sessionsSvc
|
|
6403
7332
|
const agentSvc = this._agentSvc
|
|
6404
|
-
if (!sessionsSvc || !agentSvc || typeof sessionsSvc.list !== 'function') {
|
|
7333
|
+
if (!sessionsSvc || !agentSvc || typeof sessionsSvc.list !== 'function') { diagThrottled('rla:svc', 'restoreLastAgent: svc missing sessions=' + !!sessionsSvc + ' agent=' + !!agentSvc); return }
|
|
6405
7334
|
const sessions = sessionsSvc.list()
|
|
6406
|
-
if (!sessions || !sessions.length) {
|
|
6407
|
-
|
|
7335
|
+
if (!sessions || !sessions.length) { diagThrottled('rla:none', 'restoreLastAgent: no sessions'); return }
|
|
7336
|
+
if (typeof agentSvc.get !== 'function') { diagThrottled('rla:get', 'restoreLastAgent: agentSvc.get unavailable'); return }
|
|
7337
|
+
// 找最近活跃的**用户会话**(log 最后事件时间最大)。
|
|
7338
|
+
// ★2026-09-21 二次修 bug(证据: tools/probe-session-kind.mjs 解压会话头部):
|
|
7339
|
+
// 上一轮修的判据是「有 parentSession 就跳过」, **过宽** —— 它把「接续会话」也一并排除。
|
|
7340
|
+
// 实测两类会话的区别:
|
|
7341
|
+
// · 子代理 : origin='subagent', delegationDepth=1 ⇒ 该排除
|
|
7342
|
+
// · 接续会话 : origin 缺省, delegationDepth=0 ⇒ **是用户会话, 必须收**
|
|
7343
|
+
// 当列表里只有接续会话时(用户点过「一键接续」后即为此形态), best 永远是 null ⇒
|
|
7344
|
+
// `_lastAgent` 恢复不了 + 每 tick 重试 ⇒ 刷屏。改用 isSubAgentSession() 精确判定。
|
|
6408
7345
|
let best = null, bestTime = 0
|
|
7346
|
+
let skippedSub = 0
|
|
6409
7347
|
for (const s of sessions) {
|
|
7348
|
+
if (isSubAgentSession(s)) { skippedSub++; continue }
|
|
6410
7349
|
let t = 0
|
|
6411
7350
|
try { const last = s.log && s.log[s.log.length - 1]; if (last && last.time) t = last.time } catch (e) {}
|
|
6412
7351
|
if (t >= bestTime) { bestTime = t; best = s }
|
|
6413
7352
|
}
|
|
6414
|
-
if (!best
|
|
7353
|
+
if (!best) { diagThrottled('rla:nobest', 'restoreLastAgent: no usable session (总 ' + sessions.length + ' 个, 其中子代理 ' + skippedSub + ' 个)'); return }
|
|
6415
7354
|
const a = agentSvc.get(best.id)
|
|
6416
|
-
|
|
7355
|
+
// 接受条件: agent 可用且**不是子代理**。接续会话(有 parent 但 delegationDepth=0)必须接受 ——
|
|
7356
|
+
// 这正是本 bug 的核心: 旧判据 `parentSession === undefined` 会把它拒掉。
|
|
7357
|
+
if (a && a.session && !isSubAgentSession(a)) {
|
|
6417
7358
|
this._lastAgent = a
|
|
6418
|
-
|
|
7359
|
+
_diagLastAt.delete('rla:reject') // 成功即清退避, 下次真失败能立刻看到
|
|
7360
|
+
const h = sessionHeaderOf(a) || {}
|
|
7361
|
+
diag('restoreLastAgent: recovered agent id=' + a.id + ' session=' + a.session.id +
|
|
7362
|
+
' logEvents=' + sessionEventsOf(a.session).length +
|
|
7363
|
+
// 带归属信息便于事后核对: continuation 表示它是接续会话(修复的主要目标形态)
|
|
7364
|
+
' kind=' + (hasParentSession(a) ? 'continuation' : 'top') +
|
|
7365
|
+
' depth=' + (h.delegationDepth === undefined ? '-' : h.delegationDepth) +
|
|
7366
|
+
' origin=' + (h.origin || '-'))
|
|
6419
7367
|
} else {
|
|
6420
|
-
|
|
7368
|
+
// 选中了可用候选却仍拿不到 agent —— 多为宿主尚未把该会话实例化, 属**暂时**状态。
|
|
7369
|
+
// 用节流(5 分钟一条)而非每 tick 一条, 避免刷屏; 恢复成功后自动清退避。
|
|
7370
|
+
diagThrottled('rla:reject', 'restoreLastAgent: candidate rejected (id=' + (a && a.id) + ' hasSession=' + !!(a && a.session) + ' parent=' + (a && a.session && a.session.header && a.session.header.parentSession) + ')')
|
|
6421
7371
|
}
|
|
6422
|
-
} catch (e) {
|
|
7372
|
+
} catch (e) { diagThrottled('rla:err', 'restoreLastAgent error: ' + (e && e.message)) }
|
|
6423
7373
|
}
|
|
6424
7374
|
|
|
6425
7375
|
/** 自动总结:按时间点推断时段,生成总结并置 pendingSummary(供 client 弹窗)。 */
|
|
@@ -6728,8 +7678,10 @@ class MemoryEngine {
|
|
|
6728
7678
|
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
6729
7679
|
if (this.config.autoConsolidate === false) { why('config.autoConsolidate=false'); return }
|
|
6730
7680
|
if (!agent || !agent.session) { why('no agent/session'); return }
|
|
6731
|
-
//
|
|
6732
|
-
|
|
7681
|
+
// 只处理**用户会话**(2026-09-21 修: 原判据「有 parentSession 就跳过」过宽, 会把接续会话一并跳过
|
|
7682
|
+
// ⇒ 接续会话里的对话**永远不自动沉淀**。改用 isSubAgentSession() 精确排除子代理;
|
|
7683
|
+
// 接续会话(delegationDepth=0)是用户真实会话, 必须正常沉淀。)
|
|
7684
|
+
try { if (isSubAgentSession(agent)) { why('sub-agent session skipped'); return } } catch (e) {}
|
|
6733
7685
|
const minChars = Math.max(Number(this.config.autoConsolidateMinChars) || 240, 80)
|
|
6734
7686
|
const today = this.memToday()
|
|
6735
7687
|
if (this._autoCallDate !== today) { this._autoCallDate = today; this._autoCallCount = 0 }
|
|
@@ -7128,9 +8080,14 @@ class MemoryEngine {
|
|
|
7128
8080
|
noteMsg = '\nAI 蒸馏不可用,原文已按老方式归档到 ' + p.notesPath
|
|
7129
8081
|
}
|
|
7130
8082
|
// 4) 活跃目录移除旧日志(原文已在 archive/ 保底)
|
|
8083
|
+
// ★ T3-1(2026-09-19)**数据丢失修复**:原实现遍历 `oldLogs` ⇒
|
|
8084
|
+
// 归档失败(磁盘/权限/新卫生门拒绝)的日志**照样被 rm** ⇒ 永久丢失。
|
|
8085
|
+
// 上方注释虽写「原文已在 archive/ 保底」,但第 2 步的 catch 只记日志、
|
|
8086
|
+
// 并不保证 archived 收录了它。此处改为**以 archived 为准**:没归档成功的一律保留在活跃目录。
|
|
7131
8087
|
const deleted = []
|
|
7132
8088
|
const kept = []
|
|
7133
8089
|
for (const log of oldLogs) {
|
|
8090
|
+
if (archived.indexOf(log.name) === -1) { kept.push(log.name); continue }
|
|
7134
8091
|
try {
|
|
7135
8092
|
await rm(path.join(p.projectDir, log.name), { force: true })
|
|
7136
8093
|
deleted.push(log.name)
|
|
@@ -7184,6 +8141,9 @@ class MemoryEngine {
|
|
|
7184
8141
|
periodSummary: this.periodSummary(),
|
|
7185
8142
|
refreshedAt: this.state.loadedAt,
|
|
7186
8143
|
configReadError: this._readError,
|
|
8144
|
+
// ★#82:配置是否曾被隔离过(坏文件已挪到 .corrupt-<ts>,用户数据未被覆盖)。
|
|
8145
|
+
// 供前端「设置」页与诊断接口显示,避免「设置莫名全没了」无从解释。
|
|
8146
|
+
configCorrupted: !!(this._readError && String(this._readError).includes('quarantined=')),
|
|
7187
8147
|
}
|
|
7188
8148
|
}
|
|
7189
8149
|
}
|
|
@@ -7706,6 +8666,62 @@ function sanitizeForInjection(text, maxChars) {
|
|
|
7706
8666
|
return neutralizePromptTemplateVars(truncateHead(s.clean, maxChars || 2000))
|
|
7707
8667
|
}
|
|
7708
8668
|
/** 写入端: 单条上限 + 乱码拒写 + 连续重复拒写。返回 { ok, reason, clean }。 */
|
|
8669
|
+
/**
|
|
8670
|
+
* ★ T3-1(2026-09-19):**写入原语卫生门(hygiene-only,无体量语义)**。
|
|
8671
|
+
*
|
|
8672
|
+
* **为什么另开一个函数,而不是把 `sanitizeForWrite` 下沉:**
|
|
8673
|
+
* `sanitizeForWrite` 除了卫生检查,还带一条**单条载荷上限 8000 字**,
|
|
8674
|
+
* 超长时**不是拒绝而是 `slice(0, 8000)` 静默截断**(见其 `:8327-8329`)。
|
|
8675
|
+
* 而 `appendText` / `writeFull` 目前**没有任何体量限制**,且承担两类"必须写全文"的职责:
|
|
8676
|
+
* · `:7716` **原文保底归档**(maintain 的最后一道保险,注释即写「绝不丢信息」)
|
|
8677
|
+
* · `:7733` AI 不可用时把归档日志**原文内联**回笔记
|
|
8678
|
+
* 实测真实日志 77805 / 52394 / 43224 字 ⇒ 若把带截断的闸门下沉,这两条会被**静默砍到 8000 字**。
|
|
8679
|
+
*
|
|
8680
|
+
* **所以本函数只做「拦脏」,绝不做「截断」** —— 体量策略属于调用方的业务语义,
|
|
8681
|
+
* 不该进写入原语。`sanitizeForWrite` 保持原样、现有 6 个入口继续用它,
|
|
8682
|
+
* 二者**判据同源**(复用同一批正则与同一批函数),因此不会出现"两套标准"。
|
|
8683
|
+
*
|
|
8684
|
+
* **返回**:`{ ok, reason? }`。**不返回 clean** —— 本门不改写正文,只决定放行/拒绝。
|
|
8685
|
+
*
|
|
8686
|
+
* **★★ 判据范围(2026-09-19 实测决定,勿扩)**:
|
|
8687
|
+
* 只保留**内容级**判据(无论文本多长、无论写什么文件都该拦的):
|
|
8688
|
+
* `mojibake`(乱码)/ `stutter`(复读退化)/ `base64`(base64 残骸行)/ `duplicate-lines`(连续重复行)
|
|
8689
|
+
* **刻意不含两样**:
|
|
8690
|
+
* · **`RAW_JSON_MARK`** —— 它是**入口级**判据(针对"AI 调写入工具时传了外部画像 raw JSON"),
|
|
8691
|
+
* 且含**裸词 `updatedAt`**。实测扫描 541 个真实记忆文件:**124 个命中该正则**(多数是正常提到
|
|
8692
|
+
* `updatedAt` 的正文),若下沉到原语层会**大面积误伤**。它继续留在 `sanitizeForWrite` 入口层。
|
|
8693
|
+
* · **体量上限** —— 见上文,截断是调用方的业务语义。
|
|
8694
|
+
*
|
|
8695
|
+
* **★ 只挂在 `appendText` 一处,不挂 `writeFull`**(实测依据):
|
|
8696
|
+
* `writeFull` 写的是**整篇文档**(`:8193` 移除导入段落 / `:9905`/`:9943` 整篇重写),
|
|
8697
|
+
* 用内容级判据审"整篇文档"命中面过大;且 `:7723` 是**原文保底归档**,绝不能因判据误伤而失败。
|
|
8698
|
+
* `appendText` 追加的是**单条新内容**,正是判据设计时面对的形态。
|
|
8699
|
+
*
|
|
8700
|
+
* **fail-soft 纪律**:本函数**绝不抛出**;任何内部异常一律视为放行(`ok:true`),
|
|
8701
|
+
* 因为它是**新增的守卫**,不能成为新的失败源。
|
|
8702
|
+
*/
|
|
8703
|
+
function hygieneGateForPrimitive(text) {
|
|
8704
|
+
try {
|
|
8705
|
+
var raw = String(text == null ? '' : text)
|
|
8706
|
+
if (raw.length === 0) return { ok: true } // 空串由调用方语义决定(append 空串无害),本门不拦
|
|
8707
|
+
if (mojibakeDensity(raw) > 0.001) return { ok: false, reason: 'mojibake' }
|
|
8708
|
+
if (hasStutter(raw)) return { ok: false, reason: 'stutter' }
|
|
8709
|
+
var b64line = raw.split('\n').some(function (l) { var k = l.trim(); return k.length > 100 && BASE64_LINE.test(k) })
|
|
8710
|
+
if (b64line) return { ok: false, reason: 'base64' }
|
|
8711
|
+
// 连续行重复(同一段一模一样的行连续 ≥3 次 → 疑似退化 writer 循环;空行打断连续)
|
|
8712
|
+
var seq = 0, prev = '', repeated = false
|
|
8713
|
+
for (var l of raw.split('\n')) {
|
|
8714
|
+
var t = l.trim()
|
|
8715
|
+
if (!t) { seq = 0; prev = ''; continue }
|
|
8716
|
+
if (t === prev) { seq++; if (seq >= 3) { repeated = true; break } } else { prev = t; seq = 1 }
|
|
8717
|
+
}
|
|
8718
|
+
if (repeated) return { ok: false, reason: 'duplicate-lines' }
|
|
8719
|
+
return { ok: true }
|
|
8720
|
+
} catch (e) {
|
|
8721
|
+
return { ok: true } // fail-soft:新守卫绝不成为新失败源
|
|
8722
|
+
}
|
|
8723
|
+
}
|
|
8724
|
+
|
|
7709
8725
|
function sanitizeForWrite(text, opts) {
|
|
7710
8726
|
var o = opts || {}
|
|
7711
8727
|
var maxEntry = o.maxEntryChars || 8000
|
|
@@ -7841,7 +8857,13 @@ function foldSessionLogEvents(lines) {
|
|
|
7841
8857
|
const m = messageOfEvent(ev)
|
|
7842
8858
|
if (m && m.role && Array.isArray(m.content)) {
|
|
7843
8859
|
const txt = textOfContent(m.content)
|
|
7844
|
-
|
|
8860
|
+
// ★L3.5(2026-09-17): 附件描述符必须在这里就带上 —— 旧实现只取 text, 附件字段
|
|
8861
|
+
// 在源头被丢弃, 转写/接续材料因此完全看不到"用户投过这张图/这个文件"。
|
|
8862
|
+
// 只有文本的消息保持**原样形状** {role,text}(不新增字段), 守 legacy 逐字节兼容纪律。
|
|
8863
|
+
const atts = attachmentsOfContent(m.content)
|
|
8864
|
+
if (txt && atts.length) out.msgs.push({ role: m.role, text: txt, attachments: atts })
|
|
8865
|
+
else if (txt) out.msgs.push({ role: m.role, text: txt })
|
|
8866
|
+
else if (atts.length) out.msgs.push({ role: m.role, text: '', attachments: atts })
|
|
7845
8867
|
}
|
|
7846
8868
|
} else if (t === 'tool/call') {
|
|
7847
8869
|
const d = (ev && ev.data) || {}
|
|
@@ -7875,6 +8897,34 @@ function workspaceIdForSession(workspaces, sessionId, cwd) {
|
|
|
7875
8897
|
return ''
|
|
7876
8898
|
}
|
|
7877
8899
|
|
|
8900
|
+
/** ★L5(2026-09-17)·板式判定(纯函数,单一真源)。
|
|
8901
|
+
* **本函数只服务于真正的 graph 特性**(看板卡渲染、sidecar 事件、白板 tag 地图等)。
|
|
8902
|
+
* ⚠️ 纪律(用户已批准的判据):锚点是**写入格式契约**, 与看板**渲染形态**无关 ⇒
|
|
8903
|
+
* **锚点写入处不得用本函数把关** —— 旧实现在 PLAN 锚点与账本锚点两处误把它当渲染闸门,
|
|
8904
|
+
* 导致 legacy 档的白板/账本永远拿不到锚点, §2 承诺的「白板内容凭锚点进 L0 检索语料」恒为空。
|
|
8905
|
+
* 这条判据取代早先"只数 boardMode 门"的窄口径:不问是不是 boardMode 门, 只问
|
|
8906
|
+
* **这个门控的是「渲染」还是「写入/取材」**。 */
|
|
8907
|
+
function isGraphModePre(cfg) {
|
|
8908
|
+
try { return String((cfg || {}).boardMode || '').trim().toLowerCase() === 'graph' } catch (e) { return false }
|
|
8909
|
+
}
|
|
8910
|
+
|
|
8911
|
+
/** ★L4(2026-09-17)·取交接目录里**最新一篇**账本的文件名(纯 IO 助手)。
|
|
8912
|
+
* 判据与 buildContinueCarry 的 ledgerName 选取**同源**(mtime 最大, 平手取字典序靠后者),
|
|
8913
|
+
* 但不复用其中的内联循环 —— 那段落有它自己的 staleNote 计算, 抽出来会改变它的形状。
|
|
8914
|
+
* 找不到/无权限 ⇒ 空串(fail-soft, 绝不抛, 白板进检索绝不因它阻塞)。 */
|
|
8915
|
+
async function latestLedgerNamePre(handoffDir) {
|
|
8916
|
+
try {
|
|
8917
|
+
const names = (await readdir(handoffDir).catch(() => [])).filter((n) => /^handoff-\d{8}-\d{6}(-[a-z])?\.md$/.test(n))
|
|
8918
|
+
let best = '', bestMt = -1
|
|
8919
|
+
for (const n of names.sort()) {
|
|
8920
|
+
const st = await stat(path.join(handoffDir, n)).catch(() => null)
|
|
8921
|
+
const mt = st ? Number(st.mtimeMs) : 0
|
|
8922
|
+
if (mt >= bestMt) { bestMt = mt; best = n }
|
|
8923
|
+
}
|
|
8924
|
+
return best
|
|
8925
|
+
} catch (e) { return '' }
|
|
8926
|
+
}
|
|
8927
|
+
|
|
7878
8928
|
/** 从 session 提取消息。surface 不是完整可靠的 user 来源,因此失败时回退完整事件日志。 */
|
|
7879
8929
|
function messageOfEvent(ev) {
|
|
7880
8930
|
if (!ev) return null
|
|
@@ -7896,6 +8946,84 @@ function textOfContent(content) {
|
|
|
7896
8946
|
walk(content, 0)
|
|
7897
8947
|
return out.join('')
|
|
7898
8948
|
}
|
|
8949
|
+
/** ★L3.5(2026-09-17)·附件描述符抽取(纯函数,供 smoke 驱动)。
|
|
8950
|
+
* 实测:DSH 附件是**内容寻址 blob**(非 base64 内嵌),会话日志里存的是结构化 part
|
|
8951
|
+
* `{type:'image'|'file', attachment:{attachmentId:'sha256:<hex>', mediaType, name, bytes, width, height}}`;
|
|
8952
|
+
* 官方落盘规则(@deepseek-ai/dsh-attachment-local/lib/index.js):
|
|
8953
|
+
* 图片对象 :290 join(root,'objects', sha256.slice(0,2), sha256)
|
|
8954
|
+
* 文件对象 :661 join(root,'files', sha256.slice(0,2), sha256, ref.name)
|
|
8955
|
+
* 旧实现 `textOfContent(m.content)` **只取文本** ⇒ 附件字段在源头就被丢掉, 转写里看不到
|
|
8956
|
+
* "用户投过这张图/这个文件", 接续会话因此完全不知道有这些材料可读。 */
|
|
8957
|
+
function attachmentsOfContent(content) {
|
|
8958
|
+
const out = []
|
|
8959
|
+
const seen = Object.create(null)
|
|
8960
|
+
const walk = (v, depth) => {
|
|
8961
|
+
if (depth > 8 || v == null) return
|
|
8962
|
+
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return }
|
|
8963
|
+
if (typeof v !== 'object') return
|
|
8964
|
+
const a = v.attachment && typeof v.attachment === 'object' ? v.attachment : null
|
|
8965
|
+
if (a && typeof a.attachmentId === 'string' && a.attachmentId) {
|
|
8966
|
+
const kind = v.type === 'file' ? 'file' : (v.type === 'image' ? 'image' : '')
|
|
8967
|
+
const key = kind + '|' + a.attachmentId + '|' + String(a.name || '')
|
|
8968
|
+
if (kind && !seen[key]) {
|
|
8969
|
+
seen[key] = 1
|
|
8970
|
+
out.push({
|
|
8971
|
+
kind,
|
|
8972
|
+
ref: a.attachmentId,
|
|
8973
|
+
id: a.attachmentId.indexOf('sha256:') === 0 ? a.attachmentId.slice(7) : a.attachmentId,
|
|
8974
|
+
mediaType: String(a.mediaType || ''),
|
|
8975
|
+
name: String(a.name || ''),
|
|
8976
|
+
bytes: Number(a.bytes) || 0,
|
|
8977
|
+
width: Number(a.width) || 0,
|
|
8978
|
+
height: Number(a.height) || 0,
|
|
8979
|
+
})
|
|
8980
|
+
}
|
|
8981
|
+
}
|
|
8982
|
+
if (v.content !== undefined) walk(v.content, depth + 1)
|
|
8983
|
+
}
|
|
8984
|
+
walk(content, 0)
|
|
8985
|
+
return out
|
|
8986
|
+
}
|
|
8987
|
+
|
|
8988
|
+
/** ★L3.5·按 attachmentId 推导 blob 落盘路径(纯函数,供 smoke 驱动;不校验存在性)。
|
|
8989
|
+
* 返回 {objectPath, filePath} 两个候选:图片在 objects/<前2位>/<hex>, 具名文件在
|
|
8990
|
+
* files/<前2位>/<hex>/<name>。未知/异常 id ⇒ 两值均为 ''(fail-soft,绝不抛)。 */
|
|
8991
|
+
function attachmentBlobPathsPre(att, homeDir) {
|
|
8992
|
+
const empty = { objectPath: '', filePath: '' }
|
|
8993
|
+
try {
|
|
8994
|
+
if (!att || !att.id) return empty
|
|
8995
|
+
const hex = String(att.id)
|
|
8996
|
+
if (!/^[0-9a-f]{16,}$/i.test(hex)) return empty
|
|
8997
|
+
const root = path.join(String(homeDir || dshHome()), 'attachments', 'v1')
|
|
8998
|
+
const two = hex.slice(0, 2)
|
|
8999
|
+
return {
|
|
9000
|
+
objectPath: path.join(root, 'objects', two, hex),
|
|
9001
|
+
filePath: att.kind === 'file' && att.name ? path.join(root, 'files', two, hex, att.name) : '',
|
|
9002
|
+
}
|
|
9003
|
+
} catch (e) { return empty }
|
|
9004
|
+
}
|
|
9005
|
+
|
|
9006
|
+
/** ★L3.5·把附件描述符渲染成可读文本行(纯函数,供 smoke 驱动)。
|
|
9007
|
+
* 输出形如 `[附件 image] name.png (12.3 KB) → <绝对路径>`;blob 不存在时也照样列出
|
|
9008
|
+
* —— 路径是"去哪儿找"的线索, 存在性由调用方(接续会话的 read)自行判定。
|
|
9009
|
+
* homeDir 传空时调用 dshHome(); 渲染零副作用、零 IO。 */
|
|
9010
|
+
function renderAttachmentLinesPre(atts, homeDir) {
|
|
9011
|
+
try {
|
|
9012
|
+
const list = Array.isArray(atts) ? atts : []
|
|
9013
|
+
if (!list.length) return []
|
|
9014
|
+
return list.map((a) => {
|
|
9015
|
+
const paths = attachmentBlobPathsPre(a, homeDir)
|
|
9016
|
+
const target = paths.filePath || paths.objectPath
|
|
9017
|
+
const bits = []
|
|
9018
|
+
if (a.mediaType) bits.push(a.mediaType)
|
|
9019
|
+
if (a.bytes > 0) bits.push(a.bytes >= 1024 ? (Math.round(a.bytes / 1024 * 10) / 10) + ' KB' : a.bytes + ' B')
|
|
9020
|
+
if (a.width && a.height) bits.push(a.width + 'x' + a.height)
|
|
9021
|
+
const label = a.kind === 'file' ? '文件' : '图片'
|
|
9022
|
+
return '[附件 ' + label + '] ' + (a.name || (a.id || '').slice(0, 12)) + (bits.length ? ' (' + bits.join(', ') + ')' : '') + ' → ' + (target || '(路径不可解析)')
|
|
9023
|
+
})
|
|
9024
|
+
} catch (e) { return [] }
|
|
9025
|
+
}
|
|
9026
|
+
|
|
7899
9027
|
/**
|
|
7900
9028
|
* 新旧两代 Session API 兼容的原始事件数组读取:
|
|
7901
9029
|
* - 旧版 @deepseek-ai/dsh-session 在 Session 上暴露 .events 数组;
|
|
@@ -8051,7 +9179,21 @@ export function apply(ctx, config) {
|
|
|
8051
9179
|
try {
|
|
8052
9180
|
if (!process._dshAutoMemoryRejectionGuard) {
|
|
8053
9181
|
process._dshAutoMemoryRejectionGuard = true
|
|
8054
|
-
|
|
9182
|
+
// ★#84(2026-09-20):**加计数**。原实现只打一行日志 ⇒ 无法回答
|
|
9183
|
+
// 「一共几次、是否在频繁发生、最近一次何时」——而这条 guard 恰好会**吞掉**
|
|
9184
|
+
// 本该炸出来的错误(本仓 ⑩-b 那类「fail-soft 吞错」的典型形态)。
|
|
9185
|
+
// 计数暴露到诊断面后,这类问题才有观测入口。
|
|
9186
|
+
const rejStat = { count: 0, firstAt: 0, lastAt: 0, lastLine: '' }
|
|
9187
|
+
process._dshAutoMemoryRejectionStat = rejStat
|
|
9188
|
+
process._dshAutoMemoryRejectionGuard = true
|
|
9189
|
+
process.on('unhandledRejection', (reason) => {
|
|
9190
|
+
const line = damDiagLine(reason)
|
|
9191
|
+
rejStat.count++
|
|
9192
|
+
rejStat.lastAt = Date.now()
|
|
9193
|
+
rejStat.lastLine = line
|
|
9194
|
+
if (!rejStat.firstAt) rejStat.firstAt = rejStat.lastAt
|
|
9195
|
+
damSafeDiag(process.stderr, '[dsh-auto-memory] unhandledRejection guard #' + rejStat.count + ': ' + line)
|
|
9196
|
+
})
|
|
8055
9197
|
}
|
|
8056
9198
|
} catch (e) {}
|
|
8057
9199
|
const engine = new MemoryEngine()
|
|
@@ -8067,6 +9209,17 @@ export function apply(ctx, config) {
|
|
|
8067
9209
|
engine._shadowHost = createShadowHost({ engine })
|
|
8068
9210
|
// M5-3:Context Bridge Host 接线(assoc+contextBridge 双门;默认关闭零构造/零 IO)
|
|
8069
9211
|
engine._contextHost = createContextHost({ engine })
|
|
9212
|
+
// R3(2026-09-18):降级台账。挂在引擎上,与各 host 同层 —— 跨臂统一,不分散到各 host。
|
|
9213
|
+
// 机制只记录不阻断;留痕自身 fail-soft(见 degrade.js 的元规则)。
|
|
9214
|
+
engine._degradeSink = createDegradeSinkPre({})
|
|
9215
|
+
// R4(2026-09-18):配额探针 —— 与降级台账**同模块、同落盘通道,但判据并列不混**。
|
|
9216
|
+
// 用户要求「配额得基于长期的观察,科学的(测量),不能拍脑子」,故每轮采集
|
|
9217
|
+
// tier0Meta 的配额切片(tokens/dropped/perLayer),供 deriveQuotaVerdictPre 下结论。
|
|
9218
|
+
// 注意:**绝不能**把常规观测塞进 `_degradeSink.record` —— 台账判据是「只记预期外失败」,
|
|
9219
|
+
// 混入后「有没有降级」将永远非空,降级信号被淹没。
|
|
9220
|
+
engine._quotaProbe = createQuotaProbePre({})
|
|
9221
|
+
// 注:臂健康快照(deriveArmsHealthPre)在各臂执行路径上按实际情况标注,
|
|
9222
|
+
// 见 recall() 内 semantic/evidence 段与 _l0IndexSync 段。
|
|
8070
9223
|
// M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
|
|
8071
9224
|
engine._activationHost = createActivationHost({ engine })
|
|
8072
9225
|
// M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
|
|
@@ -8093,20 +9246,33 @@ export function apply(ctx, config) {
|
|
|
8093
9246
|
// (此前 io 缺省 no-op → 重启清零)。落盘为快照整体写,数据量有界(episodes≤retention/facts/过程中)。
|
|
8094
9247
|
{
|
|
8095
9248
|
const hubDir = path.join(dshHome(), 'memory', 'hub')
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8099
|
-
|
|
8100
|
-
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
9249
|
+
// ★#110(2026-09-22):hub 持久化 IO 失败**可见化**(实现已抽到 `hub-io.js`,本处只做接线)。
|
|
9250
|
+
// 旧实现三个方法各自 `catch (_) {}` 静默吞异常,后果有两层:
|
|
9251
|
+
// ① 异常在**适配器这一层**就被吃掉 ⇒ 三店 A-8 的 `try { io.save() } catch` 永远走不到 catch 分支
|
|
9252
|
+
// ⇒ store 照样报 `{ok:true, persisted:true}`,上层统计与返回值全是绿的;
|
|
9253
|
+
// ② 用户侧表现为「记忆看着存上了,重启清零」,日志、计数、面板三处都拿不到信号。
|
|
9254
|
+
// 现在:save/clear 失败**照原样抛出**(让 A-8 的既有 try/catch 真正生效)+ 每次失败记账到
|
|
9255
|
+
// `engine._hubIoHealth`(人话原因 + 累计次数 + 时间),经 debugInfo().associativeMemory.hubIo
|
|
9256
|
+
// 与诊断日志(节流 5 分钟/键)输出;load 的「无文件/损坏 → null(空启动)」语义不变。
|
|
9257
|
+
const hubIoHealth = createHubIoHealthPre()
|
|
9258
|
+
engine._hubIoHealth = hubIoHealth
|
|
9259
|
+
const hubIo = createHubIoPre({
|
|
9260
|
+
dir: hubDir,
|
|
9261
|
+
health: hubIoHealth,
|
|
9262
|
+
onError: (key, msg) => diagThrottled('hubIo:' + key, msg),
|
|
9263
|
+
})
|
|
9264
|
+
// ★#110:把 io 工厂挂到 engine,供 hub 喂数循环做**批内合并落盘**(`beginBatch` / `endBatch`)。
|
|
9265
|
+
// 喂数循环在另一个块作用域里,拿不到本块的 `hubIo` 局部变量,只能走 engine 这条既有通道。
|
|
9266
|
+
engine._hubIoFactory = hubIo
|
|
9267
|
+
// ★P0-3(2026-09-22):事实库此前是**内联**创建在下面的 stores 字面量里、engine 上无引用。
|
|
9268
|
+
// 这里提到局部常量并单独挂一份,供 debugInfo() 的只读投影(factsPrune)使用。
|
|
9269
|
+
// 不改任何既有行为:stores.facts 仍是同一个实例,只是有了名字。
|
|
9270
|
+
const factStore = createFactStorePre({ config: { maxFacts: Number(engine.config.factRetentionMax) || 1000 }, io: hubIo('facts.json') })
|
|
9271
|
+
engine._factStore = factStore
|
|
8106
9272
|
engine._memoryHub = createMemoryHubPre({
|
|
8107
9273
|
stores: {
|
|
8108
9274
|
episodic: createEpisodicStorePre({ config: { minSegments: Number(engine.config.episodicMinSegments) || 2, retention: Number(engine.config.episodicRetention) || 256 }, io: hubIo('episodes.json') }),
|
|
8109
|
-
facts:
|
|
9275
|
+
facts: factStore,
|
|
8110
9276
|
procedures: createProcedureStorePre({
|
|
8111
9277
|
// 2026-08-30 修复:gates 用 getter 活读 engine.config——挂载(apply 同步)时
|
|
8112
9278
|
// engine.config 尚未 loadConfig,静态读会冻结 DEFAULT(3/2),设置页改门槛永不生效
|
|
@@ -8114,7 +9280,7 @@ export function apply(ctx, config) {
|
|
|
8114
9280
|
// 0 值合法(验证用);仅非有限负值回退默认——不可用 || 0 会 falsy 穿透
|
|
8115
9281
|
get minSessionDiversity() { const v = Number(engine.config.procedureMinSessions); return (Number.isFinite(v) && v >= 0) ? v : 3 },
|
|
8116
9282
|
get minSuccessCount() { const v = Number(engine.config.procedureMinSuccess); return (Number.isFinite(v) && v >= 0) ? v : 2 },
|
|
8117
|
-
get maxCorrectionRate() {
|
|
9283
|
+
get maxCorrectionRate() { const v = Number(engine.config.procedureCorrectionCap); return (Number.isFinite(v) && v >= 0) ? v : 0.3 },
|
|
8118
9284
|
get maxContradictions() { return 0 },
|
|
8119
9285
|
get highRiskRequiresApproval() { return engine.config.procedureHighRiskApproval !== false },
|
|
8120
9286
|
},
|
|
@@ -8122,6 +9288,10 @@ export function apply(ctx, config) {
|
|
|
8122
9288
|
io: hubIo('procedures.json'),
|
|
8123
9289
|
}),
|
|
8124
9290
|
},
|
|
9291
|
+
// ★T10:机械 procedure 切片开关(默认 false)。
|
|
9292
|
+
// 用 getter 活读 engine.config(与上面 gates 同理:挂载时 config 尚未 loadConfig,
|
|
9293
|
+
// 静态读会冻结 DEFAULT 值,导致设置页改了不生效)。
|
|
9294
|
+
get mechanicalProcedureFeedEnabled() { return engine.config.hubMechanicalProcedureFeedEnabled === true },
|
|
8125
9295
|
})
|
|
8126
9296
|
try {
|
|
8127
9297
|
// restore 逐条校验,坏记录跳过(fail closed 幂等恢复);无文件/损坏 → 空启动
|
|
@@ -8165,7 +9335,17 @@ export function apply(ctx, config) {
|
|
|
8165
9335
|
try { const d = JSON.parse(readFileSync(hubFlushFile(), 'utf8')); if (d && typeof d === 'object') { hubFlushState.date = String(d.date || ''); hubFlushState.count = Number(d.count) || 0; hubFlushState.flushed = d.flushed || {} } } catch (_) {}
|
|
8166
9336
|
}
|
|
8167
9337
|
const hubFlushSave = () => {
|
|
8168
|
-
|
|
9338
|
+
// ★ T2-3(2026-09-19):改**原子写**(tmp + rename)。
|
|
9339
|
+
// 原实现是裸 `writeFileSync` —— 无锁、无 tmp+rename ⇒ 并发/中断时可能写坏,
|
|
9340
|
+
// 而写坏后 `hubFlushLoad` 静默吞掉异常 ⇒ `flushed` 回退到旧值 ⇒ **已写过的 fact 会被再写一遍**
|
|
9341
|
+
// (正文出现两个同名 `(M8 固化)` 段落)。rename 在同目录内是原子替换。
|
|
9342
|
+
try {
|
|
9343
|
+
const f = hubFlushFile()
|
|
9344
|
+
mkdirSync(path.dirname(f), { recursive: true })
|
|
9345
|
+
const tmp = f + '.tmp'
|
|
9346
|
+
writeFileSync(tmp, JSON.stringify({ date: hubFlushState.date, count: hubFlushState.count, flushed: hubFlushState.flushed }), 'utf8')
|
|
9347
|
+
renameSync(tmp, f)
|
|
9348
|
+
} catch (_) {}
|
|
8169
9349
|
}
|
|
8170
9350
|
hubFlushLoad()
|
|
8171
9351
|
// 语料查询器(heading 富化用):与 context-host 同源 sidecar 目录,懒建缓存
|
|
@@ -8207,20 +9387,33 @@ export function apply(ctx, config) {
|
|
|
8207
9387
|
// 异步富化+喂送(不阻塞 tick);单行失败静默
|
|
8208
9388
|
void (async () => {
|
|
8209
9389
|
let fed = 0
|
|
8210
|
-
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8217
|
-
|
|
8218
|
-
|
|
9390
|
+
// ★#110(2026-09-22):批内合并落盘 —— 旧实现每行一次 upsert ⇒ **一次整份快照写盘**,
|
|
9391
|
+
// 一批 N 行就是 N 次写放大。这里用 hubIo 的批控制把整批收敛成「批末最多一次/文件」。
|
|
9392
|
+
// 批末一定 flush(finally),任何一行抛错也不会把已接受的改动留在内存不落盘。
|
|
9393
|
+
const ioFactory = engine._hubIoFactory
|
|
9394
|
+
try {
|
|
9395
|
+
if (ioFactory && typeof ioFactory.beginBatch === 'function') ioFactory.beginBatch()
|
|
9396
|
+
for (const row of newRows) {
|
|
9397
|
+
try {
|
|
9398
|
+
const src = Array.isArray(row.sourceIds) ? row.sourceIds[0] : null
|
|
9399
|
+
if (src) {
|
|
9400
|
+
const rec = await hubCorpusLookup(src)
|
|
9401
|
+
if (rec) {
|
|
9402
|
+
row.subject = String(rec.heading || rec.text || '').split('\n')[0].slice(0, 30)
|
|
9403
|
+
row.predicate = '记录要点'
|
|
9404
|
+
row.object = String(rec.text || '').slice(0, 120)
|
|
9405
|
+
}
|
|
8219
9406
|
}
|
|
8220
|
-
|
|
8221
|
-
|
|
8222
|
-
|
|
8223
|
-
}
|
|
9407
|
+
const r = hub.ingestJudgement(row)
|
|
9408
|
+
if (r && r.consumed) fed++
|
|
9409
|
+
} catch (_) {}
|
|
9410
|
+
}
|
|
9411
|
+
} finally {
|
|
9412
|
+
if (ioFactory && typeof ioFactory.endBatch === 'function') {
|
|
9413
|
+
const b = ioFactory.endBatch()
|
|
9414
|
+
if (b && b.written) diag('hub feed batch: ' + b.written + ' 份快照批量落盘(合并 ' + newRows.length + ' 行喂入)')
|
|
9415
|
+
if (b && b.ok === false) diag('hub feed batch: 有快照落盘失败 —— 原因见 hubIo 健康度(debugInfo().associativeMemory.hubIo)')
|
|
9416
|
+
}
|
|
8224
9417
|
}
|
|
8225
9418
|
if (fed) diag('hub feed: +' + fed + '/' + newRows.length + ' rows consumed')
|
|
8226
9419
|
})()
|
|
@@ -8245,31 +9438,100 @@ export function apply(ctx, config) {
|
|
|
8245
9438
|
if (typeof fact.confidence === 'number' && fact.confidence < 0.6) continue
|
|
8246
9439
|
const subj = String(fact.subject || '').trim()
|
|
8247
9440
|
if (!subj || subj.startsWith('mem_')) continue // 无富化的 memoryId 主语不入正文
|
|
9441
|
+
// ★ T1-3(2026-09-19 真机追加):**写入前内容卫生门**。
|
|
9442
|
+
// 背景:本通路的 6 道过滤全是「结构性」检查,**没有一道是内容卫生** —— 实测脏 fact
|
|
9443
|
+
// 已被写入正文并归档(`archive/notes-archived.md:493` 的 `## DSH ������(M8 固化)`)。
|
|
9444
|
+
// 判据复用 ⑨ 同源清洗器(F3 召回块标记 / F4 U+FFFD 编码损坏)。
|
|
9445
|
+
// ★ 处置选择「**脏则 skip + 留痕**」,**不做「清洗后照写」**:
|
|
9446
|
+
// 本仓纪律是 fail-soft 必须留痕、不得静默改写;清洗后照写等于悄悄改用户数据,
|
|
9447
|
+
// 且会丢失「曾经出现过脏 fact」这一诊断事实。
|
|
9448
|
+
const objRaw = fact.object ? String(fact.object) : ''
|
|
9449
|
+
const predRaw = String(fact.predicate || '要点')
|
|
9450
|
+
const cSubj = stripRuntimeIntentPre(subj).trim()
|
|
9451
|
+
const cObj = stripRuntimeIntentPre(objRaw).trim()
|
|
9452
|
+
const cPred = stripRuntimeIntentPre(predRaw).trim()
|
|
9453
|
+
const dirty = (cSubj !== subj) || (cObj !== objRaw.trim()) || (cPred !== predRaw.trim())
|
|
9454
|
+
// ★ F5(行内残留):清洗器按行判断,**真人与信封同一行**时整行保留(删了丢人话)
|
|
9455
|
+
// ⇒ 「清洗后是否变化」对这种形态无效。故再补一道行内残留检测:
|
|
9456
|
+
// 实测真机 fact[1] 的 object 就是 `现在是什么情况? Current DSH file policy: …`。
|
|
9457
|
+
|| looksRuntimeResiduePre(subj) || looksRuntimeResiduePre(objRaw) || looksRuntimeResiduePre(predRaw)
|
|
9458
|
+
if (dirty) {
|
|
9459
|
+
hubFlushState.flushed[fact.factId] = true // 标记为已处理,避免每轮重扫
|
|
9460
|
+
try { diag('hub flush skip(hygiene): fact ' + String(fact.factId).slice(0, 16)) } catch (_) {}
|
|
9461
|
+
try {
|
|
9462
|
+
if (engine._degradePre && typeof engine._degradePre.record === 'function') {
|
|
9463
|
+
engine._degradePre.record('hub-flush', 'dirty-fact-skipped:' + String(fact.factId).slice(0, 16))
|
|
9464
|
+
}
|
|
9465
|
+
} catch (_) {}
|
|
9466
|
+
continue
|
|
9467
|
+
}
|
|
8248
9468
|
try {
|
|
8249
9469
|
if (!p) p = await engine.resolvePaths(engine.currentRuntime().agent)
|
|
8250
9470
|
const target = fact.scope === 'User' ? p.userFile : p.notesPath
|
|
8251
9471
|
if (!target) continue
|
|
8252
9472
|
const cur = await engine.readTextSafe(target)
|
|
8253
|
-
if (cur && cur.includes(subj)) {
|
|
8254
|
-
|
|
9473
|
+
if (cur && cur.includes(subj)) {
|
|
9474
|
+
// ★ T2-1(2026-09-19):**标记与计数必须自洽**。
|
|
9475
|
+
// 原实现只 `flushed[...] = true` 而不 `count++` ⇒ 「已处理条数」被系统性低估,
|
|
9476
|
+
// 「今日写了几条」与「标记了几条」长期对不上(实测 count=0 / flushed=4 即此现象)。
|
|
9477
|
+
// 注意:此分支**并未真正写入正文**,故不计入写额度(count),但必须计入「已处理」标记 ——
|
|
9478
|
+
// 两者语义不同,此处显式留痕以便诊断区分(不写正文不计额度是正确行为,问题只在于原先完全静默)。
|
|
9479
|
+
hubFlushState.flushed[fact.factId] = true
|
|
9480
|
+
try { diag('hub flush skip(already-in-body): fact ' + String(fact.factId).slice(0, 16)) } catch (_) {}
|
|
9481
|
+
continue
|
|
9482
|
+
}
|
|
9483
|
+
// ★ T1-4:换行归一化 —— `## <subj>(M8 固化)` 与 `- <pred>:<obj>` 都是**单行**模板,
|
|
9484
|
+
// 若内容含 `\n` 可**伪造出新的 `## ` 标题**,并被 `compactLegacyLayer`
|
|
9485
|
+
// (`:4796` `^##\s+(.+)$`)当成独立段落搬运。此处把残存换行压成空格。
|
|
9486
|
+
const subj1 = cSubj.replace(/\s*[\r\n]+\s*/g, ' ').trim()
|
|
9487
|
+
const pred1 = cPred.replace(/\s*[\r\n]+\s*/g, ' ').trim()
|
|
9488
|
+
const obj1 = cObj.replace(/\s*[\r\n]+\s*/g, ' ').trim()
|
|
9489
|
+
const body = '\n## ' + subj1 + '(M8 固化)\n- ' + pred1 + (obj1 ? ':' + obj1 : '') + '\n- 来源:记忆中枢治理固化' + (typeof fact.confidence === 'number' ? '(confidence=' + fact.confidence.toFixed(2) + ')' : '')
|
|
8255
9490
|
const written = await engine.appendText(target, body)
|
|
8256
9491
|
try { if (engine.state) { if (fact.scope === 'User') engine.state.userText = written; else engine.state.notesText = written } } catch (_) {}
|
|
8257
9492
|
hubFlushState.flushed[fact.factId] = true
|
|
8258
9493
|
hubFlushState.count++
|
|
8259
9494
|
diag('hub flush: fact ' + String(fact.factId).slice(0, 16) + ' → ' + (fact.scope === 'User' ? 'user' : 'notes'))
|
|
8260
|
-
} catch (
|
|
9495
|
+
} catch (eFlush) {
|
|
9496
|
+
// ★ T1-5:**失败必须留痕**。原实现是 `catch (_) {}` 全吞 ⇒ 成功有 diag、失败零留痕,
|
|
9497
|
+
// 「写了但没成功」完全不可观测(面板 overview() 也不含 flush 字段)。
|
|
9498
|
+
// 对照本仓既有正确做法:`:5853` note-status 路径有 `_degradePre.record`。
|
|
9499
|
+
try { diag('hub flush FAIL: fact ' + String(fact.factId).slice(0, 16) + ' → ' + String((eFlush && eFlush.message) || eFlush)) } catch (_) {}
|
|
9500
|
+
try {
|
|
9501
|
+
if (engine._degradePre && typeof engine._degradePre.record === 'function') {
|
|
9502
|
+
engine._degradePre.record('hub-flush', 'append-failed:' + String(fact.factId).slice(0, 16) + ':' + String((eFlush && eFlush.message) || eFlush).slice(0, 80))
|
|
9503
|
+
}
|
|
9504
|
+
} catch (_) {}
|
|
9505
|
+
}
|
|
8261
9506
|
}
|
|
8262
9507
|
hubFlushSave()
|
|
8263
9508
|
} catch (_) {}
|
|
8264
9509
|
}
|
|
9510
|
+
// ★ T2-4(2026-09-19):**重入保护**。原实现 `setInterval(() => { void hubFlushTick() }, …)`
|
|
9511
|
+
// 只吞 Promise、无 in-flight 标志 ⇒ 若某次 tick 因 async IO 超过 30 分钟,下一次会并发进入,
|
|
9512
|
+
// 而 `hubFlushState` 是共享可变状态(读 flushed → 写 flushed/count)⇒ 竞态下「两条并发各读到 count=7」
|
|
9513
|
+
// 会写出超限条数。此处用单标志串行化:进行中则直接跳过本轮(不排队,避免堆积)。
|
|
9514
|
+
let hubFlushInFlight = false
|
|
9515
|
+
const hubFlushTickGuarded = async () => {
|
|
9516
|
+
if (hubFlushInFlight) {
|
|
9517
|
+
try { diag('hub flush skip(in-flight)') } catch (_) {}
|
|
9518
|
+
return
|
|
9519
|
+
}
|
|
9520
|
+
hubFlushInFlight = true
|
|
9521
|
+
try { await hubFlushTick() } finally { hubFlushInFlight = false }
|
|
9522
|
+
}
|
|
8265
9523
|
const hubFeedTimer = setInterval(hubFeedTick, 60 * 1000)
|
|
8266
|
-
const hubFlushTimer = setInterval(() => { void
|
|
9524
|
+
const hubFlushTimer = setInterval(() => { void hubFlushTickGuarded() }, 30 * 60 * 1000)
|
|
8267
9525
|
// unref:定时器不阻止进程退出(测试 settle 不经过 apply 的 disposer 链会挂住)
|
|
8268
9526
|
hubFeedTimer.unref(); hubFlushTimer.unref()
|
|
8269
|
-
const hubBootTimer = setTimeout(() => { hubFeedTick(); void
|
|
9527
|
+
const hubBootTimer = setTimeout(() => { hubFeedTick(); void hubFlushTickGuarded() }, 90 * 1000)
|
|
8270
9528
|
hubBootTimer.unref()
|
|
8271
9529
|
if (!engine._hubFeedDisposers) engine._hubFeedDisposers = []
|
|
8272
|
-
|
|
9530
|
+
// ★ T2-2(2026-09-19):**补 clearTimeout(hubBootTimer)**。
|
|
9531
|
+
// 原 disposer 只 clear 两个 interval,漏了 boot timer ⇒ 若在启动后 90s 内 dispose,
|
|
9532
|
+
// boot 回调仍会在 dispose **之后**触发一次(写入侧 dispose 已跑)。虽然 `:8958` 的门控仍在,
|
|
9533
|
+
// 但「dispose 后仍执行回调」本身违反 disposer 契约,必须补上。
|
|
9534
|
+
engine._hubFeedDisposers.push(() => { clearInterval(hubFeedTimer); clearInterval(hubFlushTimer); clearTimeout(hubBootTimer) })
|
|
8273
9535
|
}
|
|
8274
9536
|
// C2 内置语义引擎宿主(2026-08-26 用户裁定:C2=默认主路径)。懒加载 e5-small q8;
|
|
8275
9537
|
// 只做检索排序,激活决策仍属两车道策略。下载器落位=发行包布局 lib/models。
|
|
@@ -8277,7 +9539,8 @@ export function apply(ctx, config) {
|
|
|
8277
9539
|
const pluginDir = path.dirname(fileURLToPath(import.meta.url))
|
|
8278
9540
|
engine._jsSemantic = createJsSemanticEnginePre({ pluginDir })
|
|
8279
9541
|
// #15 后续/B:JS 模型下载落位用户目录(~/.dsh/models/js-semantic/)——包目录在 npm 更新时被重装,130MB 曾被冲掉
|
|
8280
|
-
|
|
9542
|
+
// ★#86-3:统一口径(原为内联三元,与 dshHome() 重复实现)。
|
|
9543
|
+
const userModelsRoot = path.join(resolveDshHomePre(), 'models', 'js-semantic')
|
|
8281
9544
|
mkdirSync(userModelsRoot, { recursive: true })
|
|
8282
9545
|
engine._jsDownload = createSemanticDownloaderPre({ modelsRoot: userModelsRoot })
|
|
8283
9546
|
// ─────────── 三层检索契约 C3(2026-09-14):L0 向量索引接线 ───────────
|
|
@@ -8333,7 +9596,9 @@ export function apply(ctx, config) {
|
|
|
8333
9596
|
try { for (const lg of await engine.listDailyLogs(p.projectDir, 14)) push('log', path.join(p.projectDir, lg && lg.name ? lg.name : '')) } catch (_) {}
|
|
8334
9597
|
return await engine._l0IndexSync.sync({ enabled: true, workspaceKey: p.ws || p.projectDir || '', dir: engine.l0IndexDir(), sources })
|
|
8335
9598
|
} catch (e) {
|
|
9599
|
+
// R3:预期外失败 —— 索引同步失败会让检索长期用陈旧索引,值得留痕。
|
|
8336
9600
|
try { diag('l0-index sync 降级: ' + String((e && e.message) || e).slice(0, 120)) } catch (_) {}
|
|
9601
|
+
try { if (engine._degradeSink) engine._degradeSink.record('l0-sync', String((e && e.message) || e).slice(0, 160)) } catch (_) {}
|
|
8337
9602
|
return { ok: false, reason: 'error', enabled: true, written: 0, count: 0, files: [] }
|
|
8338
9603
|
}
|
|
8339
9604
|
}
|
|
@@ -8352,7 +9617,18 @@ export function apply(ctx, config) {
|
|
|
8352
9617
|
// probeJsSemanticAssets —— peer 探测与引擎加载走同一套 Node 解析(2026-09-02 issue 修正)。
|
|
8353
9618
|
// _peerExtraDirs = 深度扫描热接入位(semanticDeepDetect 命中后 probe/加载/档位即时生效)。
|
|
8354
9619
|
engine._peerExtraDirs = []
|
|
8355
|
-
|
|
9620
|
+
// ★ issue #70 修复(2026-09-19):把引擎的**运行期降级状态**注入探测结果。
|
|
9621
|
+
// 旧实现只回 `ready = assetPresent && peerPresent`(纯文件存在性)⇒ 引擎一旦 degrade
|
|
9622
|
+
// (onnx 损坏 / 维度不符 / peer 加载失败),引导卡仍显示「✓ 就绪」、`resolveSemanticTier`
|
|
9623
|
+
// 仍给 c2,而每次检索都在静默词法兜底 —— 三条用户可见路径与真实可用性脱钩。
|
|
9624
|
+
// 此处是**唯一接点**:`semantic-status`、`resolveSemanticTier`(:9148)、引导卡(:10000) 全走它。
|
|
9625
|
+
engine.semanticAssetProbe = async () => probeJsSemanticAssets(
|
|
9626
|
+
pluginDir,
|
|
9627
|
+
engine._peerExtraDirs,
|
|
9628
|
+
(engine._jsSemantic && typeof engine._jsSemantic.status === 'function')
|
|
9629
|
+
? (engine._jsSemantic.status() || {}).degraded
|
|
9630
|
+
: '',
|
|
9631
|
+
)
|
|
8356
9632
|
// 打开即自动检测(0.1.37,#14 后续):快检 → 模型在场但推理库缺失时深度扫描
|
|
8357
9633
|
// (~/.dsh/profiles/* 全家 + pnpm 虚拟存储) → 命中即热接入(probe/加载双注入,无需重启),
|
|
8358
9634
|
// 并给出 recommendation 供引导卡分流(none/setup-both/download-model/install-peer)。
|
|
@@ -8420,11 +9696,28 @@ export function apply(ctx, config) {
|
|
|
8420
9696
|
try { return (await engine.semanticAssetProbe()).ready ? 'c2' : 'c1' } catch (_) { return 'c1' }
|
|
8421
9697
|
}
|
|
8422
9698
|
// context-host refs 选择钩子:C2 就绪时返回 {scores:Map};任何失败回退词法序。
|
|
9699
|
+
// ★R4-留痕(2026-09-18):原实现的 `catch (_) { return null }` 是**静默的** ——
|
|
9700
|
+
// 它把两类完全不同的情形压成同一个 null:
|
|
9701
|
+
// ① `tier !== 'c2'`(C2 资产未就绪)—— **预期内**,用户就是没装语义模型;
|
|
9702
|
+
// ② `_jsSemantic.rank` **抛错**(模型损坏/内存不足/代码缺陷)—— **预期外**,能力在运行时失效。
|
|
9703
|
+
// 二者不可区分 ⇒ 用户只感到"语义唤回不太灵",日志里什么都没有(这正是用户报的
|
|
9704
|
+
// 「静默失效困扰我一些时间了」)。现在:① 保持静默(不刷屏),② 记台账 + 留 tier 痕迹。
|
|
9705
|
+
// 铁律遵守:JS 与 Python 两套引擎仍**互不依赖**,此处只观测 JS 这一套自身的状态。
|
|
9706
|
+
engine._jsRankTier = ''
|
|
9707
|
+
engine._jsRankError = ''
|
|
8423
9708
|
engine._jsSemanticRank = async (corpusSnap, queryText) => {
|
|
8424
9709
|
try {
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
9710
|
+
const tier = await engine.resolveSemanticTier()
|
|
9711
|
+
engine._jsRankTier = String(tier || '')
|
|
9712
|
+
if (tier !== 'c2') return null
|
|
9713
|
+
const r = await engine._jsSemantic.rank(corpusSnap, queryText)
|
|
9714
|
+
engine._jsRankError = ''
|
|
9715
|
+
return r
|
|
9716
|
+
} catch (eJs) {
|
|
9717
|
+
engine._jsRankError = String((eJs && eJs.message) || eJs).slice(0, 140)
|
|
9718
|
+
try { if (engine._degradeSink) engine._degradeSink.record('semantic-arm', 'js 语义引擎抛错 → 该臂失效: ' + engine._jsRankError) } catch (_) {}
|
|
9719
|
+
return null
|
|
9720
|
+
}
|
|
8428
9721
|
}
|
|
8429
9722
|
// P13(2026-09-09):C3(python)语义臂 —— 经 sidecar recall_rank 调 worker.dense_search
|
|
8430
9723
|
// (三重过滤:workspaceRef+scope+miv),返回 {scores:Map<memoryId,score>, source:'c3'}。
|
|
@@ -8474,14 +9767,37 @@ export function apply(ctx, config) {
|
|
|
8474
9767
|
}
|
|
8475
9768
|
// P13 择优:lexical → null;python 档 → _pySemanticRank;auto → 先 py(模型就绪,失败回 C2);js/C2 → _jsSemanticRank。
|
|
8476
9769
|
// 逐级 fail-soft,任何一级失败自动落到下一级,绝不抛错阻塞检索。_jsSemanticRank 保留不动(激活路径仍在用)。
|
|
9770
|
+
//
|
|
9771
|
+
// ★R4-留痕(2026-09-18):三级降级链 `py → C2 → 词法` **每一跳都留痕**。
|
|
9772
|
+
// 旧实现只有最外层一个 catch 写台账 ⇒ 只有"抛错"这一种失败可见;而 `_pySemanticRank`
|
|
9773
|
+
// 失败的**常见形态是返回 null**(worker 拒绝 / 超时 / 空 scores / 三重过滤不匹配),
|
|
9774
|
+
// 它静默落回 C2 ⇒ 用户完全看不出 C3 档没在工作(正是用户报的「静默失效」)。
|
|
9775
|
+
// 现在分两层留痕,遵循 R3 判据(预期内静默、预期外记台账,绝不刷屏):
|
|
9776
|
+
// · **过程状态** `engine._rankPath` = 本轮降级链快照,成功也写(诊断一眼看全走到哪一跳)
|
|
9777
|
+
// · **degrade 台账** 只记预期外失败(该跳**抛错**)—— "返回 null" 属合法回退,不记
|
|
9778
|
+
engine._rankPath = ''
|
|
8477
9779
|
engine._semanticRankBest = async (corpusSnap, queryText) => {
|
|
9780
|
+
const hops = []
|
|
8478
9781
|
try {
|
|
8479
9782
|
const mode = String(engine.config.semanticEngineMode || 'auto')
|
|
8480
|
-
if (mode === 'lexical') return null
|
|
9783
|
+
if (mode === 'lexical') { engine._rankPath = '配置 lexical(无语义臂)'; return null }
|
|
8481
9784
|
const r = await engine._pySemanticRank(corpusSnap, queryText)
|
|
8482
|
-
if (r) return r
|
|
8483
|
-
|
|
8484
|
-
|
|
9785
|
+
if (r) { engine._rankPath = 'c3(python)'; return r }
|
|
9786
|
+
hops.push('c3 无结果')
|
|
9787
|
+
} catch (ePyBest) {
|
|
9788
|
+
hops.push('c3 抛错')
|
|
9789
|
+
try { if (engine._degradeSink) engine._degradeSink.record('semantic-arm', 'c3(python) 跳抛错 → 落 C2: ' + String((ePyBest && ePyBest.message) || ePyBest).slice(0, 120)) } catch (_) {}
|
|
9790
|
+
}
|
|
9791
|
+
try {
|
|
9792
|
+
const r2 = await engine._jsSemanticRank(corpusSnap, queryText)
|
|
9793
|
+
if (r2) { engine._rankPath = hops.join(' → ') + ' → c2(js)'; return r2 }
|
|
9794
|
+
hops.push('c2 无结果')
|
|
9795
|
+
} catch (eJsBest) {
|
|
9796
|
+
hops.push('c2 抛错')
|
|
9797
|
+
try { if (engine._degradeSink) engine._degradeSink.record('semantic-arm', 'c2(js) 跳抛错 → 落词法: ' + String((eJsBest && eJsBest.message) || eJsBest).slice(0, 120)) } catch (_) {}
|
|
9798
|
+
}
|
|
9799
|
+
engine._rankPath = hops.join(' → ') + ' → 词法(无语义臂)'
|
|
9800
|
+
return null
|
|
8485
9801
|
}
|
|
8486
9802
|
// JS 端判定核(2026-08-27):读策略工件(懒加载+缓存),对 C2 检索结果做 fv2 决策。
|
|
8487
9803
|
// 完全独立于 Python——JS 端默认闭环(C2 检索 + JS 判定 + M6 投递)的核心。
|
|
@@ -8580,21 +9896,15 @@ export function apply(ctx, config) {
|
|
|
8580
9896
|
// 默认关闭=零 Python process、零协议 IO、零 semantic 目录)
|
|
8581
9897
|
// M7.6 Python 一键向导(#16-#20 配套):detect/venv/deps/model 四步,落盘用户目录,不碰 npm 包目录
|
|
8582
9898
|
engine._pythonSetup = createPythonSetupPre({
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
if (env && env.trim()) return env.trim()
|
|
8586
|
-
try { return path.join(homedir(), '.dsh') } catch (_) { return homedir() }
|
|
8587
|
-
},
|
|
9899
|
+
// ★#86-3:统一口径(原回落链失败时返回 homedir() 本身,**丢掉 .dsh 后缀**)。
|
|
9900
|
+
dshHome: () => resolveDshHomePre(),
|
|
8588
9901
|
diag: (m) => diag('python-setup: ' + m),
|
|
8589
9902
|
})
|
|
8590
9903
|
engine._pythonSidecar = createPythonSidecarClientPre({
|
|
8591
9904
|
command: () => String(engine.config.pythonBackendExecutable || '').trim() || 'python',
|
|
8592
9905
|
scriptPath: () => String(engine.config.pythonBackendWorkerPath || '').trim() || defaultWorkerScriptPathPre(),
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
if (env && env.trim()) return env.trim()
|
|
8596
|
-
try { return path.join(homedir(), '.dsh') } catch (_) { return '' }
|
|
8597
|
-
},
|
|
9906
|
+
// ★#86-3:统一口径(原回落链失败时返回**空串** ⇒ 调用方拼出相对路径)。
|
|
9907
|
+
dshHome: () => resolveDshHomePre(),
|
|
8598
9908
|
})
|
|
8599
9909
|
engine.__homedirFn = homedir
|
|
8600
9910
|
const sessionQuery = ctx.get('sessionQuery')
|
|
@@ -8722,9 +10032,11 @@ export function apply(ctx, config) {
|
|
|
8722
10032
|
try { engine.checkWaterLevelAtStep(agent) } catch (eWL) {}
|
|
8723
10033
|
// M6-3:pre-step 时序(§8)——校验 cursor/index/TTL 后 claim packet,等待渲染面消费
|
|
8724
10034
|
try { if (engine._activationHost) engine._activationHost.onPreStep(agent) } catch (_) {}
|
|
8725
|
-
//
|
|
10035
|
+
// 只刷新**用户会话**:子代理(subagent)session 无 cwd,刷新会把 state 切到错误工作区。
|
|
10036
|
+
// 2026-09-21 修: 原判据「有 parentSession 就跳」会把接续会话一并跳过 ⇒ 接续会话的
|
|
10037
|
+
// 工作区状态永远不刷新。改用 isSubAgentSession() 精确排除子代理(接续会话有 cwd,可安全刷新)。
|
|
8726
10038
|
let skip = false
|
|
8727
|
-
try { if (agent
|
|
10039
|
+
try { if (isSubAgentSession(agent)) skip = true } catch (e) {}
|
|
8728
10040
|
const st = engine.stateFor(agent)
|
|
8729
10041
|
if (!skip && (!st.loadedAt || Date.now() - st.loadedAt > 15000)) {
|
|
8730
10042
|
// ★2026-09-15(修「注入头工作区 (未知)」· 病因 C):**必须包进 withAgent**。
|
|
@@ -8913,9 +10225,9 @@ export function apply(ctx, config) {
|
|
|
8913
10225
|
|
|
8914
10226
|
// ---------- 工具 ----------
|
|
8915
10227
|
const tools = [
|
|
8916
|
-
defineTool('memory_log', '向当前工作区的 .dsh-memory/ 今日日志追加一条工作记录(append-only,自动建目录/文件)。完成实质性工作(改代码/修 bug/写文档/重构/技术选型/用户偏好约定)后必须调用;有跨会话长期价值的内容在同一轮内一并写入记忆(memory_note 项目/ memory_user 跨项目),progress 与 memory
|
|
10228
|
+
defineTool('memory_log', '向当前工作区的 .dsh-memory/ 今日日志追加一条工作记录(append-only,自动建目录/文件)。完成实质性工作(改代码/修 bug/写文档/重构/技术选型/用户偏好约定)后必须调用;有跨会话长期价值的内容在同一轮内一并写入记忆(memory_note 项目/ memory_user 跨项目),progress 与 memory 一起写;不要记录临时信息。**★顺手维护白板(G4/M3,2026-09-17)**:若本次工作让**白板或账本所述与现状不符**(方向变了/阶段完成/旧结论被推翻),请在同一次回复里顺带调用 `memory_note`:项目稳定事实变了用 `kind=plan` 重写白板(旧版自动归档),动态状态变了用 `kind=handoff` 新开一篇账本(append-only,不追改旧账本)。**这是条件触发,不是每回都做**;白板功能关闭时跳过即可,不要因此报错。**调用后必须在本轮回复正文(摘要可见的正文,不是工具调用区)中向用户转述一句:如"已把 X 记入今日日志"**。', {
|
|
8917
10229
|
note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
|
|
8918
|
-
date: { type: 'string', description: '日志日期 YYYY-MM-DD
|
|
10230
|
+
date: { type: 'string', description: '日志日期 YYYY-MM-DD,**缺省今天**。★可补写过去某天(如补记昨天的工作)——会写进那一天的文件,不会覆盖同文件已有内容(append-only)。格式非法时静默回退到今天。' },
|
|
8919
10231
|
kind: { type: 'string', enum: ['rule', 'preference', 'fact', 'todo'], description: '★P6B:条目性质标记(缺省 fact)。rule=用户规则/约定;preference=偏好;todo=待办;fact=事实记录。是规则或用户明确约定的条目请传 kind=rule——它会被规则层识别为必须遵守的约束。纯标记、零额外 LLM 调用。' },
|
|
8920
10232
|
}, async (args, exec) => {
|
|
8921
10233
|
const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
|
|
@@ -8938,10 +10250,18 @@ export function apply(ctx, config) {
|
|
|
8938
10250
|
return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
|
|
8939
10251
|
}),
|
|
8940
10252
|
|
|
8941
|
-
defineTool('memory_note', '更新当前项目长期笔记 .dsh-memory/MEMORY.md(本项目专属的约定、决策、架构要点),或写交接白板。kind=note(默认):action=append 追加一段(自动带日期标题)/action=replace 整体替换(需先基于注入内容或 memory_recall 结果给出完整新内容),本项目笔记有**容量上限**(字符,设置项 noteCapacityChars,默认
|
|
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:整体重写白板 PLAN.md(人能读的项目全貌规划图),对项目全貌的理解发生实质变化时用,旧版自动归档。**结论失效时的两个通道(不要混用)**:supersedes=被更新结论取代(有后继);retract=**当时就做错了、直接撤回**(无后继,本身即教训)。**调用后必须在本轮回复正文中向用户转述:更新了什么**。', {
|
|
8942
10254
|
content: { type: 'string', required: true, description: '笔记内容。kind=plan 时给完整新全貌(不是增量)。' },
|
|
8943
10255
|
action: { type: 'string', enum: ['append', 'replace'], description: '仅 kind=note 时有效:append=追加, replace=整体替换。' },
|
|
8944
10256
|
kind: { type: 'string', enum: ['note', 'handoff', 'plan'], description: 'note=项目笔记(默认), handoff=四段式交接账本(新篇), plan=白板全貌重写。' },
|
|
10257
|
+
// ★G3(2026-09-19):结论层状态写入 —— **显式可选参数**,不传时行为与从前逐字节相同(零自动行为、零误判)。
|
|
10258
|
+
// 设计依据:用户 2026-09-18 裁定「只认显式声明」(门槛 = 结构化参数 = 最强的显式)。
|
|
10259
|
+
// 为什么不做"自动比对同主题旧条目":误判代价不对称(漏判=维持现状;误判=有效结论被标作废)。
|
|
10260
|
+
supersedes: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 superseded 的旧条目 memoryId 列表(mem_<32hex>)。**仅在你明确知道被取代的是哪条时传**;不确定就不要传。会在旧条目正文末尾追加一行 `<!-- dsh-status: superseded by=<新条目id> -->`。' },
|
|
10261
|
+
// ★T6(2026-09-20 用户拍板):**retracted 通道** —— 与 supersedes 严格分工,别混用。
|
|
10262
|
+
retract: { type: 'array', items: { type: 'string' }, description: '(可选)要标为 **retracted(撤回)** 的旧条目 memoryId 列表(mem_<32hex>)。**与 supersedes 的分工**:supersedes = 被**更新的结论取代**(有后继结论,可追 mem_id);**retract = 当时就做错了、直接撤回**(无后继,"错误本身"就是教训)。用户裁定「retracted 不是垃圾,是教训,不过滤只备注」⇒ 检索仍会返回它并标 ⚠已撤回。**强烈建议同时传 retractReason 说明错在哪**。仅在你确知标错的是哪条时传。' },
|
|
10263
|
+
retractReason: { type: 'string', description: '(可选,配合 retract)撤回原因,一行内说明**错在哪**,上限 120 字符。会写成 `reason="…"` 附在状态行上,供检索时显示。' },
|
|
10264
|
+
restore: { type: 'array', items: { type: 'string' }, description: '(可选)**撤销通道**:把指定 memoryId 的状态改回 current(移除状态行)。用于纠正标错的 superseded/retracted。' },
|
|
8945
10265
|
}, async (args, exec) => {
|
|
8946
10266
|
const p = await engine.resolvePaths(exec.agent)
|
|
8947
10267
|
const content = String(args.content || '').trim()
|
|
@@ -8967,7 +10287,13 @@ export function apply(ctx, config) {
|
|
|
8967
10287
|
else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
|
|
8968
10288
|
return (args.kind === 'plan'
|
|
8969
10289
|
? '白板 PLAN.md 已更新' + (r.archived ? '(旧版已归档: ' + r.archived + ')' : '(首建)') + ': ' + r.path
|
|
8970
|
-
: '交接账本已写入: ' + r.path)
|
|
10290
|
+
: '交接账本已写入: ' + r.path)
|
|
10291
|
+
// ★ 2026-09-21 bugfix(A-2):handoff 此前**静默截断** —— sanitizeForWrite 超限时是
|
|
10292
|
+
// 「截断 + truncated:true」返回 ok(:8625-8628),而本分支接住了 gateH 却从不外显该标志
|
|
10293
|
+
// (对照 note 分支 :10184 会拼提示)。账本四段固定以「进度与下一步」结尾,截断恰好砍掉它,
|
|
10294
|
+
// 模型却收到「已写入」⇒ 跨窗口续命材料无声缩水。现与 note 分支对齐。
|
|
10295
|
+
+ (gateH.truncated ? '\n⚠ 账本超长已截断到 ' + writeH.length + ' 字符(上限 ' + (args.kind === 'plan' ? 200000 : 8000) + '),尾部「进度与下一步」可能已丢失 —— 请精简后重写。' : '')
|
|
10296
|
+
+ '\n请在本轮回复正文向用户转述本次更新要点。'
|
|
8971
10297
|
}
|
|
8972
10298
|
const replace = args.action === 'replace'
|
|
8973
10299
|
// 写闸门: append 单条上限 8000 字; replace(整篇重写)放行到 20 万字, 但同样经受乱码/复读/重复块质量闸门
|
|
@@ -8988,10 +10314,20 @@ export function apply(ctx, config) {
|
|
|
8988
10314
|
body = await engine.appendText(p.notesPath, '\n## ' + engine.memToday() + '\n' + write)
|
|
8989
10315
|
}
|
|
8990
10316
|
engine.state.notesText = body; engine.state.loadedAt = Date.now()
|
|
8991
|
-
|
|
10317
|
+
// ★G3(2026-09-19)结论层状态写入 —— **仅在显式传参时执行**;不传 ⇒ 本段零作用。
|
|
10318
|
+
// 顺序:**先正常写入成功,再改状态**(写入失败就不该动状态,避免"新结论没进去、旧结论却被标废")。
|
|
10319
|
+
// fail-soft:状态应用失败**绝不影响**已成功的笔记写入(照常返回成功,另附一行说明)。
|
|
10320
|
+
let statusNote = ''
|
|
10321
|
+
try {
|
|
10322
|
+
const sup = Array.isArray(args.supersedes) ? args.supersedes.filter((x) => typeof x === 'string') : []
|
|
10323
|
+
const ret = Array.isArray(args.retract) ? args.retract.filter((x) => typeof x === 'string') : []
|
|
10324
|
+
const res = Array.isArray(args.restore) ? args.restore.filter((x) => typeof x === 'string') : []
|
|
10325
|
+
if (sup.length || ret.length || res.length) statusNote = await engine.applyNoteStatusPre(p.notesPath, { supersedes: sup, retract: ret, restore: res, reason: args.retractReason }, body)
|
|
10326
|
+
} catch (e) { statusNote = '\n(状态写入异常,已跳过:' + String((e && e.message) || e) + ')' }
|
|
10327
|
+
return '已更新项目笔记: ' + p.notesPath + '\n追加内容:\n' + write + (gate.truncated ? '\n(内容超长,已截断到 ' + write.length + ' 字符)' : '') + (acct.compacted ? '\n(已自动压缩旧内容腾出空间)' : '') + statusNote
|
|
8992
10328
|
}),
|
|
8993
10329
|
|
|
8994
|
-
defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。有**容量上限**(字符,设置项 userCapacityChars,默认
|
|
10330
|
+
defineTool('memory_user', '更新用户级记忆 ~/.dsh/memory/MEMORY.md(跨所有项目的长期规则/偏好,用户明确要求记住时用)。action=append 追加;action=replace 整体替换。有**容量上限**(字符,设置项 userCapacityChars,默认 24000);**超出时的行为**(你不需要自己控制长度,写就是):①先把较早内容交给 AI 折叠成要点(同一层 10 分钟内只整理一次);②整理失败则**整条原文**归档到 `.dsh-memory/archive/`(信息不丢);③整理后仍超才拒绝(正常不会发生)。**调用后必须在本轮回复正文中向用户转述:已记住该规则/偏好**。', {
|
|
8995
10331
|
content: { type: 'string', required: true, description: '要记住的规则或偏好内容。' },
|
|
8996
10332
|
action: { type: 'string', enum: ['append', 'replace'], required: true, description: 'append=追加, replace=整体替换。' },
|
|
8997
10333
|
}, async (args, exec) => {
|
|
@@ -9048,14 +10384,14 @@ export function apply(ctx, config) {
|
|
|
9048
10384
|
limit: { type: 'integer', description: '最多返回条数,缺省 8。' },
|
|
9049
10385
|
scope: { type: 'string', enum: ['all', 'handoff', 'sessions'], description: '检索范围:all=全部(默认), handoff=交接白板语料(跨窗口续命材料), sessions=历史 DSH 会话。' },
|
|
9050
10386
|
format: { type: 'string', enum: ['l0', 'full'], description: '本地记忆命中格式:l0=L0 摘要列表(默认,每条含 id/得分/匹配原因),full=整条原文(旧行为)。' },
|
|
9051
|
-
expand: { type: 'string', description: '按记忆 id(mem_ + 32 个十六进制字符)
|
|
10387
|
+
expand: { type: 'string', description: '按记忆 id(mem_ + 32 个十六进制字符)展开该条完整原文。★**提供 expand 时 query 只作占位、检索语义被忽略**(仍必填,随便填该 id 即可);这是**两段式用法**:先用默认 l0 拿到候选列表与 id,再对感兴趣的那条 expand 取全文,避免一次性灌入大量原文。' },
|
|
9052
10388
|
}, async (args, exec) => engine.recall(args.query, args.limit, exec.agent, args.scope || 'all', { format: args.format || 'l0', expand: args.expand })),
|
|
9053
10389
|
|
|
9054
10390
|
defineTool('memory_maintain', '维护记忆(30 天蒸馏):把 days(缺省30)天前的 .dsh-memory/ 每日日志交给 AI 蒸馏提炼出有长期价值的要点写入项目 MEMORY.md,原文保底归档到 .dsh-memory/archive/ 后从活跃日志移除。AI 不可用时降级为原样归档,不丢信息。', {
|
|
9055
|
-
days: { type: 'integer', description: '
|
|
10391
|
+
days: { type: 'integer', description: '**归档阈值天数**,缺省 30。语义:把**早于「今天 − days 天」**的每日日志挑出来蒸馏,不是「最近 days 天」。**只影响活跃日志的可见性,不删信息**——要点进 MEMORY.md,原文整份归档到 `.dsh-memory/archive/`。AI 不可用时降级为原样归档。★属**低频维护动作**,不要每轮调。' },
|
|
9056
10392
|
}, async (args, exec) => engine.maintain(args.days, exec.agent)),
|
|
9057
10393
|
|
|
9058
|
-
defineTool('memory_status', '
|
|
10394
|
+
defineTool('memory_status', '查看自动记忆的当前状态:存储位置、各记忆文件大小、今日日志条数、待反思、上次刷新时间。★**什么时候用**:①用户问「记忆系统正常吗/我的记忆存在哪/有多少条」;②怀疑某轮没写进记忆时自查;③新工作区开工前确认路径对不对。★**不该用于**:每轮例行检查(它是只读诊断,不是流程环节);想看记忆**内容**用 memory_read / memory_recall。', {}, async (_args, exec) => {
|
|
9059
10395
|
const snap = await engine.snapshot(exec.agent)
|
|
9060
10396
|
const lines = []
|
|
9061
10397
|
lines.push('工作区: ' + snap.ws)
|
|
@@ -9067,12 +10403,12 @@ export function apply(ctx, config) {
|
|
|
9067
10403
|
return lines.join('\n')
|
|
9068
10404
|
}),
|
|
9069
10405
|
|
|
9070
|
-
defineTool('memory_reflect', '
|
|
10406
|
+
defineTool('memory_reflect', '保存每日反思。★**触发条件严格**:仅在收到框架的「昨日反思待生成」提示、且**已在回复正文中向用户呈现了反思内容之后**才调用——不是你想反思就反思。落盘到 .dsh-memory/reflections/YYYY-MM-DD.md 并标记该日完成(标记后当天不再提示)。date 传**被反思那天的日志日期**(通常是昨天),不是今天。', {
|
|
9071
10407
|
date: { type: 'string', required: true, description: '反思对应的日期 YYYY-MM-DD(即被反思那天的日志日期)。' },
|
|
9072
10408
|
text: { type: 'string', required: true, description: '完整反思内容:成果回顾 / 教训改进 / 今日可延续要点。' },
|
|
9073
10409
|
}, async (args, exec) => engine.saveReflection(args.date, args.text, exec.agent)),
|
|
9074
10410
|
|
|
9075
|
-
defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import
|
|
10411
|
+
defineTool('memory_external', '查看/接入其他 AI 工具(AI 助手/CodeBuddy/Claude Code/Codex/ZCode/Kimi Code/TRAE/项目约定文件)的记忆。action=list 列出全部检测到的外部记忆源(路径/大小/预览/会话数);action=import **以纯链接模式**接入(source 为源 id,target=project 接进项目笔记 / user 接进用户级记忆)。★**「纯链接模式」的含义**:只在你的记忆里写一条**源文件绝对路径指针**,**不把对方内容抄进来**——目的是①防外部脏内容混入、②对方内容会变而指针不会过期。⇒ 需要内容时**按指针路径读取原文件**或记忆里说明的路径,不要去猜。★首次在新工作区工作时先 list,判断该项目是否曾在其他 AI 工具里做过。首次在新工作区工作、或用户提到其他软件里做过的事时调用。', {
|
|
9076
10412
|
action: { type: 'string', enum: ['list', 'import'], required: true, description: 'list=列出外部记忆源; import=接入指定源。' },
|
|
9077
10413
|
source: { type: 'string', description: '要接入的源 id(action=import 时必填,来自 list 结果)。' },
|
|
9078
10414
|
target: { type: 'string', enum: ['project', 'user'], description: '接入目标: project=项目笔记(默认), user=用户级记忆。' },
|
|
@@ -9129,9 +10465,107 @@ export function apply(ctx, config) {
|
|
|
9129
10465
|
title: { type: 'string', required: true, description: '事项标题。' },
|
|
9130
10466
|
}, async (args, exec) => engine.calendarRemove(args.date, args.time, args.title, exec.agent)),
|
|
9131
10467
|
|
|
9132
|
-
defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则)
|
|
10468
|
+
defineTool('memory_consolidate', 'AI 主动维护长期记忆(做梦式固化):读最近 days 天的工作日志,由 AI 发散提炼出有跨会话长期价值的决策/架构/用户偏好,自动写入项目笔记 MEMORY.md(带日期标题)与用户级 MEMORY.md(跨项目规则),并在正文向用户转述固化结果。★**与「自动沉淀」的边界**:每轮对话结束框架会**自动**评估并写日志/升格要点,**你不需要为日常轮次做这件事**;本工具是**你主动发起的加料**——隔一段时间(或一个阶段收尾时)用来做一次更彻底的提炼,读的是**多日日志**、输出进项目笔记与用户级记忆。⇒ 用它做「阶段性固化」,不要用它替代每轮的 memory_log。', {
|
|
9133
10469
|
days: { type: 'integer', description: '读取最近 N 天日志,缺省 7,上限 30。' },
|
|
9134
10470
|
}, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
|
|
10471
|
+
|
|
10472
|
+
// ★★★ T4(2026-09-19 用户拍板「让大模型来介入 procedure memory」)★★★
|
|
10473
|
+
// **工具数 16→17**(无条件注册;四处硬锁联动:smoke-test.mjs / m3b3 / context-observer / graph-mode)。
|
|
10474
|
+
// 背景:此前三条记忆线(episodic / semantic / procedural)**全部只有机械生成**,
|
|
10475
|
+
// 模型没有任何写入通路。procedural 线的唯一来源是 `memory-hub.js` 的 crossFeed:
|
|
10476
|
+
// 它把每个"成功 episode"机械切成候选,而 episode 的 actions 就是 `['user','user','user']`
|
|
10477
|
+
// ⇒ 产出 14 条里 13 条 evidence 全 0 / successCriteria 全 0 / steps 是 "步骤1: user" 占位符。
|
|
10478
|
+
// 清洗器只能删信封文字,**无法把 ['user','user','user'] 变成有价值的流程**(输入本就不含流程信息)。
|
|
10479
|
+
// 本工具给出**模型直写通路**:模型看懂了什么值得复用,就直接写进来。
|
|
10480
|
+
// action='write' → observe()(进审批列表,等人工或后续晋升)
|
|
10481
|
+
// action='activate' → observe → promote(model 授权跳统计门) → activate → **自动导出 SKILL.md**
|
|
10482
|
+
// 即用户原话「如果我这个模型觉得值得上升,那就可以直接上升到这个激活列表」。
|
|
10483
|
+
// 护栏(授权也不放行,见 procedure-store.js promote 注释):observationOnly 短路 /
|
|
10484
|
+
// 必须有 successCriteria / correction 记录阻止。行为全程 fail-soft + diag 留痕。
|
|
10485
|
+
defineTool('memory_procedure', '把一个**值得复用的流程**写进 procedure memory(技能库)——这是模型直写通路,取代此前的机械生成。什么时候用:你刚跑通了一个多步骤流程、踩坑后总结出了正确做法、或发现某个操作值得下次照做时。**判据**:有明确步骤、可重复、下次遇到类似场景能直接照做。action=write 进审批列表(保守,推荐先这样);action=activate 一步到位激活并可被自动召回(仅当你确信它稳定可复用)。写出的条目须含 title + steps,建议一并给 successCriteria(**没有 successCriteria 的条目永远无法晋升**)。', {
|
|
10486
|
+
action: { type: 'string', enum: ['write', 'activate'], description: 'write=写入并进审批列表(默认);activate=写入后直接晋升并激活(可被召回,同时自动导出 SKILL.md)。' },
|
|
10487
|
+
title: { type: 'string', required: true, description: '一句话说清这是什么流程(如「发布前跑全量回归并核对 SHA256」)。不要用运行时样板文字或纯提问句。' },
|
|
10488
|
+
steps: { type: 'string', required: true, description: '流程步骤,**一行一步**(换行分隔);行首的 "1. " / "- " 会自动去掉。' },
|
|
10489
|
+
successCriteria: { type: 'string', description: '怎么算跑通,一行一条。**强烈建议填写**——缺它则该条目结构上无法晋升。' },
|
|
10490
|
+
preconditions: { type: 'string', description: '前置条件,一行一条(可选)。' },
|
|
10491
|
+
checks: { type: 'string', description: '过程中要检查的点,一行一条(可选)。' },
|
|
10492
|
+
rollback: { type: 'string', description: '失败时怎么回滚,一行一条(可选)。' },
|
|
10493
|
+
riskLevel: { type: 'string', enum: ['low', 'medium', 'high'], description: '风险等级,缺省 low。high 会要求人工批准后才可激活。' },
|
|
10494
|
+
}, async (args, exec) => {
|
|
10495
|
+
try {
|
|
10496
|
+
const hub = engine._memoryHub
|
|
10497
|
+
if (!hub || !hub.stores || !hub.stores.procedures) return 'memory_procedure: 记忆中枢未启用(hub 或 procedure store 不可用),未写入。'
|
|
10498
|
+
const procs = hub.stores.procedures
|
|
10499
|
+
// 一行一条:去掉行首编号/项目符号,丢弃空行(模型输出格式不稳定的兜底)
|
|
10500
|
+
const toLines = (v) => String(v == null ? '' : v).split(/\r?\n/)
|
|
10501
|
+
.map((s) => s.replace(/^\s*(?:\d+[.、)]|[-*•])\s*/, '').trim())
|
|
10502
|
+
.filter(Boolean)
|
|
10503
|
+
const title = String(args.title || '').trim()
|
|
10504
|
+
const steps = toLines(args.steps)
|
|
10505
|
+
const successCriteria = toLines(args.successCriteria)
|
|
10506
|
+
if (!title) return 'memory_procedure: title 必填。'
|
|
10507
|
+
if (!steps.length) return 'memory_procedure: steps 必填(至少一步)。'
|
|
10508
|
+
const cand = {
|
|
10509
|
+
title, steps, successCriteria,
|
|
10510
|
+
preconditions: toLines(args.preconditions),
|
|
10511
|
+
checks: toLines(args.checks),
|
|
10512
|
+
rollback: toLines(args.rollback),
|
|
10513
|
+
riskLevel: ['low', 'medium', 'high'].includes(args.riskLevel) ? args.riskLevel : 'low',
|
|
10514
|
+
// origin='agent' ⇒ 走 agent 口径;**不设 observationOnly** —— 它是真技能(有 steps + criteria),
|
|
10515
|
+
// 与机械切出来的空壳观察行在结构上区分开,因此天然可晋升。
|
|
10516
|
+
origin: 'agent',
|
|
10517
|
+
sourceMemoryIds: [], sourceEpisodes: [],
|
|
10518
|
+
}
|
|
10519
|
+
const r = procs.observe(cand)
|
|
10520
|
+
if (!r || !r.ok) return 'memory_procedure: 写入失败(' + String((r && r.reason) || 'unknown') + ')。'
|
|
10521
|
+
const pid = r.procedure && r.procedure.procedureId
|
|
10522
|
+
let out = (r.merged ? '已并入既有条目(指纹相同)' : '已写入') + ':' + title + ' [id=' + String(pid).slice(0, 20) + ']'
|
|
10523
|
+
if (!successCriteria.length) out += '\n⚠ 未提供 successCriteria —— 该条目**结构上无法晋升**,之后请补写。'
|
|
10524
|
+
if (args.action !== 'activate') return out + '\n(当前在审批列表,未激活。需要时再调 action=activate)'
|
|
10525
|
+
|
|
10526
|
+
// —— action=activate:模型授权跳统计门 → 晋升 → 激活 → 导出 SKILL.md ——
|
|
10527
|
+
const pr = procs.promote(pid, {}, { authorizedBy: 'model' })
|
|
10528
|
+
if (!pr || !pr.ok || pr.decision !== 'promote') {
|
|
10529
|
+
// ★ B-1 配套(用户 2026-09-22 方针①「模型友好」+ ②「原因要人能看懂」):
|
|
10530
|
+
// 旧文案只甩 decision + reasonCodes 机器码,模型读完不知道下一步该做什么,
|
|
10531
|
+
// 遇到 high-risk 就以为"卡死了"。现在补一层**人话解释 + 可执行的下一步**,
|
|
10532
|
+
// 机器码原样保留(便于日志/前端复用),模型据此能自己决定是补 successCriteria、
|
|
10533
|
+
// 还是提示用户去「记忆中枢」点「批准」。
|
|
10534
|
+
const codes = ((pr && pr.reasonCodes) || []).map(String)
|
|
10535
|
+
const EXPLAIN = {
|
|
10536
|
+
'high-risk-awaiting-approval': '该条被标为高风险,需要**用户在「记忆中枢」页签点「批准」**后才能晋升(批准只解这一道门,其余门槛不变)。你可以告诉用户这条待批准。',
|
|
10537
|
+
'no-success-criteria': '缺少 successCriteria ⇒ 结构上无法晋升。请用 memory_procedure 补写「怎么算跑通」再调 action=activate。',
|
|
10538
|
+
'observation-only': '这条是纯 episode 观察行(没有 steps/successCriteria),设计上不参与晋升;请改为写入一条真正的技能条目。',
|
|
10539
|
+
'has-correction': '该条存在纠正记录(maxContradictions=0)⇒ 先解决矛盾,不要强推。',
|
|
10540
|
+
}
|
|
10541
|
+
const c3 = codes[0]
|
|
10542
|
+
const human = EXPLAIN[c3] || (codes.find((c) => EXPLAIN[c]) ? EXPLAIN[codes.find((c) => EXPLAIN[c])] : '')
|
|
10543
|
+
const corrCode = codes.find((c) => c.startsWith('correction-rate-'))
|
|
10544
|
+
return out + '\n未激活:晋升未通过(decision=' + String((pr && pr.decision) || '?') +
|
|
10545
|
+
' reasonCodes=' + JSON.stringify(codes) + ')。条目仍在审批列表。' +
|
|
10546
|
+
(human ? '\n→ ' + human : '') +
|
|
10547
|
+
(corrCode ? '\n→ 纠正率超过当前上限(' + corrCode + '):说明该条历史执行里被纠正过,先改进流程再重试。' : '') +
|
|
10548
|
+
(codes.includes('diversity-below-3') ? '\n→ 跨会话证据不足(需要至少 3 个不同会话的成功记录):继续在别的会话里用一次,或由用户手动晋升。' : '')
|
|
10549
|
+
}
|
|
10550
|
+
const ar = procs.activate(pid)
|
|
10551
|
+
if (!ar || !ar.ok) return out + '\n已晋升但激活失败:' + String((ar && ar.reason) || 'unknown')
|
|
10552
|
+
out += '\n已晋升(授权=model)并激活。'
|
|
10553
|
+
try {
|
|
10554
|
+
const ex = exportSkillForPre(ar.procedure, {
|
|
10555
|
+
skillsRoot: resolveSkillsRootPre({ dshHome: dshHome() }),
|
|
10556
|
+
projectPath: process.cwd(),
|
|
10557
|
+
exportedAt: new Date().toISOString(),
|
|
10558
|
+
})
|
|
10559
|
+
out += ex && ex.ok ? '\n已导出 SKILL.md:' + String(ex.dirName || '') : '\nSKILL.md 导出失败(' + String((ex && ex.reason) || '?') + '),不影响已激活状态。'
|
|
10560
|
+
} catch (e) {
|
|
10561
|
+
out += '\nSKILL.md 导出异常:' + String((e && e.message) || e) + '(不影响已激活状态)'
|
|
10562
|
+
}
|
|
10563
|
+
try { diag('procedure write(by model): ' + String(pid).slice(0, 20) + ' action=' + String(args.action)) } catch (_) {}
|
|
10564
|
+
return out
|
|
10565
|
+
} catch (e) {
|
|
10566
|
+
return 'memory_procedure 失败: ' + ((e && e.message) || String(e))
|
|
10567
|
+
}
|
|
10568
|
+
}),
|
|
9135
10569
|
]
|
|
9136
10570
|
|
|
9137
10571
|
// ── WB-GRAPH 白板线新工具(board_mode_v1 闸门, 2026-09-16)──
|
|
@@ -9143,11 +10577,11 @@ export function apply(ctx, config) {
|
|
|
9143
10577
|
// **数组之外**, 返回值**从未 push 进 tools** ⇒ 即便闸门判定为 true, 两个工具也不会被注册。
|
|
9144
10578
|
// 这是与 BUG-1 独立的第二道致命缺陷: 修好「读到真配置」还不够, 还必须真的把定义收进数组。
|
|
9145
10579
|
if (resolveBoardModePre(engine.config.boardMode).graphEnabled) {
|
|
9146
|
-
tools.push(defineTool('
|
|
10580
|
+
tools.push(defineTool('memory_expand', '白板结构化展开(P3, 需 boardMode=graph):正向遍历——给定 tag(如 type:dead-end / topic:登录)展开所有匹配的账本/白板条目 Content,默认 limit 10、硬帽 20。返回条目 id、标题、来源(source 文件+行)与判据状态。适合主动重建上下文(如「把所有失败方案列出来」)。', {
|
|
9147
10581
|
tag: { type: 'string', description: '要展开的 tag,如 type:dead-end 或 topic:主题名。' },
|
|
9148
10582
|
limit: { type: 'integer', description: '返回条数上限,缺省 10,硬帽 20。' },
|
|
9149
10583
|
}, async (args, exec) => engine.expandWhiteboardByTagPre(exec.agent, String(args.tag || ''), Math.min(Math.max(Number(args.limit) || 10, 1), 20))))
|
|
9150
|
-
tools.push(defineTool('
|
|
10584
|
+
tools.push(defineTool('memory_trace', '白板结构化回溯(P3, 需 boardMode=graph):反向遍历——给定条目 id 回溯它的 cue(入口关键词/路径)、tag 与相邻条目,以及归档版本链(prev_version)。适合「这条结论从哪来」的溯源。', {
|
|
9151
10585
|
id: { type: 'string', description: '条目 id(index.json 里的条目标识)。' },
|
|
9152
10586
|
}, async (args, exec) => engine.traceWhiteboardByIdPre(exec.agent, String(args.id || ''))))
|
|
9153
10587
|
}
|
|
@@ -9442,7 +10876,8 @@ export function apply(ctx, config) {
|
|
|
9442
10876
|
if (!cfg || typeof cfg !== 'object') cfg = {}
|
|
9443
10877
|
cfg.activationEmitMode = mode
|
|
9444
10878
|
fsMod.mkdirSync(pathMod.dirname(cfgPath), { recursive: true })
|
|
9445
|
-
|
|
10879
|
+
// ★#82:同一类裸写 —— 切原子写(语义引擎开关的读数来源,半截 JSON 会让双轨读到不同值)。
|
|
10880
|
+
writeTextAtomicPreSync(cfgPath, JSON.stringify(cfg, null, 2))
|
|
9446
10881
|
return writeJson(res, 200, { ok: true, mode })
|
|
9447
10882
|
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
9448
10883
|
},
|
|
@@ -9623,12 +11058,50 @@ export function apply(ctx, config) {
|
|
|
9623
11058
|
// 不绕过任何 gate;每次动作进 diagnose 审计。
|
|
9624
11059
|
const procs = hub.stores && hub.stores.procedures
|
|
9625
11060
|
const pid = String((body && body.procedureId) || '')
|
|
9626
|
-
|
|
11061
|
+
// ★ B-1 修复(2026-09-22):白名单补 `approve`。
|
|
11062
|
+
// 旧白名单只有 promote/activate/deprecate/pin,而高风险条目建候选时恒 `approved:false`
|
|
11063
|
+
// (procedure-store.js:235)且全仓无任何代码能置真,promote 只会返回 decision:'ask'
|
|
11064
|
+
// ⇒ 高风险条目的晋升是**死路**。现在把"人来确认"这只手补上:approve 只解一道门,
|
|
11065
|
+
// 其余五道结构门(已弃用 / 观察型 / 缺 successCriteria / 纠正率超限 / 存在纠正)照旧拦。
|
|
11066
|
+
// ★B(2026-09-22):白名单补 `force-promote`(人工**强制晋升**)。
|
|
11067
|
+
// 动机(有数据证据):只读投影 evaluatePromotion 对盘上 30 条**全部**返回 keep,
|
|
11068
|
+
// ⇒ 按 A-9 的门控,界面上一个可点的晋升按钮都没有("手动通道看起来被关掉了")。
|
|
11069
|
+
// 其中「只被 diversity/success 两道统计门拦住」的行(实际 14 条里绝大多数)
|
|
11070
|
+
// 在语义上是可以人工越过的 —— promote() 的 `if (!authorizedBy)` 正是为这场景留的口子。
|
|
11071
|
+
if (['promote', 'force-promote', 'approve', 'activate', 'deprecate', 'pin'].includes(action)) {
|
|
9627
11072
|
if (!procs) return writeJson(res, 200, { ok: false, reason: 'no-procedure-store' })
|
|
9628
11073
|
if (!pid) return writeJson(res, 400, { error: 'procedureId required' })
|
|
9629
11074
|
let r
|
|
9630
|
-
if (action === '
|
|
9631
|
-
else if (action === '
|
|
11075
|
+
if (action === 'approve') r = procs.approve(pid, 'user')
|
|
11076
|
+
else if (action === 'promote') r = procs.promote(pid)
|
|
11077
|
+
// ★B(2026-09-22):人工强制晋升 —— `authorizedBy:'user'`。
|
|
11078
|
+
// · 只越过 diversity / success 两道**统计门**;
|
|
11079
|
+
// · 结构门(deprecated / observationOnly / no-success-criteria / has-correction /
|
|
11080
|
+
// correction-rate / 高风险待批准)**照旧拦** —— 与 T4 冻结的"授权语义"逐字一致;
|
|
11081
|
+
// · 留痕:晋升成功会写 `p.authorizedBy = 'user'`,reasonCodes 记 `user-authorized`,
|
|
11082
|
+
// 与模型授权(`model-authorized`)在审计面可区分。
|
|
11083
|
+
else if (action === 'force-promote') r = procs.promote(pid, {}, { authorizedBy: 'user' })
|
|
11084
|
+
else if (action === 'activate') {
|
|
11085
|
+
r = procs.activate(pid)
|
|
11086
|
+
// ★ ⑪-2(用户 2026-09-19 拍板):晋升为 active 后**自动导出** SKILL.md。
|
|
11087
|
+
// 落点 = 用户级 `<dshHome>/skills/`(DSH 四条发现路径之一,可跨项目迁移);
|
|
11088
|
+
// 导出物**必须标注适用项目**(⑪-3),且随附程序只作**参考**、不得直接运行(⑪-1)。
|
|
11089
|
+
// 全程 fail-soft:导出失败只记诊断,绝不回滚已成功的 activate。
|
|
11090
|
+
if (r && r.ok) {
|
|
11091
|
+
try {
|
|
11092
|
+
const ex = exportSkillForPre(r.procedure, {
|
|
11093
|
+
skillsRoot: resolveSkillsRootPre({ dshHome: dshHome() }),
|
|
11094
|
+
projectPath: process.cwd(),
|
|
11095
|
+
exportedAt: new Date().toISOString(),
|
|
11096
|
+
})
|
|
11097
|
+
r.skillExport = ex
|
|
11098
|
+
try { diag('hub review: skill export ' + pid.slice(0, 20) + ' → ' + JSON.stringify({ ok: ex.ok, reason: ex.reason, dir: ex.dirName })) } catch (_) {}
|
|
11099
|
+
} catch (e) {
|
|
11100
|
+
r.skillExport = { ok: false, reason: 'export-threw:' + String((e && e.message) || e) }
|
|
11101
|
+
try { diag('hub review: skill export threw ' + String((e && e.message) || e)) } catch (_) {}
|
|
11102
|
+
}
|
|
11103
|
+
}
|
|
11104
|
+
}
|
|
9632
11105
|
else if (action === 'deprecate') r = procs.deprecate(pid, 'user-disabled')
|
|
9633
11106
|
else r = procs.setPinned(pid, (body && body.v) !== false)
|
|
9634
11107
|
// issue #30:旧日志只记 `r.ok` —— 而 promote 的"拒绝晋升"也是 ok:true(decision='keep'),
|
|
@@ -9963,6 +11436,61 @@ export function apply(ctx, config) {
|
|
|
9963
11436
|
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
9964
11437
|
},
|
|
9965
11438
|
},
|
|
11439
|
+
// ── ★R7(2026-09-20):用户级硬性约束的条目级读写 ──────────────────
|
|
11440
|
+
// 背景:`[规则 — 用户级硬性约束]` 段**每轮无条件注入、不走语义层** ⇒
|
|
11441
|
+
// 过时条目不会被自动淘汰,AI 也可能写错 ⇒ 必须让用户能自己增删改。
|
|
11442
|
+
// 纪律:① 真源仍是 `~/.dsh/memory/MEMORY.md`,本路由**不新增事实来源**;
|
|
11443
|
+
// ② 写入复用既有 `writeFull` 事务(备份 + 校验),不绕过;
|
|
11444
|
+
// ③ 删除**是真删**(本层渲染器不认状态标记,软删会被当正文注入模型)⇒
|
|
11445
|
+
// 由前端做二次确认,host 侧只如实执行。
|
|
11446
|
+
{
|
|
11447
|
+
kind: 'exact',
|
|
11448
|
+
path: API['rules-list'],
|
|
11449
|
+
handler: async (req, res) => {
|
|
11450
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11451
|
+
if ((req.method || 'GET') !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11452
|
+
try {
|
|
11453
|
+
const p = await engine.resolvePaths(undefined)
|
|
11454
|
+
const text = (await engine.readTextSafe(p.userFile)) || ''
|
|
11455
|
+
const items = listRuleItemsPre(text)
|
|
11456
|
+
writeJson(res, 200, {
|
|
11457
|
+
path: p.userFile,
|
|
11458
|
+
items,
|
|
11459
|
+
// 缺省注入时这些条目会长成什么样(R7-6 预览用)
|
|
11460
|
+
preview: items.map((x) => '- ' + x.text).join('\n'),
|
|
11461
|
+
})
|
|
11462
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11463
|
+
},
|
|
11464
|
+
},
|
|
11465
|
+
{
|
|
11466
|
+
kind: 'exact',
|
|
11467
|
+
path: API['rules-apply'],
|
|
11468
|
+
handler: async (req, res) => {
|
|
11469
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
11470
|
+
if ((req.method || 'POST') !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
11471
|
+
const body = await readJsonBody(req)
|
|
11472
|
+
const op = String((body && body.op) || '')
|
|
11473
|
+
if (!['add', 'update', 'remove'].includes(op)) return writeJson(res, 400, { error: 'invalid-op' })
|
|
11474
|
+
try {
|
|
11475
|
+
const p = await engine.resolvePaths(undefined)
|
|
11476
|
+
const before = (await engine.readTextSafe(p.userFile)) || ''
|
|
11477
|
+
let r
|
|
11478
|
+
if (op === 'add') r = appendRuleItemPre(before, body.text, { dateSection: body.dateSection })
|
|
11479
|
+
else if (op === 'update') r = updateRuleItemPre(before, Number(body.index), body.text)
|
|
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
|
+
})
|
|
11491
|
+
} catch (e) { writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
11492
|
+
},
|
|
11493
|
+
},
|
|
9966
11494
|
{
|
|
9967
11495
|
kind: 'exact',
|
|
9968
11496
|
path: API.external,
|
|
@@ -10220,4 +11748,4 @@ export function apply(ctx, config) {
|
|
|
10220
11748
|
}
|
|
10221
11749
|
|
|
10222
11750
|
/** 导出卫生守卫与脏 token 检查器(供 smoke-test / 回归测试直接调用)。 */
|
|
10223
|
-
export { sanitizeForWrite, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession }
|
|
11751
|
+
export { sanitizeForWrite, hygieneGateForPrimitive, dirtyScanForFiles, mojibakeDensity, tailHas, hasStutter, WRITE_GATE_REASON, foldSessionLogEvents, workspaceIdForSession, attachmentsOfContent, attachmentBlobPathsPre, renderAttachmentLinesPre }
|