@a9i5k4/dsh-auto-memory 2.5.3 → 3.0.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 +171 -1
- package/README.zh-CN.md +171 -1
- package/docs/CONTRIBUTORS.html +471 -0
- package/docs/HANDOFF-CRITERIA.md +92 -0
- package/docs/INTEGRATION-ANALYSIS.md +350 -348
- package/docs/USER-GUIDE.en.md +56 -1
- package/docs/USER-GUIDE.zh-CN.md +57 -2
- package/docs/internal/ACCEPT-35-LIVE.md +143 -0
- package/docs/internal/ACCEPTANCE-20260914.md +90 -0
- package/docs/internal/ARCH-REVIEW-BRIEF.md +411 -0
- package/docs/internal/ARCH-REVIEW-REQUEST.md +201 -0
- package/docs/internal/ARCH-REVIEW-ROUND2.md +169 -0
- package/docs/internal/ARCH-REVIEW-ROUND3.md +206 -0
- package/docs/internal/AUDIT-WB-GRAPH-FULL-20260916.md +314 -0
- package/docs/internal/CONCURRENCY-INVESTIGATION-20260917.md +192 -0
- package/docs/internal/CROSS-SESSION-SEARCH-PATH-DECISION.md +72 -0
- package/docs/internal/CROSS-SESSION-SEARCH-RESEARCH.md +131 -0
- package/docs/internal/DECISIONS-20260914-SESSION.md +269 -0
- package/docs/internal/DESIGN-P1-STATE-COMMIT-20260915.md +219 -0
- package/docs/internal/DIRECTION-CHECK-WB-GRAPH-20260916.md +132 -0
- package/docs/internal/FEEDBACK-TO-DSHAPI-RELAY.md +13 -0
- package/docs/internal/GH-DISCUSSION-5732-COMMENT.md +74 -0
- package/docs/internal/GPT-ACCEPTANCE-PROMPT-20260916.md +352 -0
- package/docs/internal/GPT-REVIEW-PROMPT.md +216 -0
- package/docs/internal/GROUP-WEBHOOK-SETUP.md +33 -0
- package/docs/internal/KICKOFF-P0.md +254 -0
- package/docs/internal/MASTER-PLAN-3.0.md +411 -0
- package/docs/internal/MEMORY-MUTATION-AND-INDEX-DESIGN.md +85 -0
- package/docs/internal/MERGE-CONFLICT-SCAN-20260914.md +222 -0
- package/docs/internal/PENDING-FIXES-20260916.md +289 -0
- package/docs/internal/RAG-KARPATHY-PROGRAM.md +229 -0
- package/docs/internal/REPORT-P0-NIGHTLY.md +212 -0
- package/docs/internal/REPORT-P5-ACCEPTANCE.md +31 -0
- package/docs/internal/REPORT-WB-GRAPH-NIGHTLY.md +153 -0
- package/docs/internal/REVIEW-WB-GRAPH-SELF.md +81 -0
- package/docs/internal/ROADMAP-20260917-WEEK.md +305 -0
- package/docs/internal/ROADMAP.md +106 -0
- package/docs/internal/RUN-P0-NIGHTLY.md +227 -0
- package/docs/internal/S10-CONSTRUCTION-HANDOFF-20260917.md +175 -0
- package/docs/internal/S10-GAPS-PLAIN-20260917.md +125 -0
- package/docs/internal/SEMANTIC-ARCHITECTURE-SPEC.md +360 -0
- package/docs/internal/SESSION-FILE-REPAIR-PROTOCOL.md +90 -0
- package/docs/internal/THREE-LAYER-CONTRACT.md +210 -0
- package/docs/internal/TODO-BACKLOG.md +263 -142
- package/docs/internal/TODO-GRAPH.html +715 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260914-v2 +493 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-alsfix +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p1 +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-p6a-rev +703 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260915-wshint +710 -0
- package/docs/internal/TODO-GRAPH.html.bak-20260916-batch +715 -0
- package/docs/internal/WB-FORMAT-CONVENTION.md +112 -0
- package/docs/internal/WB-GRAPH-DECISIONS-20260914.md +71 -0
- package/docs/internal/reviews/CLAIM-VERIFICATION-20260914.md +56 -0
- package/docs/internal/reviews/PLAN-gpt6astra-round2-20260914.md +787 -0
- package/docs/internal/reviews/REVIEW-gpt6astra-20260914.md +112 -0
- package/docs/internal/reviews/ROUND3-REVIEW-INTEGRATION-20260914.md +230 -0
- package/docs/prompts/M8-3-enable-verify.md +49 -49
- package/lib/acceptance.js +71 -0
- package/lib/activation-host.js +90 -9
- package/lib/activation-inbox.js +25 -7
- package/lib/board-mode.js +30 -0
- package/lib/client.js +878 -75
- package/lib/context-bridge.js +2 -2
- package/lib/context-host.js +70 -6
- package/lib/engine-identity.js +149 -0
- package/lib/engine-switch.js +247 -0
- package/lib/episodic-store.js +11 -10
- package/lib/evidence-store.js +2 -2
- package/lib/fact-store.js +1 -1
- package/lib/fs-retry.js +46 -0
- package/lib/index.js +1987 -153
- package/lib/intent-clean-safe.js +40 -0
- package/lib/intent-clean.js +12 -16
- package/lib/l0-extract.js +263 -149
- package/lib/l0-index-sync.js +195 -0
- package/lib/l0-index.js +349 -239
- package/lib/ledger-criteria.js +142 -0
- package/lib/m7-index-sync-host.js +65 -4
- package/lib/m7-wire.js +3 -3
- package/lib/memory-anchor.js +56 -1
- package/lib/memory-envelope.js +252 -0
- package/lib/memory-hub.js +14 -4
- package/lib/memory-mutation.js +246 -0
- package/lib/memory-writer.js +204 -24
- package/lib/procedure-observation.js +48 -0
- package/lib/procedure-store.js +34 -17
- package/lib/python-setup.js +1 -1
- package/lib/rerank-host.js +160 -0
- package/lib/rules-layer.js +261 -0
- package/lib/semantic-js.js +15 -0
- package/lib/shadow-retrieval.js +3 -3
- package/lib/state-commit.js +245 -0
- package/lib/subagent-gc.js +4 -8
- package/lib/tier-layer-inject.js +650 -0
- package/lib/tier0-catalog.js +693 -0
- package/lib/water-window.js +263 -186
- package/lib/wb-contract.js +495 -0
- package/lib/wb-sidecar.js +839 -0
- package/lib/ws-overview-rank.js +2 -2
- package/package.json +1 -1
- package/python/m7_embedding_v1.py +5 -5
- package/python/worker_semantic_v1.py +17 -6
- package/python/worker_v1.py +38 -4
package/lib/index.js
CHANGED
|
@@ -26,8 +26,9 @@ import { homedir } from 'node:os'
|
|
|
26
26
|
import { randomUUID } from 'node:crypto'
|
|
27
27
|
import path from 'node:path'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
29
|
-
import { MemoryDocumentStore } from './memory-writer.js'
|
|
30
|
-
import {
|
|
29
|
+
import { MemoryDocumentStore, memoryWriteError } from './memory-writer.js'
|
|
30
|
+
import { retryRename } from './fs-retry.js'
|
|
31
|
+
import { parseAnchors, stripAnchorLines } from './memory-anchor.js'
|
|
31
32
|
import { createShadowHost } from './shadow-host.js'
|
|
32
33
|
import { createContextHost } from './context-host.js'
|
|
33
34
|
import { createSuccessEvidencePre } from './context-bridge.js'
|
|
@@ -35,6 +36,9 @@ import { createIndexSyncHostPre } from './m7-index-sync-host.js'
|
|
|
35
36
|
import { createActivationHost } from './activation-host.js'
|
|
36
37
|
import { createJsSemanticEnginePre, createSemanticDownloaderPre, fuseD6Pre, probeJsSemanticAssets, deepScanPeerTransformers, E5_SMALL_Q8_MANIFEST_V1 } from './semantic-js.js'
|
|
37
38
|
import { loadAndVerifyPolicy, decideActivationV2, lexicalContainment } from './semantic-decide.js'
|
|
39
|
+
import { createL0IndexSyncPre } from './l0-index-sync.js'
|
|
40
|
+
// ★P2(2026-09-15 引擎隔离 T2-9):宽引擎身份(模型/权重指纹/tokenizer/精度/维度/池化/归一化/输入版本)。
|
|
41
|
+
import { computeEngineIdentityPre, JS_E5_IDENTITY_DESC_V1 } from './engine-identity.js'
|
|
38
42
|
import { createPythonSidecarClientPre, defaultWorkerScriptPathPre } from './python-sidecar-client.js'
|
|
39
43
|
import { createPythonSetupPre } from './python-setup.js'
|
|
40
44
|
import { createEpisodicStorePre } from './episodic-store.js'
|
|
@@ -45,8 +49,21 @@ import { pickConsolidationTextPre } from './intent-clean.js'
|
|
|
45
49
|
import { createStorageManagerPre } from './storage-manage.js'
|
|
46
50
|
import { rankWorkspacesByMemoryRecencyPre } from './ws-overview-rank.js'
|
|
47
51
|
import { scanPluginSubagentSessions, recycleSessions, PLUGIN_LABEL_PREFIX, decodeZstdFrames, decodeZstdFramesHead } from './subagent-gc.js'
|
|
48
|
-
import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre } from './water-window.js'
|
|
49
|
-
import { buildSourceCatalog, CorpusRegistry, canonicalize } from './m4-corpus.js'
|
|
52
|
+
import { parseModelWindowsPre, pickWindowPre, findOfficialContextWindowPre, findSessionModelPre, scanPressureSignalsPre, reusableWindowCachePre, shouldArmAutoContinuePre } from './water-window.js'
|
|
53
|
+
import { buildSourceCatalog, CorpusRegistry, canonicalize, sourceFingerprint } from './m4-corpus.js'
|
|
54
|
+
// C5 三层注入装配(Tier-0 常驻目录 + 闸门下探 + I7 降级标注):纯函数模块,注入路径零 LLM 调用(S9)
|
|
55
|
+
import { composeTieredInjectionPre, TIER_BUDGET_V1, TIER_MARK_V1, selectReusableTierHitsPre, describeReuseReasonPre } from './tier-layer-inject.js'
|
|
56
|
+
// T0-3 分项账本(chars/limits 单一口径):纯函数模块,注入路径零 LLM 调用(S9)
|
|
57
|
+
import { composeMemoryEnvelopePre } from './memory-envelope.js'
|
|
58
|
+
// P0 写入门(共同提交与保护入口)+ 白板格式适配器(格式只维护一份)
|
|
59
|
+
// 边界(总纲 §0.5 / ROUND3 §3.1):3.0 主体拥有 validateMutationBoundaryPre(只收规范化投影);
|
|
60
|
+
// 白板线拥有 parseWhiteboardPre(解释白板格式)。两者在写入路径上串联,但职责不混。
|
|
61
|
+
import { validateMutationBoundaryPre, mutationRefusalTextPre } from './memory-mutation.js'
|
|
62
|
+
import { parseWhiteboardPre, toMutationProjectionPre, extractProtectedRegionsPre, checkHandoffCriteriaPre, checkPlanCriteriaPre, criteriaRefusalTextPre, WB_MARKERS_V1, WB_CONTRACT_VERSION } from './wb-contract.js'
|
|
63
|
+
import { resolveBoardModePre } from './board-mode.js'
|
|
64
|
+
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
|
+
// P6A 规则层(规则类与参考类分开措辞;规则真源 = 既有用户级记忆,不新增 RULES.md)
|
|
66
|
+
import { extractRulesLayerPre, renderRulesSectionPre, RULES_SECTION_TITLE_V1, RULES_SECTION_GUIDE_V1, REFERENCE_SECTION_GUIDE_V1, LOG_KINDS_V1 } from './rules-layer.js'
|
|
50
67
|
import * as nodeZlib from 'node:zlib'
|
|
51
68
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
52
69
|
import { createHash } from 'node:crypto'
|
|
@@ -161,6 +178,7 @@ export const API = {
|
|
|
161
178
|
'py-setup-status': '/api/dsh-auto-memory/python-setup/status',
|
|
162
179
|
'semantic-deep-detect': '/api/dsh-auto-memory/semantic-deep-detect',
|
|
163
180
|
'handoff-state': '/api/dsh-auto-memory/handoff-state',
|
|
181
|
+
'kanban-board': '/api/dsh-auto-memory/kanban-board',
|
|
164
182
|
'handoff-continue': '/api/dsh-auto-memory/handoff-continue',
|
|
165
183
|
'handoff-permission': '/api/dsh-auto-memory/handoff-permission',
|
|
166
184
|
'auto-continue-state': '/api/dsh-auto-memory/auto-continue-state',
|
|
@@ -195,6 +213,12 @@ const COMPACT_FOLD_MAX_CHARS = 1500
|
|
|
195
213
|
const COMPACT_THROTTLE_MS = 10 * 60 * 1000
|
|
196
214
|
|
|
197
215
|
const DEFAULT_CONFIG = {
|
|
216
|
+
/** WB-GRAPH 白板线总开关(2026-09-16, board_mode_v1)。
|
|
217
|
+
* ★2026-09-17(3.0.0 大版本,用户裁定「白板默认新版,旧版为了兼容而保留」):默认由 'legacy'
|
|
218
|
+
* 改为 **'graph'** —— 新版看板(结构化 sidecar + 列式泳道 + P3 遍历工具,工具数 14→16)成为出厂形态;
|
|
219
|
+
* 'legacy'(旧版文字白板)保留为**兼容档**,显式设置即回到字节级旧行为。
|
|
220
|
+
* 开关解耦:只管白板线形态,不顺带改变其他功能。非法值 fail closed 按 legacy。 */
|
|
221
|
+
boardMode: 'graph',
|
|
198
222
|
/** 用户级记忆目录(绝对路径或 ~ 开头)。 */
|
|
199
223
|
userMemoryDir: '~/.dsh/memory',
|
|
200
224
|
/** 项目级记忆目录名(相对工作区)。 */
|
|
@@ -207,7 +231,38 @@ const DEFAULT_CONFIG = {
|
|
|
207
231
|
* 2026-08-26 调低:该快照每轮都会追加到历史尾部(尾部追加不击穿前缀缓存,但有稳态 token 成本,
|
|
208
232
|
* 活跃会话中每 ~15s 刷新一次)。默认 2400≈600-800 token/轮 已是平衡点;建议 1600-2400 之间,
|
|
209
233
|
* 设置页「记忆窗口→注入预算」可调。调太低会截断记忆内容,太高会拉长每轮 token 基数。 */
|
|
210
|
-
|
|
234
|
+
/** 每轮注入总预算(字符)。2026-09-14 用户裁定:1600 → **2000**。
|
|
235
|
+
* 原因:Tier-0 常驻目录不能在原预算内"免费"塞入 —— 原式 `(1600-500-目录)/4` 会把 4 个证据段
|
|
236
|
+
* 从约 275 字符挤到约 174;抬到 2000 后证据段回到约 275,目录另占约 200 token。
|
|
237
|
+
* 嫌贵可在设置里调回 1600(代价=证据段变短)或关掉 `tier0CatalogEnabled`。 */
|
|
238
|
+
/** ★2026-09-15(用户裁定):动态记忆快照注入预算(字符)。
|
|
239
|
+
* 默认 **8000**(原 2000):实测旧值下用户级记忆只有约 9% 能进注入——4 个证据段各分 `sub` 后
|
|
240
|
+
* 每段只剩几百字符,等于"记忆在场但只看到个开头"。
|
|
241
|
+
* 8000 字符 ≈ 4000 token ≈ 1M 窗口的 0.4%;配合**分级注入**(完整版每 5 轮一次)平均约 1300 token/轮。
|
|
242
|
+
* 设计取向(**不要再用"越大越好"的思路调它**):本门是"**摘要内联**"的额度,
|
|
243
|
+
* 全文永远靠 `memory_recall` / `memory_read` 按需下钻(Tier-0 目录常驻就是为此)。
|
|
244
|
+
* 需要装下更多语料时应提高 Tier-0 目录的配额(`tier0BudgetShare` / `tier0MaxTokens`),而不是把本值推到几万。 */
|
|
245
|
+
injectBudgetChars: 8000,
|
|
246
|
+
/** C5 三层注入:Tier-0 常驻目录(指引层)开关。默认开——每轮注入"要不要用某条记忆"的索引,
|
|
247
|
+
* 由 refresh() 从已缓存语料纯文本派生(零额外 IO、零 LLM,契约 S9)。false=回到旧快照。 */
|
|
248
|
+
tier0CatalogEnabled: true,
|
|
249
|
+
/** Tier-0 目录 token 预算(契约 B0;上限硬编码 800,超设无效;口径 = max(ceil(chars/2), ceil(chars/4)+4))。
|
|
250
|
+
* 默认 **400 = B0 的一半**:800 是「上限」而非「默认」;目录的价值是指引,实测 9 条仅 234 token,
|
|
251
|
+
* 留 400 给增长余量,同时不挤证据层。想多吃就调到 800(上限)。 */
|
|
252
|
+
tier0MaxTokens: 400,
|
|
253
|
+
/** Tier-0 目录最多占「注入预算」的比例(默认 **0.25**):目录是索引层,不得把证据层(日志/笔记)挤空。
|
|
254
|
+
* 与 `tier0MaxTokens` 取小生效;另在注入侧再封顶一次(见 renderMemoryDynamic 的 catalogCost)。 */
|
|
255
|
+
tier0BudgetShare: 0.25,
|
|
256
|
+
/** 群反馈第 4 条 / P0-④d:**注入来源排除**(坏记忆不再反复灌入)。
|
|
257
|
+
* 字符串数组,四种写法(写死口径,可测;只做精确/前缀匹配,不做模糊猜测):
|
|
258
|
+
* · 记忆 id:`mem_<32hex>` → 排除该条命中
|
|
259
|
+
* · 整层: `log` / `whiteboard` / `project` / `user` / `reflection` → 排除整层
|
|
260
|
+
* · 目录前缀(以 / 或 \ 结尾):`D:\ws\.dsh-memory\archive\` → 排除其下全部文件
|
|
261
|
+
* · 精确文件路径:`D:\ws\.dsh-memory\MEMORY.md`
|
|
262
|
+
* 路径比较 Windows 大小写不敏感、分隔符统一;被排除项在**注入闸门之前**挡下,
|
|
263
|
+
* 并渲染 `[降级] 已按用户排除项挡下 N 条命中` —— 绝不静默(I7)。
|
|
264
|
+
* 与 `supersede` 是**两套互补机制**:supersede 管"被新版本替代",本项管"这条来源整个不可信"。 */
|
|
265
|
+
injectExcludeSources: [],
|
|
211
266
|
/** 注入的最近日志天数。 */
|
|
212
267
|
recentDaysInjected: 1,
|
|
213
268
|
/** subagent 类功能(时段总结/问候语/自动沉淀)使用的模型;留空=跟随系统路由默认。 */
|
|
@@ -250,12 +305,38 @@ const DEFAULT_CONFIG = {
|
|
|
250
305
|
maintainScheduleEnabled: true,
|
|
251
306
|
/** 30 天蒸馏触发时间(HH:MM)。 */
|
|
252
307
|
maintainScheduleTime: '10:00',
|
|
253
|
-
/** M-CM1 交接白板:PLAN.md(项目全貌快照,模型重写+旧版归档)+四段式交接账本;注入动态快照首位。false
|
|
254
|
-
|
|
308
|
+
/** M-CM1 交接白板:PLAN.md(项目全貌快照,模型重写+旧版归档)+四段式交接账本;注入动态快照首位。false=完全不读写。
|
|
309
|
+
* ★2026-09-17(3.0.0 大版本,用户裁定「白板默认新版」):默认由 false 改为 **true**。
|
|
310
|
+
* 理由:README/用户手册早已对外声称「交接默认开启」,而代码默认是关 —— 文档与实现长期不一致;
|
|
311
|
+
* 且白板/账本是 3.0 的招牌能力,出厂关着等于新用户看不到它。要完全关闭仍可显式设 false。 */
|
|
312
|
+
handoffEnabled: true,
|
|
255
313
|
/** 白板 PLAN.md 注入预算(字符,硬截断;全文可经 memory_read/文件读取)。 */
|
|
256
314
|
handoffPlanChars: 1200,
|
|
257
315
|
/** 最新交接账本注入预算(字符,硬截断)。 */
|
|
258
316
|
handoffLedgerChars: 800,
|
|
317
|
+
/** ★2026-09-15(用户裁定):**精简版里的白板/账本额度**。
|
|
318
|
+
* 用户的理由:轮次结束时理论上都要更新白板;轮次间触发自动接续时也要强制更新白板与账本
|
|
319
|
+
* ⇒ 「模型看不见白板长什么样」与「规矩不在场」是同一类病(不是不听话,是没收到)。
|
|
320
|
+
* 这两个值刻意**小于**完整版的 `handoffPlanChars`/`handoffLedgerChars`:
|
|
321
|
+
* 精简版的目标不是"看到全文",而是"看到它长什么样、以及它已经旧了";全文走文件/`memory_read`。 */
|
|
322
|
+
slimPlanChars: 400,
|
|
323
|
+
slimLedgerChars: 300,
|
|
324
|
+
/** P6A 规则分层模式(2026-09-14):
|
|
325
|
+
* 'off'(默认)=**不启用**规则分层——注入措辞与节奏完全沿用旧行为(新旧并存 + 开关回退)。
|
|
326
|
+
* 'self'=只看当前 agent 自己写的用户级规则;'none'=只看工作区级规则。
|
|
327
|
+
* 开启后规则段**每轮注入且不参与裁剪**(见 `renderRulesSectionPre` 与分项账本的 `rules` 分项)。 */
|
|
328
|
+
/** ★2026-09-15 审核修正:规则分层模式默认值 `off` → **`self`**。
|
|
329
|
+
* `off` 的语义是"回落旧行为"(规则段完全不渲染),作为**回退开关**是对的,
|
|
330
|
+
* 但作为**出厂默认**等于:新用户与未设过该键的老用户都拿不到规则段 ——
|
|
331
|
+
* 实测本机配置无此键 ⇒ 精简版里"规矩"一个字都没有("规矩每轮在场"做成了"规矩从不出现")。
|
|
332
|
+
* `self` = 看用户级(跨工作区恒定)+ 工作区级;要完全回退仍可显式设 `off`。 */
|
|
333
|
+
rulesLayeringMode: 'self',
|
|
334
|
+
/** P0 判据门开关(2026-09-14):true(默认)=按 target 校验判据——交接账本用 H1–H4(硬)/S1–S4(软),
|
|
335
|
+
* 白板 PLAN 用 P-H1/P-H2(硬)/P-S1(软);false=退掉这道**可选质量门**。
|
|
336
|
+
* ⚠️ 关掉它**不会**跳过丢卡 / 用户区 / 重复 id 三条共同保护——那三条在
|
|
337
|
+
* `lib/memory-mutation.js:validateMutationBoundaryPre` 里无条件生效
|
|
338
|
+
* (ROUND3 §3.7 第 4 条:新旧开关不能撤掉共同保护)。 */
|
|
339
|
+
criteriaGate: true,
|
|
259
340
|
/** M-CM4 水位感知:窗口 token 数。0=自动——从 settings.yaml 的 agent-default-model 解析对应模型的 contextWindow(失败回退 131072);手动设值即覆盖自动检测。 */
|
|
260
341
|
waterLevelWindowTokens: 0,
|
|
261
342
|
/** 水位建议阈值(0.1-1.5):ratio 越阈值时注入交接建议。
|
|
@@ -302,9 +383,15 @@ const DEFAULT_CONFIG = {
|
|
|
302
383
|
/** 无人值守自动检测的非工作时间窗(24h 字符串数组,如 ["22:00-08:00"];跨午夜支持)。
|
|
303
384
|
* 空数组=不按时间自动(仅托管任务触发)。 */
|
|
304
385
|
unattendedAutoHours: ['22:00-08:00'],
|
|
305
|
-
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
386
|
+
/** ★2026-09-15(用户裁定):**分级注入**——完整快照每 `snapshotMinGapRounds` 轮一次,
|
|
387
|
+
* 其余轮注入**精简版**(规则 + Tier-0 目录 + 日程,见 `renderSlimSnapshotPre`)。
|
|
388
|
+
* 动机:旧的"节流=整份快照跳过"让规矩与索引在 2–5 轮**不在场**(用户最痛的病之一);
|
|
389
|
+
* 用户的形态是「不是不注入,而是精简注入」。
|
|
390
|
+
* `false` = 回退旧行为(节流期间只给反思请求,不注入精简版)。 */
|
|
391
|
+
snapshotTieredInject: true,
|
|
392
|
+
/** 动态记忆快照注入频率控制(2026-08-27 → 2026-09-15 分级化):
|
|
393
|
+
* snapshotMinGapRounds = **完整快照**的最小间隔轮数(默认 **5**;其间各轮给精简版)。
|
|
394
|
+
* 0=**合法值**=每轮都发完整快照(不再被 `Number(v) || 5` 吞掉)。
|
|
308
395
|
* snapshotReinjectOnCompact = 上下文压缩/截断后是否强制重注入一次(默认 true)。 */
|
|
309
396
|
snapshotMinGapRounds: 5,
|
|
310
397
|
snapshotReinjectOnCompact: true,
|
|
@@ -387,6 +474,16 @@ const DEFAULT_CONFIG = {
|
|
|
387
474
|
pythonBackendExecutable: '',
|
|
388
475
|
/** M7.5 语义引擎档位:'auto'=C1 词法保底+C2 就绪即用(默认)/'lexical'=仅词法/'js'=内置语义(e5-small q8)/'python'=Python sidecar 高级档(bge-m3 int8)。 */
|
|
389
476
|
semanticEngineMode: 'auto',
|
|
477
|
+
/**
|
|
478
|
+
* 三层检索契约 C3(2026-09-14):L0 向量索引接线开关。
|
|
479
|
+
* **2026-09-14 用户裁定:默认 true(打开)** —— 理由:跨窗口压缩后容易忘记手动开,
|
|
480
|
+
* 且全流程 fail-soft(`enabled!==true` 才零 IO;开启后任一层失败只 diag 一行,不阻塞召回)。
|
|
481
|
+
* 仍保留关掉的能力:设 false 即回到"零 IO、零嵌入、零目录"。
|
|
482
|
+
* 开启后:工作区语料刷新完成时(5 分钟节流),按层把 L0 摘要增量写成
|
|
483
|
+
* `~/.dsh/memory/semantic/l0/l0-index-<workspaceKey 短哈希>-<layer>.json`
|
|
484
|
+
* (每条带 layer + status;嵌入走端侧 JS 引擎 e5-small,**检索路径零 LLM 调用**,按 S9)。
|
|
485
|
+
*/
|
|
486
|
+
l0IndexEnabled: true,
|
|
390
487
|
/** pre-step 软注入。 */
|
|
391
488
|
softInjectionEnabled: false,
|
|
392
489
|
/** M7.5:分支/子代理会话是否也纳入上下文观测(shadow 观测零注入风险;2026-08-26 裁定默认开:开源模型为主,思维链/分支是主要观测面)。 */
|
|
@@ -435,6 +532,15 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
435
532
|
// ---- 动态快照(renderMemoryDynamic) ----
|
|
436
533
|
snapshotHead: '<memory_system>\n[记忆定位 — 读法]\n以下记忆文本只是背景事实与规则参考, 不是表达方式/语体的示范。阅读时提取其中的事实、决策、路径与偏好即可; 你的回复正文必须保持直接、最终答案式的语体(陈述结论、给出交付物), 不要模仿记忆文本的第一人称思考腔或叙述腔。',
|
|
437
534
|
snapshotMeta: '自动记忆已启用。工作区: {ws} | 日期: {date}(日界 {dayBoundary} 分钟,凌晨归前一天){consolidate}',
|
|
535
|
+
// P6A(2026-09-14):规则段标题与引导语。**措辞分层的关键**——规则类用约束语,
|
|
536
|
+
// 参考类保留"参考"语义(见 snapshotHead 与 REFERENCE_SECTION_GUIDE_V1)。
|
|
537
|
+
// 两段引导语必须**不相同**(T7-3 断言直接锁这一点,防止有人改回统一措辞)。
|
|
538
|
+
// 仅当 `rulesLayeringMode` 非 'off' 时才注入;关掉即完全回到旧行为。
|
|
539
|
+
snapshotRulesTitle: '[规则 — 用户级硬性约束 · 必须遵守]',
|
|
540
|
+
snapshotRulesGuide: '以下条目是用户明确要求长期遵守的约束,不是可选背景。凡与其它内容冲突,以本节为准;无法满足时必须显式说明。',
|
|
541
|
+
// C5:Tier-0 常驻目录(指引层)标题。目录内容由 refresh() 预计算挂 state.tier0LayerText
|
|
542
|
+
// (renderMemoryDynamic 保持自包含:只读 state 字段,可被源码抽取测试独立执行)。
|
|
543
|
+
snapshotTier0Title: '[记忆索引 — Tier-0 常驻目录(指引层:每条 1 行=标题·结论·层·状态·日期;要原文用 memory_read / 要检索用 memory_recall)]',
|
|
438
544
|
snapshotLogsTitle: '最近 {n} 天工作日志(尾部)',
|
|
439
545
|
snapshotReflectionTitle: '最近反思 {date}(前一天工作精华)',
|
|
440
546
|
snapshotUserTitle: '用户级记忆 ~/.dsh/memory/MEMORY.md — 跨项目,必须遵守',
|
|
@@ -448,6 +554,13 @@ const DEFAULT_PROMPT_LAYERS = Object.freeze({
|
|
|
448
554
|
snapshotWelcomeTitle: '[欢迎回来]',
|
|
449
555
|
snapshotWelcomeBody: '用户离开已超过 1 小时(暂离/下班后回来)。在本轮回复的开头,先用一句简短温暖的话欢迎用户回来(如"欢迎回来!你离开的这段时间,我已经帮你把日志整理好了。"),然后提示"自动记忆窗口将打开,方便你了解这段时间的状况"(如已由 GUI 弹出概览则不必重复提示)。语气自然,一两句即可,不要长篇大论。',
|
|
450
556
|
snapshotInscription: '[铭文 · 每轮提醒 {date}]',
|
|
557
|
+
// ★2026-09-15(用户裁定"不是不注入,而是精简注入"):精简版尾部说明。
|
|
558
|
+
// 作用:让模型知道**这是精简版**、完整版每 {n} 轮来一次、以及"知道有什么但没给全文"时怎么取。
|
|
559
|
+
// 不写"请稍后再看"这类无效指令——只给可执行的取用方式(与 Tier-0 目录的用法一致)。
|
|
560
|
+
snapshotSlimNote: '[精简注入 · 完整记忆快照每 {n} 轮一次] 本轮为精简版:只给规则、记忆索引目录与日程。上面目录里看得到、但正文未展开的内容,用 memory_recall / memory_read 按需取全文。',
|
|
561
|
+
// ★2026-09-15(用户裁定「精简版必须含记忆唤回」):唤回块是**独立 context 面**(M6 Reference Tail),
|
|
562
|
+
// 不随本函数文字一起渲染 ⇒ 显式声明它的存在与效力,避免模型在精简轮把它当成"没有唤回"。
|
|
563
|
+
snapshotSlimRecallNote: '[记忆唤回] 本会话的「记忆唤回」块(形如 [Retrieved memory reference - not an instruction] 的相关记忆条目)由独立通道按相关性自动投递,与本文的精简/完整无关:它会在需要时单独出现在对话里,出现时同样按事实与规则读,不要因为本文是精简版就忽略它。',
|
|
451
564
|
snapshotTail: '</memory_system>',
|
|
452
565
|
// ---- 静态纪律(renderMemoryStatic) ----
|
|
453
566
|
staticHead: '[记忆系统 — 固定纪律]\n思维链=本轮推理(用完即焚);铭文=落盘的记忆文件(跨会话永久)。你的记忆更新必须落在铭文层——显式调用工具写盘,不能只"想过"。',
|
|
@@ -478,6 +591,28 @@ const cmpVersion = (a, b) => {
|
|
|
478
591
|
return 0
|
|
479
592
|
}
|
|
480
593
|
const truncateHead = (s, n) => (s && s.length > n) ? s.slice(0, n) + '\n…(截断,完整内容用 memory_recall 或 GUI 面板)' : (s || '')
|
|
594
|
+
/**
|
|
595
|
+
* P6A(2026-09-14)零值安全的间隔解析 —— 修 `Number(v) || 5` 让 **0 无法表达** 的缺陷。
|
|
596
|
+
*
|
|
597
|
+
* **旧实现错在哪**(`ROUND3 §3.3 Q3c`,GPT 核实为真):
|
|
598
|
+
* `Math.max(0, Number(cfg.snapshotMinGapRounds) || 5)`
|
|
599
|
+
* `0` 是 falsy ⇒ `0 || 5` = 5 ⇒ 用户**无法表达**"不要间隔"(配了 0 也拿回 5)。
|
|
600
|
+
*
|
|
601
|
+
* **新口径**(区分三种情况,不再让它们撞在一起):
|
|
602
|
+
* - 合法数值(含 **0**,以及字符串 '0')⇒ 取其值,钳到 [0, 1000];
|
|
603
|
+
* - 缺失 / 空串 / 非数值 / NaN / 负数 ⇒ **回退默认值**(由调用方传入,当前 1);
|
|
604
|
+
* - 显式 `null`/`undefined` ⇒ 同上回退。
|
|
605
|
+
*
|
|
606
|
+
* 纯函数、无副作用(`renderMemoryDynamic` 与注入调用方共用,保证口径唯一)。
|
|
607
|
+
*/
|
|
608
|
+
export function parseGapRoundsPre(value, fallback = 1) {
|
|
609
|
+
if (value === null || value === undefined) return fallback
|
|
610
|
+
if (typeof value === 'string' && value.trim() === '') return fallback
|
|
611
|
+
const n = Number(value)
|
|
612
|
+
if (!Number.isFinite(n) || n < 0) return fallback
|
|
613
|
+
return Math.min(1000, Math.floor(n))
|
|
614
|
+
}
|
|
615
|
+
|
|
481
616
|
/** M-CM1·精修:行边界截断——预算内切到最后一个完整行,不把半行文字灌进上下文(白板/账本专用)。 */
|
|
482
617
|
const truncateLinesBounded = (s, n) => {
|
|
483
618
|
if (!s || s.length <= n) return s || ''
|
|
@@ -780,6 +915,20 @@ class SessionRuntimeStore {
|
|
|
780
915
|
return runtime
|
|
781
916
|
}
|
|
782
917
|
|
|
918
|
+
/**
|
|
919
|
+
* **只读**查询(2026-09-14 P0 新增):拿已存在的 runtime,**绝不创建**。
|
|
920
|
+
* 见 `MemoryEngine.peekRuntime` 的注释——`get()` 的隐式创建会在 `await` 之后
|
|
921
|
+
* 复活已 dispose 的 runtime(实测:`runtime B survived dispose`)。
|
|
922
|
+
* 只读场景(读版本号、读游标)必须走这里。
|
|
923
|
+
*/
|
|
924
|
+
peek(agent) {
|
|
925
|
+
if (!agent || typeof agent !== 'object') return undefined
|
|
926
|
+
const direct = this._byAgent.get(agent)
|
|
927
|
+
if (direct) return direct
|
|
928
|
+
const identity = identityOfAgent(agent)
|
|
929
|
+
return identity ? this._byIdentity.get(identity) : undefined
|
|
930
|
+
}
|
|
931
|
+
|
|
783
932
|
dispose(agent) {
|
|
784
933
|
if (!agent || typeof agent !== 'object') return false
|
|
785
934
|
const runtime = this._byAgent.get(agent)
|
|
@@ -884,10 +1033,67 @@ class MemoryEngine {
|
|
|
884
1033
|
return this._runtimeContext.getStore() || this.runtimes.get(undefined)
|
|
885
1034
|
}
|
|
886
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* **只读**查询:拿已存在的 runtime,**绝不创建**。
|
|
1038
|
+
*
|
|
1039
|
+
* 为什么必须与 `get()` 分开(2026-09-14 P0 实测踩坑):`get()` 在找不到时会
|
|
1040
|
+
* `createSessionRuntime()` 并把 identity 重新登记进 `_byIdentity` / `_all`。
|
|
1041
|
+
* 任何**在 `await` 之后**对已 dispose agent 调 `runtimeFor()` 的代码,都会因此
|
|
1042
|
+
* **复活一个已销毁的 runtime**——实测病症:`smoke-test-context-observer.mjs` 报
|
|
1043
|
+
* `Error: runtime B survived dispose`(P3f dispose cleanup 段)。
|
|
1044
|
+
* 这正是本项目硬纪律「任何 `await` 之后再取 runtime/agent 句柄都可能复活已 dispose 的对象」
|
|
1045
|
+
* 的字面复现;T0-2 首版在 `buildTierLayerInjection` 里用了 `runtimeFor(agent)` 就踩了它。
|
|
1046
|
+
*
|
|
1047
|
+
* 需要"读一下当前 runtime 的版本号"这类**热路径只读**需求时,一律用本方法。
|
|
1048
|
+
*/
|
|
1049
|
+
peekRuntime(agent) {
|
|
1050
|
+
try {
|
|
1051
|
+
const cur = this._runtimeContext.getStore()
|
|
1052
|
+
if (cur && (!agent || cur.agent === agent)) return cur.disposed ? undefined : cur
|
|
1053
|
+
const r = this.runtimes.peek(agent)
|
|
1054
|
+
return r && !r.disposed ? r : undefined
|
|
1055
|
+
} catch (_) { return undefined }
|
|
1056
|
+
}
|
|
1057
|
+
|
|
887
1058
|
runtimeFor(agent) {
|
|
888
1059
|
return this.runtimes.get(agent)
|
|
889
1060
|
}
|
|
890
1061
|
|
|
1062
|
+
/**
|
|
1063
|
+
* ★2026-09-15(用户裁定 · 并入 P1):**按 agent 立即注册 paths 快照**,修 `no-paths-captured` 降级。
|
|
1064
|
+
*
|
|
1065
|
+
* 病症(本机实测证据):诊断日志出现 `ctx-host drop: no-paths-captured cv=1 key=session:…`
|
|
1066
|
+
* —— 21 次全部 **cv=1**(历史会话回放形态),且 key 形如 `session:session-aa9ba629-…`
|
|
1067
|
+
* (sessionId 本身带前缀 ⇒ 双前缀)。表现:子会话/子代理/历史会话的注入头工作区显示 `(未知)`、
|
|
1068
|
+
* 证据层与语义命中全空。
|
|
1069
|
+
*
|
|
1070
|
+
* 根因(两点,均已核对现码):
|
|
1071
|
+
* ① `refreshAll`(`:8014`)只在 `agent/session-start` / `agent/turn-stopping` 触发,
|
|
1072
|
+
* **历史会话被观测(replay)时不会为它单独跑一次 refresh** ⇒ 该 runtime 永远没被 capturePaths。
|
|
1073
|
+
* ② `refreshAll` 内部把 capturePaths 挂在 `engine.state.ws` 门后(`:8017-8021`)——`state` 是
|
|
1074
|
+
* **单例共享对象**(`_doRefresh` 每次覆盖,`:3669`),刷新失败/竞态时门恒假 ⇒ 静默不注册。
|
|
1075
|
+
*
|
|
1076
|
+
* 修法语义:**子会话/子代理与父会话同属一个工作区 ⇒ 路径天然相同**,无需各自跑一遍
|
|
1077
|
+
* `resolvePaths`(那是 IO)。因此这里只做「把**已解析好**的当前工作区 paths 按**该 agent 自己的
|
|
1078
|
+
* runtime.key** 注册一次」——纯内存、零 IO、幂等,可任意时机调用。
|
|
1079
|
+
*
|
|
1080
|
+
* 边界(不做过度承诺):`this.state.ws` 尚未就绪时返回 false(不猜测路径、不造默认值);
|
|
1081
|
+
* 调用方据此仍可如实降级。跨工作区场景下,本方法注册的是"当前工作区"的 paths —— 与
|
|
1082
|
+
* `_doRefresh` 覆盖 `state` 的行为**同源**,不引入新的错配面。
|
|
1083
|
+
*/
|
|
1084
|
+
capturePathsFor(agent) {
|
|
1085
|
+
try {
|
|
1086
|
+
if (!agent || !this.state || !this.state.ws) return false
|
|
1087
|
+
const rt = this.runtimeFor(agent)
|
|
1088
|
+
const key = rt && rt.key
|
|
1089
|
+
if (!key) return false
|
|
1090
|
+
if (this._shadowHost) this._shadowHost.capturePaths(key, this.state)
|
|
1091
|
+
if (this._contextHost) this._contextHost.capturePaths(key, this.state)
|
|
1092
|
+
if (this._activationHost) this._activationHost.capturePaths(key, this.state)
|
|
1093
|
+
return true
|
|
1094
|
+
} catch (e) { return false }
|
|
1095
|
+
}
|
|
1096
|
+
|
|
891
1097
|
stateFor(agent) {
|
|
892
1098
|
return this.runtimeFor(agent || this.currentRuntime().agent).state
|
|
893
1099
|
}
|
|
@@ -1407,17 +1613,44 @@ class MemoryEngine {
|
|
|
1407
1613
|
|
|
1408
1614
|
|
|
1409
1615
|
// ---------- 配置 ----------
|
|
1616
|
+
/** 配置合并单源(loadConfig / loadConfigSync 共用, 防双源漂移)。 */
|
|
1617
|
+
_mergeConfigPre(parsed) {
|
|
1618
|
+
this.config = { ...DEFAULT_CONFIG, ...(parsed && typeof parsed === 'object' ? parsed : {}) }
|
|
1619
|
+
this.configLoaded = true
|
|
1620
|
+
return this.config
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* 同步配置加载(2026-09-16, 修 BUG-1/BUG-11 —— 注册闸门结构性恒假)。
|
|
1625
|
+
*
|
|
1626
|
+
* **为什么必须同步**: `apply(ctx, config)` **不是 async**, 且 cordis 宿主 runner 的调用点是
|
|
1627
|
+
* `return objectPlugin.apply(sandboxContext(ctx, reportFailure), config)` —— **不 await 返回值**
|
|
1628
|
+
* (见 `dsh-cordis-host-runner/lib/index.js` 的 `guardedPlugin`)。所以:
|
|
1629
|
+
* ① 不能在 `apply` 内 `await loadConfig()`;
|
|
1630
|
+
* ② 也不能把 `apply` 改成 `async`(工具注册会落进微任务, 而插件已被判定加载完成)。
|
|
1631
|
+
* 而插件必须**在构建 tools 数组之前**拿到真配置, 否则 `resolveBoardModePre(engine.config.boardMode)`
|
|
1632
|
+
* 读到的是构造期默认值 `'legacy'` ⇒ graph 档**永远**不注册(与配置里写什么都没关系)。
|
|
1633
|
+
*
|
|
1634
|
+
* 与 `loadConfig` 共用 `_mergeConfigPre`, 保证两处口径一致。
|
|
1635
|
+
*/
|
|
1636
|
+
loadConfigSync() {
|
|
1637
|
+
try {
|
|
1638
|
+
const raw = readFileSync(this._configPath, 'utf8')
|
|
1639
|
+
return this._mergeConfigPre(JSON.parse(raw))
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
|
|
1642
|
+
return this._mergeConfigPre(null)
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1410
1646
|
async loadConfig() {
|
|
1411
1647
|
try {
|
|
1412
1648
|
const raw = await readFile(this._configPath, 'utf8')
|
|
1413
|
-
|
|
1414
|
-
this.config = { ...DEFAULT_CONFIG, ...(parsed && typeof parsed === 'object' ? parsed : {}) }
|
|
1649
|
+
return this._mergeConfigPre(JSON.parse(raw))
|
|
1415
1650
|
} catch (e) {
|
|
1416
1651
|
if (e && e.code !== 'ENOENT') this._readError = String(e && e.message ? e.message : e)
|
|
1417
|
-
this.
|
|
1652
|
+
return this._mergeConfigPre(null)
|
|
1418
1653
|
}
|
|
1419
|
-
this.configLoaded = true
|
|
1420
|
-
return this.config
|
|
1421
1654
|
}
|
|
1422
1655
|
|
|
1423
1656
|
async saveConfig(patch) {
|
|
@@ -1699,13 +1932,26 @@ class MemoryEngine {
|
|
|
1699
1932
|
/** 白板重写:旧 PLAN.md 有实质内容且与新内容不同时,先归档到 handoff/archive/PLAN-<ts>.md 再写新快照(保留更改历史)。
|
|
1700
1933
|
* P7(2026-09-09)白板老化:新内容中标题命中 /历史|踩坑|流水线要点/ 的顶层「## 」节移入 handoff/archive/PLAN-history-<ts>.md
|
|
1701
1934
|
* (同秒多写 -b/-c 后缀防撞),PLAN.md 只留当前状态;内容只移动不删除;无匹配节 → 行为与旧版完全一致。 */
|
|
1702
|
-
async writePlanSnapshot(projectDir, content) {
|
|
1935
|
+
async writePlanSnapshot(projectDir, content, opts) {
|
|
1703
1936
|
const dir = path.join(projectDir, 'handoff')
|
|
1704
1937
|
const planPath = path.join(dir, 'PLAN.md')
|
|
1705
1938
|
try {
|
|
1706
|
-
|
|
1939
|
+
const incoming = String(content || '')
|
|
1707
1940
|
const existing = await this.readTextSafe(planPath)
|
|
1708
|
-
|
|
1941
|
+
// ── P0 写入门(2026-09-14):格式检查(白板适配器)→ 判据门 → 共同提交门 ──
|
|
1942
|
+
// 顺序有意为之:**判据门在前、保护门在后**。理由:判据门是"可选质量门"(可被 criteriaGate=false
|
|
1943
|
+
// 退掉),保护门是"共同保护"(绕不过)。若顺序反了,关掉质量门会顺带跳过保护门。
|
|
1944
|
+
const gate = this.checkMutationPre({
|
|
1945
|
+
target: 'plan',
|
|
1946
|
+
beforeText: existing,
|
|
1947
|
+
afterText: incoming,
|
|
1948
|
+
projectDir,
|
|
1949
|
+
relPath: 'handoff/PLAN.md',
|
|
1950
|
+
skipCriteria: !!(opts && opts.skipCriteria),
|
|
1951
|
+
})
|
|
1952
|
+
if (!gate.ok) return { ok: false, error: gate.error, gate: gate.gate, report: gate.report }
|
|
1953
|
+
let archived = ''
|
|
1954
|
+
if (existing && existing.trim() && existing.trim() !== incoming.trim()) {
|
|
1709
1955
|
const archDir = path.join(dir, 'archive')
|
|
1710
1956
|
const archPath = path.join(archDir, 'PLAN-' + handoffStamp() + '.md')
|
|
1711
1957
|
await mkdir(archDir, { recursive: true })
|
|
@@ -1713,7 +1959,7 @@ class MemoryEngine {
|
|
|
1713
1959
|
archived = archPath
|
|
1714
1960
|
}
|
|
1715
1961
|
// P7 白板老化(确定性节分类;移动不删除;全部节都命中时 fail-soft 放弃老化原样写入)
|
|
1716
|
-
let finalContent =
|
|
1962
|
+
let finalContent = incoming
|
|
1717
1963
|
let movedHistory = 0, historyPath = ''
|
|
1718
1964
|
const sections = finalContent.split(/(?=^## )/m)
|
|
1719
1965
|
if (sections.length > 1) {
|
|
@@ -1736,8 +1982,27 @@ class MemoryEngine {
|
|
|
1736
1982
|
historyPath = hp
|
|
1737
1983
|
}
|
|
1738
1984
|
}
|
|
1985
|
+
// ★基线写入(无条件) —— 必须在锚点处理之前。
|
|
1986
|
+
// 教训(2026-09-16 自查回归): 加锚点块时若把它当成"唯一写盘点", 会让无锚点变化的场景**一个字节都不写**
|
|
1987
|
+
// (p7-write-fix 的 G2 白板老化用例随即 ENOENT: PLAN.md 不存在)。锚点只是"写完之后的再加工", 不是写盘开关。
|
|
1739
1988
|
await this.writeFullRaw(planPath, finalContent)
|
|
1740
|
-
|
|
1989
|
+
// WB-FORMAT-CONVENTION §2 锚点写入(仅 graph 档; legacy 档逐字节不变)
|
|
1990
|
+
// 根因修复: 白板内容凭锚点自动进 L0 检索语料 —— 此前只把 id 写进 sidecar, Markdown 正文零锚点,
|
|
1991
|
+
// 导致 §2 承诺的收益恒为零(规划 §5「已知现状缺口: 规范已批准、代码从未实现」)。
|
|
1992
|
+
let written = finalContent
|
|
1993
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
1994
|
+
try {
|
|
1995
|
+
const wsKey = this.wbWsKeyPre(projectDir)
|
|
1996
|
+
const anchored = applyAnchorsPre(wsKey, 'handoff/PLAN.md', finalContent)
|
|
1997
|
+
if (anchored.text !== finalContent) { written = anchored.text; await this.writeFullRaw(planPath, written) }
|
|
1998
|
+
} catch (_) { /* fail-soft: 锚点绝不阻塞主写入 */ }
|
|
1999
|
+
}
|
|
2000
|
+
// P2 sidecar(仅 boardMode=graph; fail-soft, 不阻塞主写入)
|
|
2001
|
+
await this.writeSidecarEntryPre(projectDir, 'handoff/PLAN.md', written, '白板 PLAN', '')
|
|
2002
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2003
|
+
await this.appendSidecarEventPre(projectDir, { actor: 'engine', event: 'written', target: 'PLAN.md', details: { chars: written.length, movedHistory: movedHistory || 0 } })
|
|
2004
|
+
}
|
|
2005
|
+
return { ok: true, path: planPath, archived, movedHistory: movedHistory || undefined, historyPath: historyPath || undefined, final: written }
|
|
1741
2006
|
} catch (e) {
|
|
1742
2007
|
return { ok: false, error: String((e && e.message) || e) }
|
|
1743
2008
|
}
|
|
@@ -1746,20 +2011,342 @@ class MemoryEngine {
|
|
|
1746
2011
|
/** 交接账本:每次一篇新文件(append-only 语料,不并入日志/笔记)。同秒多写用 -b/-c 后缀防撞且保持字典序=时间序。
|
|
1747
2012
|
* P7(2026-09-09):标题行归属写入函数 —— 模型 content 自带的「交接账本」标题行一律剔除(实测最近 8 篇中
|
|
1748
2013
|
* 3 篇双标题且时间戳互相矛盾),正文零丢失仅去冗余标题元数据;剔除后正文经返回值 clean 带回供状态字段同步。 */
|
|
1749
|
-
async writeHandoffLedger(projectDir, content) {
|
|
2014
|
+
async writeHandoffLedger(projectDir, content, opts) {
|
|
1750
2015
|
const dir = path.join(projectDir, 'handoff')
|
|
1751
2016
|
const base = 'handoff-' + handoffStamp()
|
|
1752
2017
|
let p = path.join(dir, base + '.md')
|
|
1753
2018
|
try {
|
|
1754
|
-
|
|
2019
|
+
// 标题行归属写入函数:模型 content 自带的「交接账本」标题行一律剔除(旧行为,保持不变)
|
|
1755
2020
|
const clean = String(content || '').split(/\r?\n/).filter((l) => !/^#\s*交接账本/.test(l)).join('\n').replace(/^\n+/, '')
|
|
1756
|
-
|
|
1757
|
-
|
|
2021
|
+
const fullText = '# 交接账本 · ' + this.memToday() + ' ' + nowHm() + '\n\n' + clean
|
|
2022
|
+
// ── P0 判据门(2026-09-14):账本用 H1–H4/S1–S4(**不能套 PLAN 的判据**,v2 修正)──
|
|
2023
|
+
// 无 beforeText ⇒ 不跑共同保护门(一篇新账本不涉及"丢卡"语义;保护门针对"重写既有目标")。
|
|
2024
|
+
// `opts.skipCriteria`(水位骨架 A6 降级路径专用)**只能跳过判据门,跳不过保护门**。
|
|
2025
|
+
const gate = this.checkMutationPre({
|
|
2026
|
+
target: 'handoff',
|
|
2027
|
+
beforeText: '',
|
|
2028
|
+
afterText: fullText,
|
|
2029
|
+
projectDir,
|
|
2030
|
+
relPath: 'handoff/' + base + '.md',
|
|
2031
|
+
skipCriteria: !!(opts && opts.skipCriteria),
|
|
2032
|
+
})
|
|
2033
|
+
if (!gate.ok) return { ok: false, error: gate.error, gate: gate.gate, report: gate.report }
|
|
2034
|
+
for (let c = 98; c <= 122 && existsSync(p); c++) { p = path.join(dir, base + '-' + String.fromCharCode(c) + '.md') }
|
|
2035
|
+
// WB-FORMAT-CONVENTION §2 锚点写入(仅 graph 档) —— 账本同样按 `##` 小节切卡
|
|
2036
|
+
let writtenLedger = fullText
|
|
2037
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2038
|
+
try {
|
|
2039
|
+
const wsKey = this.wbWsKeyPre(projectDir)
|
|
2040
|
+
const anchored = applyAnchorsPre(wsKey, 'handoff/' + path.basename(p), fullText)
|
|
2041
|
+
if (anchored.text !== fullText) writtenLedger = anchored.text
|
|
2042
|
+
} catch (_) { /* fail-soft */ }
|
|
2043
|
+
}
|
|
2044
|
+
await this.writeFullRaw(p, writtenLedger)
|
|
2045
|
+
// P2 sidecar(仅 boardMode=graph; fail-soft, 不阻塞主写入)
|
|
2046
|
+
await this.writeSidecarEntryPre(projectDir, 'handoff/' + path.basename(p), writtenLedger, '交接账本 ' + path.basename(p), '')
|
|
2047
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2048
|
+
await this.appendSidecarEventPre(projectDir, { actor: 'engine', event: 'written', target: path.basename(p), details: { chars: writtenLedger.length, criteria: (gate.criteria && gate.criteria.status) || 'unknown' } })
|
|
2049
|
+
}
|
|
2050
|
+
return { ok: true, path: p, clean, criteria: gate.criteria }
|
|
1758
2051
|
} catch (e) {
|
|
1759
2052
|
return { ok: false, error: String((e && e.message) || e) }
|
|
1760
2053
|
}
|
|
1761
2054
|
}
|
|
1762
2055
|
|
|
2056
|
+
/**
|
|
2057
|
+
* P0 写入保护统一入口(2026-09-14)。
|
|
2058
|
+
*
|
|
2059
|
+
* **为什么把两个门放一个函数**:三条写入路径(工具 `memory_note` / 水位骨架直调 /
|
|
2060
|
+
* 刷新仪式产物)都必须经过**同一个**入口,"共同保护"才有意义 —— 这正是 `T0-8B` 要锁的
|
|
2061
|
+
* (三条路径都不能绕过)。
|
|
2062
|
+
*
|
|
2063
|
+
* **两个门职责不同,不可合并**(ROUND3 §3.1 第 1 条):
|
|
2064
|
+
* ① **判据门**(`checkHandoffCriteriaPre` / `checkPlanCriteriaPre`)= 可选质量门,
|
|
2065
|
+
* `criteriaGate=false` 可退掉。判据不能张冠李戴:账本用 H1–H4/S1–S4,PLAN 用 P-H1/P-H2/P-S1。
|
|
2066
|
+
* ② **共同保护门**(`validateMutationBoundaryPre`)= 丢卡 / 用户区 / 重复 id,
|
|
2067
|
+
* **无条件生效**,`criteriaGate=false` 与"骨架 fail-soft"都绕不过(ROUND3 §3.7 第 4 条)。
|
|
2068
|
+
*
|
|
2069
|
+
* 只在**重写既有目标**(beforeText 非空)时跑保护门:首建没有"前后比对"可言。
|
|
2070
|
+
*
|
|
2071
|
+
* @returns {{ok:boolean, gate?:string, error?:string, report?:object, criteria?:object}}
|
|
2072
|
+
*/
|
|
2073
|
+
/** WB-GRAPH P2(2026-09-16, wb_sidecar_v1): 白板结构化 sidecar —— 仅 boardMode='graph' 时生效。
|
|
2074
|
+
* 在两咽喉成功写盘后调用(方案 P2-1: writePlanSnapshot/writeHandoffLedger 返回值处)。
|
|
2075
|
+
* index.json 完全可重建(真源=Markdown 文件本身); 任何错误 fail-soft(绝不影响主写入路径)。
|
|
2076
|
+
*
|
|
2077
|
+
* 2026-09-16 修复(BUG-10): relPath 与 title 一律经 `wbRefPre` 规范化 —— 此前 write 侧传
|
|
2078
|
+
* `path.relative()`(Windows 得反斜杠)+basename(含 `handoff-` 前缀), rebuild 侧传正斜杠+去前缀,
|
|
2079
|
+
* 同一条目算出**两个 id**, 破坏「完全可重建」。现在两侧共用同一构造口径。 */
|
|
2080
|
+
async writeSidecarEntryPre(projectDir, relPath, text, title, ts) {
|
|
2081
|
+
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) return
|
|
2082
|
+
try {
|
|
2083
|
+
const wsKey = this.wbWsKeyPre(projectDir)
|
|
2084
|
+
const ref = wbRefPre(relPath, title)
|
|
2085
|
+
const entry = buildSidecarEntryPre({ workspaceKey: wsKey, relPath: ref.relPath, text, title: ref.title, ts: ts || this.memToday() + ' ' + nowHm() })
|
|
2086
|
+
const sideDir = path.join(projectDir, 'handoff')
|
|
2087
|
+
await mkdir(sideDir, { recursive: true })
|
|
2088
|
+
const idxPath = path.join(sideDir, 'index.json')
|
|
2089
|
+
let index = null
|
|
2090
|
+
try { const prev = JSON.parse(await readFile(idxPath, 'utf8')); if (prev && Array.isArray(prev.entries)) index = prev } catch (_) { /* 丢失即重建 */ }
|
|
2091
|
+
if (!index) { await this.rebuildSidecarIndexPre(projectDir); return }
|
|
2092
|
+
const i = index.entries.findIndex((e) => e.id === entry.id)
|
|
2093
|
+
if (i >= 0) index.entries[i] = entry; else index.entries.push(entry)
|
|
2094
|
+
await this._writeSidecarIndexPre(projectDir, index)
|
|
2095
|
+
} catch (_) { /* fail-soft: sidecar 永不阻塞主写入 */ }
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
/** 写 index.json + 追加 events.jsonl(P2-1「criteria.passed/warned 事件链」)。
|
|
2099
|
+
* events.jsonl 是 append-only 派生物, 缺失时退化为「以文件存在为准」(方案 §3.3)。 */
|
|
2100
|
+
async _writeSidecarIndexPre(projectDir, index) {
|
|
2101
|
+
const sideDir = path.join(projectDir, 'handoff')
|
|
2102
|
+
const entries = Array.isArray(index.entries) ? index.entries : []
|
|
2103
|
+
const fresh = rebuildSidecarIndexPre(this.wbWsKeyPre(projectDir), entries.map((e) => ({
|
|
2104
|
+
relPath: e.source, text: '', title: e.title, ts: e.ts, kind: e.kind, mtime: e.mtime, criteria: e.criteria,
|
|
2105
|
+
})))
|
|
2106
|
+
// 保留调用方刚写入的富条目(rebuild 只是补倒排, 正文预览以已有条目为准)
|
|
2107
|
+
fresh.entries = entries
|
|
2108
|
+
await mkdir(sideDir, { recursive: true })
|
|
2109
|
+
await writeFile(path.join(sideDir, 'index.json'), JSON.stringify(fresh, null, 2), 'utf8')
|
|
2110
|
+
return fresh
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/** 追加一条 sidecar 事件(events.jsonl, append-only)。fail-soft。 */
|
|
2114
|
+
async appendSidecarEventPre(projectDir, ev) {
|
|
2115
|
+
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) return
|
|
2116
|
+
try {
|
|
2117
|
+
const sideDir = path.join(projectDir, 'handoff')
|
|
2118
|
+
await mkdir(sideDir, { recursive: true })
|
|
2119
|
+
const row = JSON.stringify({ ts: nowHm(), date: this.memToday(), ...(ev || {}) })
|
|
2120
|
+
await appendFile(path.join(sideDir, 'events.jsonl'), row + '\n', 'utf8')
|
|
2121
|
+
} catch (_) { /* fail-soft */ }
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* P2 重建: 从 PLAN.md + 账本白名单 + 归档确定性重建 index.json(丢卡自愈)。
|
|
2126
|
+
* 修复(BUG-10): 账本走 `wbRefPre` 同一口径; 归档纳入 versions 链; 白名单补 archive/。
|
|
2127
|
+
*/
|
|
2128
|
+
async rebuildSidecarIndexPre(projectDir) {
|
|
2129
|
+
const sideDir = path.join(projectDir, 'handoff')
|
|
2130
|
+
const docs = await this._collectWhiteboardDocsPre(projectDir).catch(() => [])
|
|
2131
|
+
const index = rebuildSidecarIndexPre(this.wbWsKeyPre(projectDir), docs)
|
|
2132
|
+
try { await mkdir(sideDir, { recursive: true }); await writeFile(path.join(sideDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8') } catch (_) {}
|
|
2133
|
+
return index
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
/** projectDir → workspaceKey(与 projectDirOf 同源: 目录名就是 wsKey(ws), 故 basename 即正确值)。
|
|
2137
|
+
* 2026-09-16 修 BUG-4: 此前写 `this.wsDirKey ? …`(该属性不存在) 属死分支, 恰好给出正确值; 现改为显式口径 + 注释说明恒等依据。 */
|
|
2138
|
+
wbWsKeyPre(projectDir) {
|
|
2139
|
+
return path.basename(String(projectDir || '')) || 'default'
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
/**
|
|
2143
|
+
* graph 档是否生效(**容错判定**, 2026-09-16)。
|
|
2144
|
+
*
|
|
2145
|
+
* 为什么不直接用 `this.config.boardMode`: 两咽喉(`writePlanSnapshot`/`writeHandoffLedger`)会被
|
|
2146
|
+
* **抽取式套件**用 `bindMethod` 沙箱直接执行, 沙箱里的 `this` 是裸 fake 对象, 通常**没有 `config`**
|
|
2147
|
+
* ⇒ `this.config.boardMode` 会抛 TypeError, 被 catch 吞成 `{ok:false}` ⇒ 测试假红
|
|
2148
|
+
* (本轮 p7-write-fix / handoff-pre 两次踩到, 与"新增方法须补桩"同源但更隐蔽: 这次缺的是**属性**不是方法)。
|
|
2149
|
+
*
|
|
2150
|
+
* 容错语义: 拿不到 config 时**按 legacy 处理**——即"未配置 = 不启用新功能", 与 DEFAULT_CONFIG 一致,
|
|
2151
|
+
* 既不改变旧行为, 也不让沙箱缺桩导致整体失败。
|
|
2152
|
+
*/
|
|
2153
|
+
wbGraphEnabledPre() {
|
|
2154
|
+
try {
|
|
2155
|
+
return resolveBoardModePre(this.config && this.config.boardMode).graphEnabled
|
|
2156
|
+
} catch (_) { return false }
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** 读 index.json(缺失即重建); graph 档外的调用方自行闸门。 */
|
|
2160
|
+
async _loadSidecarIndexPre(projectDir) {
|
|
2161
|
+
const idxPath = path.join(projectDir, 'handoff', 'index.json')
|
|
2162
|
+
try { return JSON.parse(await readFile(idxPath, 'utf8')) } catch (_) { return await this.rebuildSidecarIndexPre(projectDir) }
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/** P3(仅 boardMode='graph' 注册工具): 正向遍历——按 tag 展开条目。
|
|
2166
|
+
* sidecar 缺失时先重建; 仍无命中则**回落词法检索**(方案 P3-1 fail-soft, 修复 BUG-8)。
|
|
2167
|
+
* 修复(BUG-2): 路径走 `resolvePaths(agent)`(权威口径), 不再用不存在的 `agent.cwd`。 */
|
|
2168
|
+
async expandWhiteboardByTagPre(agent, tag, limit) {
|
|
2169
|
+
try {
|
|
2170
|
+
const p = await this.resolvePaths(agent)
|
|
2171
|
+
const projectDir = p.projectDir
|
|
2172
|
+
const index = await this._loadSidecarIndexPre(projectDir)
|
|
2173
|
+
const r = expandByTagPre(index, tag, limit)
|
|
2174
|
+
if (r.total) {
|
|
2175
|
+
return { mode: 'graph', tag: String(tag || ''), total: r.total, truncated: r.truncated, remaining: r.remaining, entries: r.entries, hint: r.hint }
|
|
2176
|
+
}
|
|
2177
|
+
// fail-soft 回落: 索引里没有该 tag ⇒ 用词法检索白板语料兜底(P3-1)
|
|
2178
|
+
const terms = String(tag || '').split(/[\s,::]+/).filter((t) => t.length >= 2)
|
|
2179
|
+
if (terms.length) {
|
|
2180
|
+
const hits = await this.searchHandoffCorpus(terms, Math.min(Number(limit) || 10, 4), p).catch(() => [])
|
|
2181
|
+
if (hits && hits.length) {
|
|
2182
|
+
return {
|
|
2183
|
+
mode: 'graph-lexical-fallback', tag: String(tag || ''), total: hits.length,
|
|
2184
|
+
note: 'index.json 无该 tag; 以下为白板语料词法命中(可先检查账本是否写了 tag:xxx 标记)',
|
|
2185
|
+
entries: hits.map((h) => ({ source: h.where, preview: (h.matches || []).join(' / ').slice(0, 200) })),
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return { mode: 'graph', tag: String(tag || ''), total: 0, entries: [], note: '无匹配条目; 可用 "*" 看全部, 或先检查账本行内是否写了 tag:xxx / type:xxx / topic:xxx 标记。' }
|
|
2190
|
+
} catch (e) { return { mode: 'graph', error: String((e && e.message) || e) } }
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** P3(仅 boardMode='graph' 注册工具): 反向回溯——按 id 找条目与相邻线索。
|
|
2194
|
+
* 修复(BUG-2): 路径走 resolvePaths; 返回契约已由 traceByIdPre 补齐 cues/tags/neighbors/versions/hint。 */
|
|
2195
|
+
async traceWhiteboardByIdPre(agent, id) {
|
|
2196
|
+
try {
|
|
2197
|
+
const p = await this.resolvePaths(agent)
|
|
2198
|
+
const index = await this._loadSidecarIndexPre(p.projectDir)
|
|
2199
|
+
return traceByIdPre(index, id)
|
|
2200
|
+
} catch (e) { return { found: false, error: String((e && e.message) || e) } }
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
/** P2-3: 白板 tag 地图(供 P2-4 注入导航层与 GUI 用)。fail-soft, 无 sidecar 返回空串。
|
|
2204
|
+
* 第二参可选传入已解析的路径对象(注入端已持有 p, 避免二次 resolvePaths 的 IO)。 */
|
|
2205
|
+
async whiteboardTagMapPre(agent, resolved) {
|
|
2206
|
+
try {
|
|
2207
|
+
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) return ''
|
|
2208
|
+
const p = resolved && resolved.projectDir ? resolved : await this.resolvePaths(agent)
|
|
2209
|
+
const index = await this._loadSidecarIndexPre(p.projectDir)
|
|
2210
|
+
const byTag = index && index.by_tag ? index.by_tag : {}
|
|
2211
|
+
const rows = Object.keys(byTag)
|
|
2212
|
+
.filter((t) => !t.startsWith('sec:'))
|
|
2213
|
+
.map((t) => ({ t, n: byTag[t].length }))
|
|
2214
|
+
.sort((a, b) => b.n - a.n)
|
|
2215
|
+
.slice(0, 8)
|
|
2216
|
+
if (!rows.length) return ''
|
|
2217
|
+
return rows.map((r) => r.t + '×' + r.n).join('、')
|
|
2218
|
+
} catch (_) { return '' }
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* 白板看板载荷(2026-09-16「兼并 dsh-graph」)。
|
|
2223
|
+
*
|
|
2224
|
+
* 立场: 不搬 dsh-graph 插件(其领域模型是「目标/Attempt/Supervisor」, 与本项目白板语料不同),
|
|
2225
|
+
* 只吸收其**可视化形态**(列式泳道 + 状态徽章 + 卡片), 数据源仍是自己的 handoff/index.json。
|
|
2226
|
+
* ⇒ 零新插件、零新存储、零 profile 改动; 白板与看板同源同生, 由 boardMode 一档切换。
|
|
2227
|
+
*
|
|
2228
|
+
* 闸门: 仅 boardMode='graph' 返回看板数据; legacy 返回 { enabled:false, reason:'legacy-mode' }
|
|
2229
|
+
* —— 前端据此保持旧文字白板(legacy 逐字节不变)。
|
|
2230
|
+
* fail-soft: sidecar 缺失由 _loadSidecarIndexPre 自愈重建; 全程 try/catch 不阻塞面板。
|
|
2231
|
+
*/
|
|
2232
|
+
async kanbanBoardData(sessionId, opts = {}) {
|
|
2233
|
+
if (!this.configLoaded) { try { await this.loadConfig() } catch (e) {} }
|
|
2234
|
+
if (this.config.handoffEnabled === false) return { enabled: false, reason: 'handoff-disabled' }
|
|
2235
|
+
if (!(String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2236
|
+
return { enabled: false, reason: 'legacy-mode' }
|
|
2237
|
+
}
|
|
2238
|
+
try {
|
|
2239
|
+
let p, wsBound = true
|
|
2240
|
+
if (sessionId) {
|
|
2241
|
+
p = await this.resolvePathsForSession(sessionId)
|
|
2242
|
+
wsBound = p.wsBound !== false
|
|
2243
|
+
} else {
|
|
2244
|
+
p = (this.state.handoffDir || this.state.planPath)
|
|
2245
|
+
? { handoffDir: this.state.handoffDir || path.dirname(this.state.planPath), planPath: this.state.planPath, ws: this.state.ws, projectDir: this.state.projectDir }
|
|
2246
|
+
: await this.resolvePaths(undefined)
|
|
2247
|
+
}
|
|
2248
|
+
if (!wsBound) return { enabled: true, wsBound: false, lanes: [], stats: null }
|
|
2249
|
+
const projectDir = p.projectDir || path.dirname(p.handoffDir)
|
|
2250
|
+
// v2(2026-09-16): 数据源改为**小节级卡片** —— 按 §2 锚点契约「每个 ## 小节一张卡」切分。
|
|
2251
|
+
// v1 用文件级条目 ⇒ 一篇账本压成一根卡(实测 92 文件仅 92 条, 且 sectionOfPre 无 break
|
|
2252
|
+
// 导致整篇归一到最后一个 ## 标题, 三条泳道恒空)。改为小节级后实测 92 → 417 条,
|
|
2253
|
+
// 目标/进行中/失败与弯路/进度 四条泳道全部有内容。
|
|
2254
|
+
const docs = await this._collectWhiteboardDocsPre(projectDir)
|
|
2255
|
+
const cards = buildSectionCardsPre(this.wbWsKeyPre(projectDir), docs)
|
|
2256
|
+
const index = await this._loadSidecarIndexPre(projectDir)
|
|
2257
|
+
const kb = buildKanbanPre(index, { now: new Date().toISOString(), cards, perLaneCap: Number(opts.perLaneCap) || 0 })
|
|
2258
|
+
// 2026-09-16「双承载面」批: 同一份小节卡**同时**给出两种形态 ——
|
|
2259
|
+
// 列表视图(buildKanbanPre 的 lanes) 供窄容器(侧边面板 440px);
|
|
2260
|
+
// 矩阵视图(buildKanbanMatrixPre 的 rows×columns) 供宽容器(conversation.view 整页)。
|
|
2261
|
+
// 一个路由喂两种形态, 前端按容器宽度选用, 避免为选形态再打一次请求。
|
|
2262
|
+
const matrix = buildKanbanMatrixPre(cards, { now: new Date().toISOString() })
|
|
2263
|
+
return Object.assign({ enabled: true, wsBound: true, ws: p.ws || '', boardMode: 'graph', cardSource: 'section', matrix }, kb)
|
|
2264
|
+
} catch (e) {
|
|
2265
|
+
return { enabled: false, reason: 'error', error: String(e && e.message ? e.message : e) }
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* 读取白板/账本文档清单(看板与 sidecar 重建的**唯一读盘口径**)。
|
|
2271
|
+
* 返回 [{relPath, text, title, kind, mtime, ts}] —— 供小节切分与文件级重建共用。
|
|
2272
|
+
*/
|
|
2273
|
+
async _collectWhiteboardDocsPre(projectDir) {
|
|
2274
|
+
const sideDir = path.join(projectDir, 'handoff')
|
|
2275
|
+
const docs = []
|
|
2276
|
+
const planText = await this.readTextSafe(path.join(sideDir, 'PLAN.md'))
|
|
2277
|
+
if (planText) docs.push({ relPath: 'handoff/PLAN.md', text: planText, title: '白板 PLAN', ts: '', kind: 'plan' })
|
|
2278
|
+
const files = await readdir(sideDir).catch(() => [])
|
|
2279
|
+
for (const f of files.filter((x) => /^handoff-\d{8}-\d{6}.*\.md$/.test(x)).sort()) {
|
|
2280
|
+
const ref = wbRefPre('handoff/' + f, '交接账本 ' + f)
|
|
2281
|
+
const st = await stat(path.join(sideDir, f)).catch(() => null)
|
|
2282
|
+
docs.push({
|
|
2283
|
+
relPath: ref.relPath, text: await this.readTextSafe(path.join(sideDir, f)), title: ref.title,
|
|
2284
|
+
ts: f.slice(8, 12) + '-' + f.slice(12, 14) + '-' + f.slice(14, 16),
|
|
2285
|
+
kind: 'ledger', mtime: st ? Number(st.mtimeMs) : 0,
|
|
2286
|
+
})
|
|
2287
|
+
}
|
|
2288
|
+
const archNames = await readdir(path.join(sideDir, 'archive')).catch(() => [])
|
|
2289
|
+
for (const f of archNames.filter((x) => /\.md$/.test(x)).sort()) {
|
|
2290
|
+
const st = await stat(path.join(sideDir, 'archive', f)).catch(() => null)
|
|
2291
|
+
docs.push({
|
|
2292
|
+
relPath: 'handoff/archive/' + f, text: await this.readTextSafe(path.join(sideDir, 'archive', f)),
|
|
2293
|
+
title: f.replace(/\.md$/, ''), ts: '', kind: 'archive', mtime: st ? Number(st.mtimeMs) : 0,
|
|
2294
|
+
})
|
|
2295
|
+
}
|
|
2296
|
+
return docs
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
checkMutationPre(input = {}) {
|
|
2300
|
+
const o = input && typeof input === 'object' ? input : {}
|
|
2301
|
+
const target = String(o.target || 'other')
|
|
2302
|
+
const afterText = String(o.afterText == null ? '' : o.afterText)
|
|
2303
|
+
const beforeText = String(o.beforeText == null ? '' : o.beforeText)
|
|
2304
|
+
try {
|
|
2305
|
+
// ── ① 判据门(可选质量门;`skipCriteria` 只跳过它)──
|
|
2306
|
+
if (this.config.criteriaGate !== false && !(o.skipCriteria)) {
|
|
2307
|
+
const crit = target === 'handoff' ? checkHandoffCriteriaPre(afterText)
|
|
2308
|
+
: target === 'plan' ? checkPlanCriteriaPre(afterText) : null
|
|
2309
|
+
if (crit && !crit.ok) {
|
|
2310
|
+
return { ok: false, gate: 'criteria', error: criteriaRefusalTextPre(crit), report: crit, criteria: crit }
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
// ── ② 共同保护门(无条件;只在"重写既有目标"时有意义)──
|
|
2314
|
+
// 锚点完备性属白板线 P1/P2,**不在此拦截**:现存 PLAN.md 一个锚点都没有,
|
|
2315
|
+
// 硬判会让所有既有白板立刻写不进去(那会把"保护"变成"锁死")。
|
|
2316
|
+
// 保护门在**能证明丢卡**时生效:即 before 侧解析出了卡片集合,而 after 侧少了。
|
|
2317
|
+
if (target !== 'plan' && target !== 'handoff') return { ok: true }
|
|
2318
|
+
if (target === 'handoff') return { ok: true }
|
|
2319
|
+
if (!beforeText.trim()) return { ok: true }
|
|
2320
|
+
const projB = parseWhiteboardPre(beforeText, { kind: 'plan' })
|
|
2321
|
+
const projA = parseWhiteboardPre(afterText, { kind: 'plan' })
|
|
2322
|
+
const beforeIds = projB.cardIds
|
|
2323
|
+
const afterIds = projA.cardIds
|
|
2324
|
+
// before 侧没有卡片集合 ⇒ 本目标尚未采用锚点契约 ⇒ 无从判"丢卡"(fail-soft,不误拒)
|
|
2325
|
+
if (!beforeIds.length) {
|
|
2326
|
+
// 但仍要把"用户区被吞掉"这类可判的保护跑掉:只要 before 有受保护区域就查
|
|
2327
|
+
const protB = extractProtectedRegionsPre(projB)
|
|
2328
|
+
if (!protB.length) return { ok: true }
|
|
2329
|
+
}
|
|
2330
|
+
const res = validateMutationBoundaryPre({
|
|
2331
|
+
target,
|
|
2332
|
+
beforeIds,
|
|
2333
|
+
afterIds,
|
|
2334
|
+
protectedRegions: extractProtectedRegionsPre(projB),
|
|
2335
|
+
afterProtectedRegions: extractProtectedRegionsPre(projA),
|
|
2336
|
+
archivedIds: Array.isArray(o.archivedIds) ? o.archivedIds : [],
|
|
2337
|
+
strict: this.config.criteriaGate !== false,
|
|
2338
|
+
})
|
|
2339
|
+
if (!res.ok) {
|
|
2340
|
+
return { ok: false, gate: 'mutation', error: mutationRefusalTextPre(res), report: res }
|
|
2341
|
+
}
|
|
2342
|
+
return { ok: true, report: res }
|
|
2343
|
+
} catch (e) {
|
|
2344
|
+
// fail-soft:保护门自身出错时**放行**会让保护形同虚设,但硬拒会把白板彻底锁死。
|
|
2345
|
+
// 取安全侧 = **拒写并显式报错**(宁可让用户看到一次失败,也不静默丢卡)。
|
|
2346
|
+
return { ok: false, gate: 'mutation', error: '写入保护门内部错误:' + String((e && e.message) || e) + '(为避免静默丢卡,本次已拒绝写入;请重试或关闭该目标的重写)' }
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
1763
2350
|
/** 列出交接账本/白板归档文件(名字字典序倒序=新→旧;匹配 handoff-<ts>[-x].md 与 PLAN-<ts>.md)。 */
|
|
1764
2351
|
async listHandoffLedgers(dir, limit = 12) {
|
|
1765
2352
|
try {
|
|
@@ -1768,9 +2355,49 @@ class MemoryEngine {
|
|
|
1768
2355
|
}
|
|
1769
2356
|
|
|
1770
2357
|
/** M-CM2:交接白板语料检索(PLAN.md+账本+归档;词法直返,轻量)。 */
|
|
2358
|
+
/**
|
|
2359
|
+
* 白板语料检索。**P2-3(2026-09-16 接线)**:由「纯词法」升级为
|
|
2360
|
+
* 「**tag/段级倒排优先 → 词法兜底**」。
|
|
2361
|
+
*
|
|
2362
|
+
* 规划 §5 P2-3 原文:「`searchHandoffCorpus` 升为 tag/段级命中优先、词法兜底;`recall` scope 路由扩展」。
|
|
2363
|
+
* 此前只做了词法半边 ⇒ 结构化索引(by_tag/by_cue)建了却没人用,模型按 tag 提问命中率低。
|
|
2364
|
+
*
|
|
2365
|
+
* 结构(两段式, 结果同构以便调用方零改动):
|
|
2366
|
+
* ① **结构化臂**(仅 graph 档且 sidecar 可用): 把 terms 逐个与 by_tag/by_cue 的键做包含匹配,
|
|
2367
|
+
* 命中条目直接返回 `where=条目 source` + `matches=[section/preview/cues]`;
|
|
2368
|
+
* ② **词法臂**(原有逻辑原样保留): 行级子串计分, 作为兜底与补充。
|
|
2369
|
+
* 两臂都按 `limit` 截断; 结构化结果置前(tag 命中比行内子串更可信)。
|
|
2370
|
+
* fail-soft: 结构化臂任何异常都静默跳过, 词法臂行为与旧版逐字节一致。
|
|
2371
|
+
*/
|
|
1771
2372
|
async searchHandoffCorpus(terms, limit, p) {
|
|
1772
2373
|
const hits = []
|
|
1773
2374
|
if (this.config.handoffEnabled === false) return hits
|
|
2375
|
+
// ① 结构化臂: tag / cue 倒排(tag 命中优先于行内子串)
|
|
2376
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
2377
|
+
try {
|
|
2378
|
+
const index = await this._loadSidecarIndexPre(p.projectDir)
|
|
2379
|
+
const entries = index && Array.isArray(index.entries) ? index.entries : []
|
|
2380
|
+
const byTag = (index && index.by_tag) || {}
|
|
2381
|
+
const byCue = (index && index.by_cue) || {}
|
|
2382
|
+
const wanted = new Set()
|
|
2383
|
+
const low = (x) => String(x || '').toLowerCase()
|
|
2384
|
+
for (const t of terms) {
|
|
2385
|
+
for (const k of Object.keys(byTag)) if (low(k).includes(t) || low(t).includes(low(k))) (byTag[k] || []).forEach((id) => wanted.add(id))
|
|
2386
|
+
for (const k of Object.keys(byCue)) if (low(k).includes(t)) (byCue[k] || []).forEach((id) => wanted.add(id))
|
|
2387
|
+
}
|
|
2388
|
+
for (const e of entries) {
|
|
2389
|
+
if (hits.length >= limit) break
|
|
2390
|
+
if (!wanted.has(e.id)) continue
|
|
2391
|
+
const cards = []
|
|
2392
|
+
if (e.section) cards.push('§' + e.section)
|
|
2393
|
+
if (e.preview) cards.push(String(e.preview).slice(0, 160))
|
|
2394
|
+
if (Array.isArray(e.cues) && e.cues.length) cards.push('cue: ' + e.cues.slice(0, 3).join(', '))
|
|
2395
|
+
if (Array.isArray(e.tags) && e.tags.length) cards.push('tag: ' + e.tags.slice(0, 6).join(' '))
|
|
2396
|
+
hits.push({ where: '白板结构化/' + e.source + ' [' + e.id.slice(4, 12) + ']', matches: cards })
|
|
2397
|
+
}
|
|
2398
|
+
} catch (_) { /* fail-soft: 结构化臂失败即回落纯词法 */ }
|
|
2399
|
+
}
|
|
2400
|
+
// ② 词法臂(原有逻辑原样保留 = 兜底)
|
|
1774
2401
|
const scan = async (label, filePath, maxMatches) => {
|
|
1775
2402
|
const text = await this.readTextSafe(filePath)
|
|
1776
2403
|
if (!text) return
|
|
@@ -1948,18 +2575,31 @@ class MemoryEngine {
|
|
|
1948
2575
|
* 窗口优先级=waterLevelWindowTokens 手动覆盖 > 官方 request/context 容量 > settings.yaml auto/fallback。越阈值或检测到 compaction 事件→advisory+自动写抽取性骨架账本。 */
|
|
1949
2576
|
async checkWaterLevel(agent) {
|
|
1950
2577
|
try {
|
|
1951
|
-
if (this.config.handoffEnabled === false) return
|
|
2578
|
+
// 2026-09-14 解耦:水位**测量**不再受 handoffEnabled 约束。旧实现此处 `if (this.config.handoffEnabled === false) return`
|
|
2579
|
+
// 使水位永不测量 ⇒ rt.waterLevelModelKnown 永不写入 ⇒ shouldArmAutoContinuePre 的 fail-closed 闸
|
|
2580
|
+
// (!== true && !hard)永远拒绝 arm —— 关白板会把自动接续一并静默关掉(实证:确认卡永不出现)。
|
|
2581
|
+
// 测量本身是只读的;产物(骨架账本/白板)仍由下方 handoffEnabled 单独把关(见骨架账本写入口)。
|
|
1952
2582
|
// 会话真实模型 + 官方容量:同一份事件扫描结果按会话缓存 5 分钟
|
|
1953
2583
|
// (该事件只在请求头变化时追加,实测 2999 条事件里仅 2 条且位于会话开头,旧实现只扫最近 256 条 → 长会话永远扫不到)。
|
|
2584
|
+
// 2026-09-14(解耦配套):runtime 句柄在**测量一开始**捕获 —— 下面有多处 await(窗口解析/文件 IO),
|
|
2585
|
+
// 旧实现在 await 之后才 this.runtimeFor(agent):若期间该 agent 已被 dispose(会话关闭/被回收),
|
|
2586
|
+
// runtimeFor 会**新建**一个 runtime,等于让已销毁的会话复活(实证:smoke-test-context-observer 的
|
|
2587
|
+
// P3f「runtime B survived dispose」在解耦后转红 —— 白板关闭时 pre-step 才第一次真正走到测量)。
|
|
2588
|
+
// 语义:一次捕获、全程复用;句柄已销毁则放弃本次写入(不复活、不写脏)。
|
|
2589
|
+
const rtOwn = this.runtimeFor(agent)
|
|
1954
2590
|
let sessModel = { provider: '', model: '', contextWindow: 0 }
|
|
1955
2591
|
try {
|
|
1956
2592
|
const sid = agent && agent.session && agent.session.id ? String(agent.session.id) : ''
|
|
2593
|
+
const eventsForModel = sessionEventsOf(agent && agent.session)
|
|
1957
2594
|
const cached = this._officialWindowCache
|
|
1958
|
-
|
|
2595
|
+
// 2026-09-14:空结果不再无条件锁死 5 分钟。首轮 pre-step 早于 request/context 写入
|
|
2596
|
+
// (实测同轮内 step/start → request/context 相隔约 21ms),扫到的 contextWindow 恒为 0,
|
|
2597
|
+
// 缓存它会让 official-context 在整个开局窗口期都取不到。判定规则见 reusableWindowCachePre。
|
|
2598
|
+
if (reusableWindowCachePre(cached, sid, eventsForModel.length, Date.now())) {
|
|
1959
2599
|
sessModel = cached.info
|
|
1960
2600
|
} else {
|
|
1961
|
-
sessModel = findSessionModelPre(
|
|
1962
|
-
this._officialWindowCache = { sid, at: Date.now(), info: sessModel }
|
|
2601
|
+
sessModel = findSessionModelPre(eventsForModel)
|
|
2602
|
+
this._officialWindowCache = { sid, at: Date.now(), info: sessModel, events: eventsForModel.length }
|
|
1963
2603
|
}
|
|
1964
2604
|
} catch (eWin) {}
|
|
1965
2605
|
// 窗口解析按「会话真实模型」查表(2026-09-08):此前恒用 settings.yaml 的 agent-default-model,
|
|
@@ -1967,13 +2607,26 @@ class MemoryEngine {
|
|
|
1967
2607
|
const winInfo = await this.resolveWaterWindow(sessModel.provider, sessModel.model)
|
|
1968
2608
|
let win = Number(winInfo && winInfo.window) || 0
|
|
1969
2609
|
let winSource = (winInfo && winInfo.source) || ''
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
2610
|
+
// ★2026-09-15 修复(用户裁定 B·彻底):窗口有**两个来源**,冲突时**取较大值**。
|
|
2611
|
+
// 旧实现让会话自报的 contextWindow **无条件覆盖** settings.yaml ⇒ 实测 262144 盖掉了 1000000,
|
|
2612
|
+
// 于是 470572 被算成 **180%**(按 1M 其实只占 47%),白写了一份骨架账本 + 催了一次接续。
|
|
2613
|
+
// 取较大值的理由(**方向性是关键**):判"是否接近上限"时,**误判"要炸"的代价**
|
|
2614
|
+
// (白写骨架、催模型交接)高于**误判"还早"**(后者仍有 compaction / provider-400 两个硬信号兜底,
|
|
2615
|
+
// 见下方 `hard = compacted || overflowed || hardWall`)。故声明冲突取大、真硬墙取小:
|
|
2616
|
+
// `hardWin`(provider 400 文本里的 windowTokens)是**观测到的拒绝**,不是声明 ⇒ 仍照旧压小分母。
|
|
2617
|
+
let winConflict = null
|
|
2618
|
+
if (!/^manual/.test(winSource) && Number(sessModel.contextWindow) > 0) {
|
|
2619
|
+
const reported = Number(sessModel.contextWindow)
|
|
2620
|
+
if (win <= 0) { win = reported; winSource = 'official-context' }
|
|
2621
|
+
else if (reported !== win) {
|
|
2622
|
+
winConflict = { settingsWin: win, settingsSource: winSource, sessionReported: reported, took: 'max' }
|
|
2623
|
+
if (reported > win) { win = reported; winSource = 'official-context' }
|
|
2624
|
+
// 否则**保留 settings 的较大值**,只把冲突记下来(下方写进 state + diag,可观测)
|
|
2625
|
+
} else { winSource = 'official-context' }
|
|
1974
2626
|
}
|
|
1975
2627
|
if (win <= 0) return
|
|
1976
|
-
const rt =
|
|
2628
|
+
const rt = rtOwn
|
|
2629
|
+
if (rt && rt.disposed) return // 会话已销毁:不复活、不写脏
|
|
1977
2630
|
// ① 压力硬信号(2026-09-10 实机取证)
|
|
1978
2631
|
// provider 的 "requested tokens" **含路由预留的输出预算**,消息实际额度 = window − maxTokens
|
|
1979
2632
|
// (实测:消息 666,044 + 预留 384,000 = 1,050,044 > 上限 1,048,576 → 400 CONTEXT_WINDOW_EXCEEDED)。
|
|
@@ -2050,6 +2703,15 @@ class MemoryEngine {
|
|
|
2050
2703
|
// 与 state 同源同值,一处漏写两处都缺 —— 必须成对维护。
|
|
2051
2704
|
rt.waterLevelWindow = triggerWin
|
|
2052
2705
|
rt.waterLevelSource = winSource
|
|
2706
|
+
// 2026-09-14:会话真实模型是否已知 + 本次是否由硬信号触发。首轮 pre-step 时
|
|
2707
|
+
// request/header 尚未写入会话 ⇒ provider/model 全空,窗口只能用 agent-default-model 推算;
|
|
2708
|
+
// arm 侧据此判断能否按比例触发(见 shouldArmAutoContinuePre)。
|
|
2709
|
+
// 只落在 runtime:arm 从 runtime 读整份快照(tokens/window/source 同处),而 UI 不消费
|
|
2710
|
+
// 这两个值 —— 与 state 上那对(window/source,由水位卡片读取)不同,无需成对维护。
|
|
2711
|
+
// 注:硬触发**原因**(state.waterLevelHardTrigger:compaction/overflow/wall)仅供日志展示,
|
|
2712
|
+
// 这里判定只需要一个布尔。
|
|
2713
|
+
rt.waterLevelModelKnown = !!(sessModel.provider || sessModel.model)
|
|
2714
|
+
rt.waterLevelHard = hard
|
|
2053
2715
|
this.state.waterLevelRatio = armRatio
|
|
2054
2716
|
this.state.waterLevelTokens = estTokens
|
|
2055
2717
|
this.state.waterLevelWindow = triggerWin
|
|
@@ -2077,11 +2739,23 @@ class MemoryEngine {
|
|
|
2077
2739
|
if (!over) return
|
|
2078
2740
|
if (!rt.waterLevelAdvised || compacted || overflowed) {
|
|
2079
2741
|
rt.waterLevelAdvised = true
|
|
2080
|
-
|
|
2742
|
+
// ★2026-09-15(用户裁定):水位越阈 / compaction 时**置位"待更新白板与账本"**。
|
|
2743
|
+
// 这是"轮次间长期运行时也要强制更新白板/账本"的注入侧开关;
|
|
2744
|
+
// 骨架账本(下面的自动写入)只是**兜底产物**,而模型**主动重写**才是正途 ——
|
|
2745
|
+
// 两者的差别是"能读的降级稿"与"完整的接续材料"。
|
|
2746
|
+
if (this.handoffChainEnabledPre()) {
|
|
2747
|
+
this.state.planUpdatePending = {
|
|
2748
|
+
reason: compacted ? 'compact' : (overflowed ? 'overflow' : 'water'),
|
|
2749
|
+
at: Date.now(),
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
diag('water level advisory: ratio=' + ratio.toFixed(2) + (hard ? '→' + armRatio.toFixed(2) + '(hard:' + this.state.waterLevelHardTrigger + ')' : '') +
|
|
2081
2753
|
' tokens=' + estTokens + '/' + triggerWin + '(win=' + win + ' reserve=' + reserve + (hardWin > 0 ? ' hardWin=' + hardWin : '') + ')' +
|
|
2082
2754
|
' src=' + (winSource || '?') + ' meter=' + meterLayer + (compacted ? ' compaction-detected' : ''))
|
|
2083
2755
|
}
|
|
2084
|
-
|
|
2756
|
+
// 2026-09-14 解耦的镜像保护:测量放行后,这条**产物**写入必须由 handoffEnabled 单独把关,
|
|
2757
|
+
// 否则「白板关 + 水位越阈」会照样写交接账本并覆盖快照里的 latestHandoffText(反向耦合)。
|
|
2758
|
+
if (this.handoffChainEnabledPre() && this.config.waterLevelAutoHandoff !== false && (!rt.waterLevelAutoHandoffDone || compacted || overflowed)) {
|
|
2085
2759
|
rt.waterLevelAutoHandoffDone = true
|
|
2086
2760
|
const p = await this.resolvePaths(agent)
|
|
2087
2761
|
// 骨架 v2:抽取性压缩——只从「已策展源」取材(沉淀子代理提炼过的今日日志尾部+反思摘要+项目笔记头部),去重限行,不做原始转储
|
|
@@ -2090,7 +2764,26 @@ class MemoryEngine {
|
|
|
2090
2764
|
const logLines = (await this.readTextSafe(p.logPath)).split(NL).filter((l) => l.trim()).slice(-16)
|
|
2091
2765
|
const failLines = dedupe(logLines.filter((l) => /失败|报错|回滚|错误|fix|bug|error|fail/i.test(l))).slice(0, 4)
|
|
2092
2766
|
const refDigest = String(reflectionDigest(this.state.latestReflection || '') || '').split(NL).filter((l) => l.trim()).slice(0, 4)
|
|
2093
|
-
|
|
2767
|
+
// ★2026-09-15 修复:旧写法 `notesLines = 项目 MEMORY.md 前 6 行` 是**提取性压缩的脏输出** ——
|
|
2768
|
+
// 实测把「## 2026-09-15 · 分级注入…」这类**日期小节标题与正文**当成了「## 目标」的内容写进账本
|
|
2769
|
+
// (现场证据:handoff-20260915-161408.md 的「## 目标」段是 MEMORY.md 开头原文)。
|
|
2770
|
+
// 现在改为**按语义找目标小节**(`## 当前目标` / `## 目标` / `## 项目目标`),
|
|
2771
|
+
// 找不到就**如实说明**并给出下钻方式 —— 宁可空着说清楚,也不塞一坨看起来像目标的东西。
|
|
2772
|
+
const notesAll = (await this.readTextSafe(p.notesPath)).split(NL)
|
|
2773
|
+
const goalLines = (() => {
|
|
2774
|
+
const iHead = notesAll.findIndex((l) => /^#{1,3}\s*(当前)?(项目)?目标/.test(String(l).trim()))
|
|
2775
|
+
if (iHead < 0) return []
|
|
2776
|
+
const out = []
|
|
2777
|
+
for (let i = iHead + 1; i < notesAll.length && out.length < 5; i++) {
|
|
2778
|
+
const t = String(notesAll[i]).trim()
|
|
2779
|
+
if (/^#{1,3}\s/.test(t)) break // 撞到下一个标题即止(不越界搬别的小节)
|
|
2780
|
+
if (t) out.push(notesAll[i])
|
|
2781
|
+
}
|
|
2782
|
+
return out
|
|
2783
|
+
})()
|
|
2784
|
+
const notesLines = goalLines.length
|
|
2785
|
+
? goalLines
|
|
2786
|
+
: ['(本项目笔记里没有可识别的「目标」小节 · 需原文请用 memory_read(kind=notes) 或 memory_recall)']
|
|
2094
2787
|
const pct = Math.round(ratio * 100)
|
|
2095
2788
|
const skeleton = dedupe([
|
|
2096
2789
|
'## 任务状态', '(系统自动快照 · 估算 ' + estTokens + ' token ≈ 水位 ' + pct + '%' + (compacted ? ' · 检测到 compaction' : '') + ';模型应尽快用 memory_note(kind=plan) 重写白板全貌)', '',
|
|
@@ -2103,6 +2796,27 @@ class MemoryEngine {
|
|
|
2103
2796
|
this.state.latestHandoffText = await this.readTextSafe(r.path)
|
|
2104
2797
|
this.state.loadedAt = Date.now()
|
|
2105
2798
|
diag('water level auto-handoff written: ' + r.path)
|
|
2799
|
+
} else if (r && r.gate === 'criteria') {
|
|
2800
|
+
// A6 预授权默认值(用户可覆盖):水位骨架的硬判据失败策略 = **照写 + 警示行**。
|
|
2801
|
+
// 理由:骨架存在的意义是"保证接续材料存在"(I4 绝不阻塞接续);素材为空时骨架天然
|
|
2802
|
+
// 可能不满足 H2/S2/S3,硬拒会让**即将被压缩的会话失去最后的交接材料**。
|
|
2803
|
+
// 但"照写"不等于"静默",也**不等于可以跳过共同保护**:这里只跳过**判据门**
|
|
2804
|
+
// (可选质量门),丢卡/用户区/重复 id 三条保护仍会跑(ROUND3 §3.7 第 4 条)。
|
|
2805
|
+
diag('water level auto-handoff skipped criteria gate: ' + String(r.error || '').slice(0, 200))
|
|
2806
|
+
const warned = '> [降级] 本骨架由水位触发自动写入,未通过判据门(素材不足)。'
|
|
2807
|
+
+ '请下一个窗口优先用 memory_note(kind=plan) 重写白板全貌,并补一篇合格账本。\n\n'
|
|
2808
|
+
+ skeleton
|
|
2809
|
+
const r2 = await this.writeHandoffLedger(p.projectDir, warned, { skipCriteria: true })
|
|
2810
|
+
if (r2 && r2.ok) {
|
|
2811
|
+
this.state.latestHandoffText = await this.readTextSafe(r2.path)
|
|
2812
|
+
this.state.loadedAt = Date.now()
|
|
2813
|
+
diag('water level auto-handoff written (degraded, criteria skipped): ' + r2.path)
|
|
2814
|
+
} else {
|
|
2815
|
+
diag('water level auto-handoff degraded write failed: ' + String((r2 && r2.error) || 'unknown').slice(0, 200))
|
|
2816
|
+
}
|
|
2817
|
+
} else if (r && r.gate === 'mutation') {
|
|
2818
|
+
// 保护门拒绝 = 真丢了东西 ⇒ **绝不绕过**(A6 只覆盖判据门)。留痕并放弃本次写入。
|
|
2819
|
+
diag('water level auto-handoff REJECTED by mutation gate (not bypassed): ' + String(r.error || '').slice(0, 200))
|
|
2106
2820
|
}
|
|
2107
2821
|
}
|
|
2108
2822
|
} catch (e) { diag('checkWaterLevel error: ' + (e && e.message)) }
|
|
@@ -2119,7 +2833,8 @@ class MemoryEngine {
|
|
|
2119
2833
|
* minGapMs 仅保留给测试注入。如需控频应改 `checkWaterLevel` 自身,而不是在这里整段跳过。 */
|
|
2120
2834
|
checkWaterLevelAtStep(agent, minGapMs = 0) {
|
|
2121
2835
|
try {
|
|
2122
|
-
|
|
2836
|
+
// 2026-09-14 解耦:pre-step 补测与 checkWaterLevel 同口径 —— 测量与 arm 资格只看 autoContinueEnabled
|
|
2837
|
+
// (armAutoContinue 内部自会拒绝 autoContinueEnabled === false),白板开关不再参与。
|
|
2123
2838
|
if (!agent || !this.hasReliableSessionIdentity(agent)) return
|
|
2124
2839
|
const rt = this.runtimeFor(agent)
|
|
2125
2840
|
const now = Date.now()
|
|
@@ -2130,8 +2845,9 @@ class MemoryEngine {
|
|
|
2130
2845
|
// 这里 arm 的倒计时标记 awaitIdle,由 tickAutoContinue 在回合仍活跃时推迟执行,避免打断正在进行的回合。
|
|
2131
2846
|
void this.checkWaterLevel(agent).then(() => {
|
|
2132
2847
|
try {
|
|
2133
|
-
const rt2 =
|
|
2134
|
-
|
|
2848
|
+
const rt2 = rt // 同 R2:不重新 runtimeFor(否则会复活已 dispose 的会话)
|
|
2849
|
+
if (!rt2 || rt2.disposed) return
|
|
2850
|
+
this.armAutoContinue(agent, { ratio: rt2.waterLevel, tokens: rt2.waterLevelTokens, window: rt2.waterLevelWindow, source: rt2.waterLevelSource, modelKnown: rt2.waterLevelModelKnown, hard: rt2.waterLevelHard }, { awaitIdle: true })
|
|
2135
2851
|
} catch (eArm) {}
|
|
2136
2852
|
}).catch(() => {})
|
|
2137
2853
|
} catch (e) { diag('checkWaterLevelAtStep error: ' + (e && e.message)) }
|
|
@@ -2169,6 +2885,12 @@ class MemoryEngine {
|
|
|
2169
2885
|
const set = this.loadContinuedSessions()
|
|
2170
2886
|
if (set.has(k)) return false
|
|
2171
2887
|
set.add(k)
|
|
2888
|
+
// ★2026-09-15(用户裁定):**接续发生时置位"待更新白板与账本"**。
|
|
2889
|
+
// 用户原话要点:轮次间在长期运行时触发了自动接续,**也应该强制更新白板与账本** ——
|
|
2890
|
+
// 理由是接续后的新窗口靠白板/账本续命,若它们停在旧状态,接续等于把旧地图交给新窗口。
|
|
2891
|
+
if (this.handoffChainEnabledPre()) {
|
|
2892
|
+
this.state.planUpdatePending = { reason: 'continue', at: Date.now() }
|
|
2893
|
+
}
|
|
2172
2894
|
try {
|
|
2173
2895
|
const file = this.continuedSessionsFile()
|
|
2174
2896
|
mkdirSync(path.dirname(file), { recursive: true })
|
|
@@ -2283,9 +3005,20 @@ class MemoryEngine {
|
|
|
2283
3005
|
armAutoContinue(agent, wl, opts = null) {
|
|
2284
3006
|
try {
|
|
2285
3007
|
if (!agent || !this.hasReliableSessionIdentity(agent)) return
|
|
2286
|
-
|
|
3008
|
+
// 2026-09-14 解耦:接续资格只看 autoContinueEnabled。白板开关只管**产物**(PLAN/账本),
|
|
3009
|
+
// 不再兼职「接续总闸」——旧实现使「关白板 = 静默关接续」(水位照测也永远 arm 不上)。
|
|
2287
3010
|
if (this.config.autoContinueEnabled === false) return
|
|
2288
|
-
|
|
3011
|
+
// 2026-09-14:会话真实模型未知时(新会话首轮 —— request/header 尚未写入会话,provider/model 全空),
|
|
3012
|
+
// 窗口与预留额度只能用 settings.yaml 的 agent-default-model 推算,而它与会话实际模型可能完全不同
|
|
3013
|
+
// (实测同一台机器上可是两个不同 provider 的模型,连 maxTokens 都不一致)。用推算的分母按比例触发
|
|
3014
|
+
// 「建新会话 + 注入交接材料」这类重动作风险不对称 ⇒ 只放行硬信号;比例判据推迟到轮末再判
|
|
3015
|
+
// (turn-stopping 时 request/header 已写入,模型已知)。判定见 shouldArmAutoContinuePre。
|
|
3016
|
+
if (!wl) return
|
|
3017
|
+
if (!shouldArmAutoContinuePre(wl)) {
|
|
3018
|
+
diag('auto-continue not armed: session model unknown (window derived from default model), waiting for a hard signal')
|
|
3019
|
+
return
|
|
3020
|
+
}
|
|
3021
|
+
if (!(wl.ratio >= (Number(this.config.autoContinueThreshold) || 0.75))) return
|
|
2289
3022
|
const st = this._autoContState || (this._autoContState = {})
|
|
2290
3023
|
const now = Date.now()
|
|
2291
3024
|
const cooldownMin = Number(this.config.autoContinueCooldownMinutes) || 30
|
|
@@ -2354,10 +3087,39 @@ class MemoryEngine {
|
|
|
2354
3087
|
const sc = this._sessionController || (() => { try { this._sessionController = this._ctxRef?.get?.('sessionController') || undefined } catch (e) {} return this._sessionController })()
|
|
2355
3088
|
if (!sc || typeof sc.create !== 'function') throw new Error('sessionController service unavailable(宿主无法建会话;请重启 dsh web 或保持页面打开)')
|
|
2356
3089
|
const oldSid = armed && armed.sessionId ? String(armed.sessionId) : ''
|
|
3090
|
+
// ①终止旧回合(2026-09-14,用户裁定流程「终止旧会话任务 → 发接续仪式 → 仪式结束后 → 开新窗口」):
|
|
3091
|
+
// 原先此处直接投仪式,而旧会话往往仍在跑 —— 仪式消息只能排在旧回合后面,既拖住「仪式结束后」的判据,
|
|
3092
|
+
// 又放任旧回合继续写会话(接续存在的意义正是避开官方压缩/context 溢出)。故先把旧回合停掉再看仪式。
|
|
3093
|
+
// 失败绝不中止交接:交接是主目标,卡在这里反而会撞上压缩 —— 一律记录后继续,结果落到 result/lastOk/diag。
|
|
3094
|
+
// 参数形状取自装机包 dsh-api-session-controller:SessionCancelRequest 只收 sessionId
|
|
3095
|
+
// (lib/types/types.d.ts:326),宿主侧同步返回 accepted:true,内部转调 agent.cancel(kind=user, keepInbox=true)
|
|
3096
|
+
// (该包 lib/index.js:872-878;只对已附着的 agent 生效,未知/未附着会话由它自己抛错)。
|
|
3097
|
+
let stopped = 'no-old-session'
|
|
3098
|
+
if (oldSid) {
|
|
3099
|
+
if (typeof sc.cancel !== 'function') {
|
|
3100
|
+
stopped = 'unavailable'
|
|
3101
|
+
diag('auto-continue: sessionController.cancel unavailable (old host), skipped stopping the old turn')
|
|
3102
|
+
} else {
|
|
3103
|
+
try {
|
|
3104
|
+
await sc.cancel({ sessionId: oldSid })
|
|
3105
|
+
stopped = 'ok'
|
|
3106
|
+
diag('auto-continue: stopped old turn sid=' + oldSid)
|
|
3107
|
+
} catch (eC) {
|
|
3108
|
+
stopped = 'error'
|
|
3109
|
+
diag('auto-continue: stop old turn failed (continuing anyway): ' + ((eC && eC.message) || eC))
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
2357
3113
|
// 刷新仪式(2026-09-10):先让旧会话把白板 PLAN 与账本刷到最新,再组装交接材料 ——
|
|
2358
3114
|
// 与 client 路径同序(先仪式后材料)。失败/超时都 fail-soft,照常接续。
|
|
2359
3115
|
let ritual = null
|
|
2360
|
-
|
|
3116
|
+
// 2026-09-14 解耦的镜像保护:刷新仪式本身就是**白板/账本材料刷新**(让旧会话重写 PLAN+账本),
|
|
3117
|
+
// 白板关时不得再跑,否则解耦后的接续会在白板关闭状态下仍然驱动白板产物。
|
|
3118
|
+
if (this.config.handoffEnabled !== false) {
|
|
3119
|
+
try { ritual = await this.hostRefreshRitual(oldSid) } catch (eRf) { diag('host ritual error: ' + ((eRf && eRf.message) || eRf)) }
|
|
3120
|
+
} else {
|
|
3121
|
+
ritual = { ok: false, reason: 'handoff-disabled' }
|
|
3122
|
+
}
|
|
2361
3123
|
const d = await this.buildContinueCarry(oldSid)
|
|
2362
3124
|
if (!d || !d.ok) throw new Error((d && d.error) || 'no handoff material')
|
|
2363
3125
|
const created = await sc.create({
|
|
@@ -2390,13 +3152,16 @@ class MemoryEngine {
|
|
|
2390
3152
|
// 接续后用户每一步都要批准(静默运行被打断)。agent 在 prompt 之后才建,故在此按延迟重试取 agent.session 再套旧预设;
|
|
2391
3153
|
// 旧会话优先按 id 从 agents 注册表取(_lastAgent 重启后可能为空),fail-soft 不影响接续结果。
|
|
2392
3154
|
const perm = await this.inheritPermissionForContinue(oldSid, newId)
|
|
2393
|
-
|
|
3155
|
+
// 仪式结果原样透出(2026-09-14):hostRefreshRitual 现在会区分「真结束」(updated)与
|
|
3156
|
+
// 「退回指纹判据」(stamp-fallback)/超时 —— 这一层若照旧只有 ok→'updated' 的粗粒度映射,
|
|
3157
|
+
// 降级的弱点就在 result/lastOk/diag 里被抹平了(实测:降级路径看似成功)。
|
|
3158
|
+
const ritualNote = (ritual && ritual.ok) ? (ritual.waited || 'updated') : ((ritual && ritual.reason) || 'skipped')
|
|
2394
3159
|
// 落闩:旧会话已接续过,此后不再被 arm(即使 30 分钟冷却已过)
|
|
2395
3160
|
try { this.markContinuedSession(oldSid, newId) } catch (eL) {}
|
|
2396
3161
|
st.lastRunAt = Date.now()
|
|
2397
|
-
st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
|
|
2398
|
-
diag('auto-continue host-executed: new session ' + newId + ' model=' + (d.model || 'default') + ' perm=' + ((perm && perm.ok && perm.preset) || ('(' + ((perm && perm.reason) || 'n/a') + ')')) + ' ritual=' + ritualNote)
|
|
2399
|
-
return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote }
|
|
3162
|
+
st.lastOk = { sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', workspaceId: d.workspaceId || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote, stopped }
|
|
3163
|
+
diag('auto-continue host-executed: new session ' + newId + ' model=' + (d.model || 'default') + ' perm=' + ((perm && perm.ok && perm.preset) || ('(' + ((perm && perm.reason) || 'n/a') + ')')) + ' ritual=' + ritualNote + ' stopped=' + stopped)
|
|
3164
|
+
return { ok: true, sessionId: newId, model: d.model || '', reasoningEffort: d.reasoningEffort || '', permissionPreset: (perm && perm.ok && perm.preset) ? perm.preset : '', refreshRitual: ritualNote, stopped }
|
|
2400
3165
|
} catch (e) {
|
|
2401
3166
|
// 官方把 prompt 失败统一包成 session/agent-busy + message "prompt rejected",真实原因只藏在 details/value 里。
|
|
2402
3167
|
// 不透出就会得到一句无法排查的 "prompt rejected"(2026-09-10 实测踩坑)。
|
|
@@ -2436,14 +3201,16 @@ class MemoryEngine {
|
|
|
2436
3201
|
} : null
|
|
2437
3202
|
const armedMine = !selfKey || !armedRaw || !armedRaw.sessionId || this.waterKey(armedRaw.sessionId) === selfKey
|
|
2438
3203
|
return {
|
|
2439
|
-
|
|
3204
|
+
// 2026-09-14 解耦:卡片可用性只看 autoContinueEnabled(与 armAutoContinue 同源同判);
|
|
3205
|
+
// 旧实现的 `&& handoffEnabled !== false` 是同一处耦合的第二份副本(白板关⇒卡片报 disabled)。
|
|
3206
|
+
enabled: this.config.autoContinueEnabled !== false,
|
|
2440
3207
|
threshold: Number(this.config.autoContinueThreshold) || 0.75,
|
|
2441
3208
|
armed: armedMine ? armedRaw : null,
|
|
2442
3209
|
executing: !!st.executing,
|
|
2443
3210
|
rejectedEdgeAt: st.rejectedEdgeAt || 0,
|
|
2444
3211
|
lastRunAt: st.lastRunAt || 0,
|
|
2445
3212
|
// at:客户端据此给「已完成」提示加 10 分钟有效期 —— 旧实现没有时间戳,提示会一直挂在所有窗口上
|
|
2446
|
-
lastOk: st.lastOk ? { sessionId: st.lastOk.sessionId, model: st.lastOk.model, reasoningEffort: st.lastOk.reasoningEffort, workspaceId: st.lastOk.workspaceId, permissionPreset: st.lastOk.permissionPreset, refreshRitual: st.lastOk.refreshRitual, at: st.lastRunAt || 0 } : null,
|
|
3213
|
+
lastOk: st.lastOk ? { sessionId: st.lastOk.sessionId, model: st.lastOk.model, reasoningEffort: st.lastOk.reasoningEffort, workspaceId: st.lastOk.workspaceId, permissionPreset: st.lastOk.permissionPreset, refreshRitual: st.lastOk.refreshRitual, stopped: st.lastOk.stopped || '', at: st.lastRunAt || 0 } : null,
|
|
2447
3214
|
error: st.error || '',
|
|
2448
3215
|
}
|
|
2449
3216
|
}
|
|
@@ -2463,6 +3230,13 @@ class MemoryEngine {
|
|
|
2463
3230
|
if (!st.armed) return { ok: false, error: 'no pending auto-continue' }
|
|
2464
3231
|
edgeAt = edgeAt || st.armed.edgeAt
|
|
2465
3232
|
if (edgeAt !== st.armed.edgeAt) return { ok: false, error: 'stale edge' }
|
|
3233
|
+
// 触发来源取证(2026-09-14):此前只有 reject 分支写日志,"这条接续是谁触发的"(用户点同意 vs
|
|
3234
|
+
// 心跳到期)在日志里无法区分。agree 分支即浏览器确认卡路径(loopback POST auto-continue-decide,
|
|
3235
|
+
// 端点只带 action/edgeAt,拿不到更细的身份),故记 edgeAt + armed 会话 + 最近活跃时刻 ——
|
|
3236
|
+
// 与 tickAutoContinue 的 'deadline reached' 那行对照即可判定来源。
|
|
3237
|
+
// 注:此处刻意不加「回合活跃则推迟」的闸 —— 用户裁定的是「停旧回合」,推迟会把点同意变成
|
|
3238
|
+
// 最多 5×20s 的空等;且 _globalLastActiveAt 是跨会话/跨工作区的全局值,在停旧回合语义下不是对的判据。
|
|
3239
|
+
diag('auto-continue agreed by user: edge=' + String(edgeAt) + ' sid=' + String(st.armed.sessionId || '') + ' lastActiveAt=' + (Number(this._globalLastActiveAt) || 0))
|
|
2466
3240
|
return this.hostAutoContinue()
|
|
2467
3241
|
}
|
|
2468
3242
|
return { ok: false, error: 'unknown action' }
|
|
@@ -2602,7 +3376,9 @@ class MemoryEngine {
|
|
|
2602
3376
|
}
|
|
2603
3377
|
|
|
2604
3378
|
/** 接续材料指纹(PLAN mtime + 最新账本名):刷新仪式靠它判断旧会话有没有真的产出新白板/账本。
|
|
2605
|
-
* 只 stat 一个文件 + 一次 readdir,比 handoffPanelData 便宜得多(仪式要按秒轮询)。
|
|
3379
|
+
* 只 stat 一个文件 + 一次 readdir,比 handoffPanelData 便宜得多(仪式要按秒轮询)。
|
|
3380
|
+
* TODO(separate):本函数读的是全局 this.state.handoffDir(当前工作区),而非「旧会话所属工作区」的目录 ——
|
|
3381
|
+
* 跨工作区接续时比较的其实是另一个目录。这是独立于本次改动的真问题,本次不动(仅作保底降级判据)。 */
|
|
2606
3382
|
async handoffMaterialStamp() {
|
|
2607
3383
|
try {
|
|
2608
3384
|
const dir = this.state.handoffDir || (await this.resolvePaths(undefined)).handoffDir
|
|
@@ -2632,6 +3408,32 @@ class MemoryEngine {
|
|
|
2632
3408
|
st.ritualAt = now
|
|
2633
3409
|
try {
|
|
2634
3410
|
const before = await this.handoffMaterialStamp()
|
|
3411
|
+
// 事件尾基线(2026-09-14,用户裁定「仪式结束后」必须是真结束):原判据「材料指纹变了」只证明
|
|
3412
|
+
// 目录发生了变化 —— 任何写入者(别的会话/别的工作区/用户手改白板)都能让它变,与「模型真的跑过
|
|
3413
|
+
// 仪式」之间没有因果(已取证:判据与原因解耦)。改用旧会话事件尾:仪式消息在被处理前不可能产出
|
|
3414
|
+
// assistant/message 或 tool/call 事件,故「事件数增长 + 基线之上出现这两类事件」才闭合因果。
|
|
3415
|
+
// inspect 缺失(旧 host)或抛错 → 退回指纹判据,并把 waited 降级为 stamp-fallback 让弱点可见。
|
|
3416
|
+
let canInspect = typeof sc.inspect === 'function'
|
|
3417
|
+
let baseSeq = -1
|
|
3418
|
+
let baseCount = -1
|
|
3419
|
+
if (canInspect) {
|
|
3420
|
+
try {
|
|
3421
|
+
const sigInspect = typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(30000) : undefined
|
|
3422
|
+
const snap0 = await sc.inspect(sid, sigInspect)
|
|
3423
|
+
const evs0 = (snap0 && snap0.events) || []
|
|
3424
|
+
baseCount = evs0.length
|
|
3425
|
+
for (const ev of evs0) {
|
|
3426
|
+
const q = Number(ev && ev.seq)
|
|
3427
|
+
if (Number.isFinite(q) && q > baseSeq) baseSeq = q
|
|
3428
|
+
}
|
|
3429
|
+
diag('host refresh ritual: event baseline n=' + baseCount + ' maxSeq=' + baseSeq)
|
|
3430
|
+
} catch (eI) {
|
|
3431
|
+
canInspect = false
|
|
3432
|
+
diag('host refresh ritual: inspect baseline failed, degrade to material stamp: ' + ((eI && eI.message) || eI))
|
|
3433
|
+
}
|
|
3434
|
+
} else {
|
|
3435
|
+
diag('host refresh ritual: sessionController.inspect unavailable (old host), degrade to material stamp')
|
|
3436
|
+
}
|
|
2635
3437
|
const reqId = (typeof randomUUID === 'function')
|
|
2636
3438
|
? randomUUID()
|
|
2637
3439
|
: ('dam-r-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10))
|
|
@@ -2644,8 +3446,22 @@ class MemoryEngine {
|
|
|
2644
3446
|
const pollMs = Number(this._ritualPollMs) > 0 ? Number(this._ritualPollMs) : 2500
|
|
2645
3447
|
while (Date.now() - t0 < timeoutMs) {
|
|
2646
3448
|
await new Promise((r) => setTimeout(r, pollMs))
|
|
2647
|
-
|
|
2648
|
-
|
|
3449
|
+
if (canInspect) {
|
|
3450
|
+
let snap = null
|
|
3451
|
+
try { snap = await sc.inspect(sid) } catch (eP) {
|
|
3452
|
+
// 轮询期 inspect 开始抛错(会话被回收等):退回指纹判据,不空耗到超时。
|
|
3453
|
+
canInspect = false
|
|
3454
|
+
diag('host refresh ritual: inspect failed during poll, degrade to material stamp: ' + ((eP && eP.message) || eP))
|
|
3455
|
+
}
|
|
3456
|
+
if (snap) {
|
|
3457
|
+
const evs = (snap && snap.events) || []
|
|
3458
|
+
const done = evs.length > baseCount && evs.some((ev) => Number(ev && ev.seq) > baseSeq && (ev.type === 'assistant/message' || ev.type === 'tool/call'))
|
|
3459
|
+
if (done) { diag('host refresh ritual: ritual processed by old session after ' + Math.round((Date.now() - t0) / 1000) + 's (events ' + baseCount + '→' + evs.length + ')'); return { ok: true, waited: 'updated' } }
|
|
3460
|
+
}
|
|
3461
|
+
} else {
|
|
3462
|
+
const cur = await this.handoffMaterialStamp()
|
|
3463
|
+
if (cur && cur !== before) { diag('host refresh ritual: material stamp changed after ' + Math.round((Date.now() - t0) / 1000) + 's (weak criterion: stamp, not proof the model ran the ritual)'); return { ok: true, waited: 'stamp-fallback' } }
|
|
3464
|
+
}
|
|
2649
3465
|
}
|
|
2650
3466
|
diag('host refresh ritual: timeout, continuing with current material')
|
|
2651
3467
|
return { ok: false, reason: 'timeout' }
|
|
@@ -2734,12 +3550,15 @@ class MemoryEngine {
|
|
|
2734
3550
|
let total = 0
|
|
2735
3551
|
for (const m of msgs) {
|
|
2736
3552
|
const chunk = '**' + m.role + '**: ' + m.text.slice(0, 2000) + NL + NL
|
|
2737
|
-
if (total + chunk.length > 60000) { body.push('(
|
|
3553
|
+
if (total + chunk.length > 60000) { body.push('(更早内容已截断——完整历史可试 memory_recall(scope=\'sessions\', query=\'关键词\') 检索;该检索需宿主侧开启会话内容检索,条件不满足时会明确报错,此时请改用 scope=\'handoff\' 或直接 read 本文件)'); break }
|
|
2738
3554
|
body.push(chunk)
|
|
2739
3555
|
total += chunk.length
|
|
2740
3556
|
}
|
|
2741
3557
|
try {
|
|
2742
|
-
|
|
3558
|
+
// P2 ALS 修复后 refresh 真正跑起来,与 handoff 写包并发创建同一 handoffDir;
|
|
3559
|
+
// Windows 上并发 mkdir(recursive) 有 EEXIST 竞态(nodejs/node#31453 类)——目录已存在即达成目标,
|
|
3560
|
+
// 仅吞 EEXIST;其余错误(含 S3 的文件占位堵路)照旧上抛,回滚序号语义不变。
|
|
3561
|
+
await mkdir(p.handoffDir, { recursive: true }).catch((eM) => { if (!eM || eM.code !== 'EEXIST') throw eM })
|
|
2743
3562
|
await writeFile(outPath, body.join(NL), 'utf8')
|
|
2744
3563
|
} catch (eW) {
|
|
2745
3564
|
// 包落盘失败 → 回滚刚分配的序号,保证不跳号(carry 会用持久计数器重新兜底)
|
|
@@ -2758,13 +3577,19 @@ class MemoryEngine {
|
|
|
2758
3577
|
* 返回 {ok, carryText, planPath, ws, provider, model, prevSessionId, transcriptPath};无任何材料时 ok:false。 */
|
|
2759
3578
|
async buildContinueCarry(preferSid) {
|
|
2760
3579
|
try {
|
|
2761
|
-
|
|
3580
|
+
// 2026-09-14 解耦(载体口径):白板关 ≠ 不能接续。旧实现此处直接返回 { ok:false, error:'handoff disabled' },
|
|
3581
|
+
// 于是「白板关 + 接续开」在投料前必然失败(hostAutoContinue 抛出 no handoff material)。
|
|
3582
|
+
// 现按**载体分层**处理,不新增任何交接路径:
|
|
3583
|
+
// ①PLAN/账本 = handoffEnabled 把关的产物层:关时既不写、也不读(旧文件也不许混进新材料);
|
|
3584
|
+
// ②旧会话转写包(buildPrevSessionPack)= 与白板无关的会话层材料,恒可用;
|
|
3585
|
+
// ③两者皆空才 ok:false —— 下方 no handoff material 判据本就把 pack 计入,无需另加分支。
|
|
3586
|
+
const withHandoff = this.config.handoffEnabled !== false
|
|
2762
3587
|
const p = await this.resolvePaths(undefined)
|
|
2763
3588
|
const NL = String.fromCharCode(10)
|
|
2764
|
-
const plan = await this.readTextSafe(p.planPath)
|
|
2765
|
-
let ledger = await this.readLatestHandoff(p.handoffDir)
|
|
3589
|
+
const plan = withHandoff ? await this.readTextSafe(p.planPath) : ''
|
|
3590
|
+
let ledger = withHandoff ? await this.readLatestHandoff(p.handoffDir) : ''
|
|
2766
3591
|
let ledgerFrom = 'workspace'
|
|
2767
|
-
if (!ledger) {
|
|
3592
|
+
if (withHandoff && !ledger) {
|
|
2768
3593
|
const gl = await this.findLatestGlobalHandoff()
|
|
2769
3594
|
if (gl) { ledger = await this.readTextSafe(gl); ledgerFrom = 'global' }
|
|
2770
3595
|
}
|
|
@@ -2791,6 +3616,8 @@ class MemoryEngine {
|
|
|
2791
3616
|
'接续上一会话的任务。材料已分层,请按需取用而非通读:先看第0层白板建立全局图景,再视需要看第1层账本(含已试方案与失败原因)与第2层近期线程;第3层完整转写仅在前三层不足以推进时才 read。恢复上下文后直接继续推进未完成事项,不要重新开始。',
|
|
2792
3617
|
'材料分层:第0层=指令+白板(全局图景);第1层=交接账本(四段式,最新);第2层=近期线程(最近 20 条,单条上限 700 字,保留角色与工具标记);第3层=完整转写(按需 read)。',
|
|
2793
3618
|
]
|
|
3619
|
+
// 白板关(载体第0/1层缺失)时如实自述,避免新会话去找不存在的 PLAN.md/账本
|
|
3620
|
+
if (!withHandoff) parts.push('注意:本次启用了自动接续但**未启用白板/账本**(handoffEnabled=false)——没有第0/1层材料,请以第2层近期线程与第3层完整转写恢复上下文,不要去找 PLAN.md 或交接账本。')
|
|
2794
3621
|
if (plan) parts.push('【第0层 · 白板 PLAN.md(节选)】更新于 ' + fmtMt(planMt) + staleNote + NL + plan.slice(0, 3000))
|
|
2795
3622
|
if (ledger) {
|
|
2796
3623
|
// P6(2026-09-09):账本权重化截断 —— 按账本自身四段标题赋权(失败原因.35>下一步.30>目标.20>任务状态.15),
|
|
@@ -2809,11 +3636,11 @@ class MemoryEngine {
|
|
|
2809
3636
|
const guide = ['【第3层 · 完整转写与检索(按需)】']
|
|
2810
3637
|
if (pack.transcriptPath) {
|
|
2811
3638
|
guide.push('- 旧会话(' + pack.sessionId + ')的完整对话转写已写入: ' + pack.transcriptPath)
|
|
2812
|
-
guide.push('- 完整转写已归档,**不必在接续前通读**:仅在第0-2层不足以推进时再 read
|
|
3639
|
+
guide.push('- 完整转写已归档,**不必在接续前通读**:仅在第0-2层不足以推进时再 read;也可用 memory_recall(scope=\'sessions\', query=\'关键词\') 定位片段(前提:宿主侧已开启会话内容检索;该调用返回不可用时改用 scope=\'handoff\' 或直接 read 本转写,不要反复重试)。')
|
|
2813
3640
|
} else {
|
|
2814
3641
|
guide.push('- 旧会话 ID: ' + pack.sessionId + '(转写文件生成失败,可用 memory_recall 检索)')
|
|
2815
3642
|
}
|
|
2816
|
-
guide.push('- 历史会话全文检索: memory_recall(scope=\'sessions\', query=\'关键词\')
|
|
3643
|
+
guide.push('- 历史会话全文检索: memory_recall(scope=\'sessions\', query=\'关键词\') 可跨全部历史会话查证细节 —— **前提:宿主 DSH 侧已开启会话内容检索(出厂默认关闭)且历史日志格式与当前编解码器兼容**;不满足时该调用会明确报错(未部署/格式不兼容),此时改用 scope=\'handoff\' 或 read 转写文件,不要重复重试。')
|
|
2817
3644
|
if (pack.model) guide.push('- 状态沿用: 模型 ' + pack.model + (pack.provider ? ' @ ' + pack.provider : '') + (pack.reasoningEffort ? '(思考档位 ' + pack.reasoningEffort + ')' : '') + ' 已在新会话自动选回(失败则回退路由默认);工作区 ' + (workspaceId ? '按 workspaceId 绑定 ' + workspaceId : '按 cwd 绑定 ' + wsForSession) + ';插件配置(无人值守/水位/交接等)为全局配置,自动继承。')
|
|
2818
3645
|
if (pack.agentPreset) guide.push('- 旧会话 agentPreset: ' + pack.agentPreset + '(已随创建传递;注意 code→ptc 改名的历史会话需用户级兼容预设)。')
|
|
2819
3646
|
parts.push(guide.join(NL))
|
|
@@ -2840,6 +3667,27 @@ class MemoryEngine {
|
|
|
2840
3667
|
parts.push('【锚点表 · 记忆条目地图(按需下钻,不必通读)】' + NL + anchorSections.join(NL))
|
|
2841
3668
|
}
|
|
2842
3669
|
} catch (eAnchor) {}
|
|
3670
|
+
// ── P2-4(2026-09-16 接线):注入端白板 tag 导航层 ─────────────────────────
|
|
3671
|
+
// 规划 §5 P2-4 原文:「P5 锚点表区扩展一行 tag 摘要(『白板 tag 地图:type:dead-end×7…』),
|
|
3672
|
+
// 保持『同输入同字节』;总预算 18000 不变」。
|
|
3673
|
+
// **只在 graph 档注入** —— legacy 档必须与旧行为逐字节相同(硬纪律),否则每轮前缀缓存被击穿
|
|
3674
|
+
// 且旧用户看到无意义的一行。数据源=sidecar index.json 的 by_tag 倒排(fail-soft,无则整行跳过)。
|
|
3675
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
3676
|
+
try {
|
|
3677
|
+
const tagMap = await this.whiteboardTagMapPre(null, p)
|
|
3678
|
+
if (tagMap) parts.push('【白板 tag 地图(结构化导航)】' + tagMap + NL + ' · 用 memory_expand_pre(tag) 按 tag 展开条目;用 memory_trace_pre(id) 回溯某条的来源与版本链。')
|
|
3679
|
+
} catch (eTagMap) {}
|
|
3680
|
+
}
|
|
3681
|
+
// ── P3-2(2026-09-16 接线):第3层唤醒句 ───────────────────────────────────
|
|
3682
|
+
// 规划 §5 P3-2 原文:第3层 guide 加一句「白板已结构化:可用 memory_expand_pre/memory_trace_pre
|
|
3683
|
+
// 按 tag 主动重建,先于通读第3层」。此前只在工具描述里写了用途,接续首条消息里从未提示 ⇒
|
|
3684
|
+
// 模型不会主动去用(存量行为等同工具不存在)。同样只在 graph 档注入。
|
|
3685
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
3686
|
+
parts.push('【白板结构化检索(优先于通读第3层)】' + NL +
|
|
3687
|
+
'- 白板/账本已建结构化索引(handoff/index.json:按 tag 倒排 + 条目 id + 归档版本链)。' + NL +
|
|
3688
|
+
'- **先按 tag 主动重建,不要直接通读第3层转写**:memory_expand_pre(tag=\'type:dead-end\') 列出所有失败方案;memory_expand_pre(tag=\'*\') 看全部条目;条目 id 可用 memory_trace_pre(id) 回溯其来源(source 文件+行)、同 tag 邻居与归档版本链。' + NL +
|
|
3689
|
+
'- 仅当索引查不到所需内容时,再回落 read 第3层转写或 memory_recall(scope=\'handoff\')。')
|
|
3690
|
+
}
|
|
2843
3691
|
// 接续序号 v2(2026-09-13,NEXT-VERSION-TODO 改点2):持久计数器,全局单调(跨工作区不重号)。
|
|
2844
3692
|
// 取值链:转写包路径已分配的(pack.contSeq)→ 包缺失/失败时用持久计数器兜底分配 → 计数器彻底不可用
|
|
2845
3693
|
// 再退旧「文件数+1」口径 → 最后退 1。contSeq 必须有值:为空时两侧 rename 都被跳过、新会话标题
|
|
@@ -2942,7 +3790,9 @@ class MemoryEngine {
|
|
|
2942
3790
|
}
|
|
2943
3791
|
}
|
|
2944
3792
|
if (fileQ) {
|
|
2945
|
-
|
|
3793
|
+
// P2-5(2026-09-16): 白名单放行 sidecar 产物 —— 新版看板要在面板里直接看 index.json / events.jsonl。
|
|
3794
|
+
// 仍严格限定 handoff 目录内、仍是**枚举式**白名单(不做通配), 不给任意路径留口子。
|
|
3795
|
+
const m = String(fileQ).match(/^(?:PLAN\.md|handoff-\d{8}-\d{6}(?:-[a-z])?\.md|archive\/PLAN-\d{8}-\d{6}\.md|index\.json|events\.jsonl)$/)
|
|
2946
3796
|
if (!m) return { enabled: true, error: 'bad file name' }
|
|
2947
3797
|
const target = path.join(handoffDir, ...String(fileQ).split('/'))
|
|
2948
3798
|
return { enabled: true, file: fileQ, text: await this.readTextSafe(target) }
|
|
@@ -2964,6 +3814,38 @@ class MemoryEngine {
|
|
|
2964
3814
|
}
|
|
2965
3815
|
ledgers.sort((a, b) => b.mtime - a.mtime)
|
|
2966
3816
|
const planMt = await stat(planPath).then((s) => Number(s.mtimeMs)).catch(() => 0)
|
|
3817
|
+
// ── P2-5(2026-09-16 接线):GUI 结构化视图(tag/段视图)──────────────────────
|
|
3818
|
+
// 规划 §5 P2-5 原文:「handoffPanelData 增加 tag/段视图;fileQ 严格白名单放行 .json」。
|
|
3819
|
+
// 白名单放行已在上轮完成; 这里补**视图数据**:把 sidecar 的 by_tag 倒排 + 条目摘要
|
|
3820
|
+
// 直接随面板数据返回, 前端据此渲染「标签导航」区(无需再单独请求 index.json)。
|
|
3821
|
+
// 仅 graph 档计算(legacy 档不读 sidecar、不加字段, 保持返回结构对旧前端完全兼容)。
|
|
3822
|
+
let structured = null
|
|
3823
|
+
if ((String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph')) {
|
|
3824
|
+
try {
|
|
3825
|
+
const index = await this._loadSidecarIndexPre(p.projectDir || path.dirname(handoffDir))
|
|
3826
|
+
const byTag = (index && index.by_tag) || {}
|
|
3827
|
+
const entries = (index && Array.isArray(index.entries)) ? index.entries : []
|
|
3828
|
+
const tagRows = Object.keys(byTag)
|
|
3829
|
+
.filter((t) => !t.startsWith('sec:'))
|
|
3830
|
+
.map((t) => ({ tag: t, count: (byTag[t] || []).length }))
|
|
3831
|
+
.sort((a, b) => b.count - a.count || (a.tag < b.tag ? -1 : 1))
|
|
3832
|
+
.slice(0, 40)
|
|
3833
|
+
structured = {
|
|
3834
|
+
version: (index && index.version) || '',
|
|
3835
|
+
total: entries.length,
|
|
3836
|
+
tags: tagRows,
|
|
3837
|
+
// 段视图: 按 kind 分组(plan / ledger / archive), 各列前 20 条摘要
|
|
3838
|
+
sections: ['plan', 'ledger', 'archive'].map((k) => ({
|
|
3839
|
+
kind: k,
|
|
3840
|
+
count: entries.filter((e) => e.kind === k).length,
|
|
3841
|
+
items: entries.filter((e) => e.kind === k).slice(0, 20).map((e) => ({
|
|
3842
|
+
id: e.id, source: e.source, section: e.section, preview: e.preview, tags: e.tags,
|
|
3843
|
+
})),
|
|
3844
|
+
})),
|
|
3845
|
+
rebuiltAt: (index && index.rebuilt_at) || '',
|
|
3846
|
+
}
|
|
3847
|
+
} catch (_) { /* fail-soft: 结构化视图失败不影响面板主体 */ }
|
|
3848
|
+
}
|
|
2967
3849
|
// 刷新仪式的会话 id:内存态为空时走磁盘回退(宿主重启后 _lastAgent 未重建的窗口期)
|
|
2968
3850
|
const sid = this.currentSessionId() || this.recentSessionIdFallback()
|
|
2969
3851
|
return {
|
|
@@ -2978,6 +3860,8 @@ class MemoryEngine {
|
|
|
2978
3860
|
? { sessionId: '', prompt: '' }
|
|
2979
3861
|
: { sessionId: sid, prompt: this.refreshRitualPrompt() },
|
|
2980
3862
|
waterLevel: water,
|
|
3863
|
+
boardMode: (String((this.config || {}).boardMode || '').trim().toLowerCase() === 'graph') ? 'graph' : 'legacy',
|
|
3864
|
+
structured,
|
|
2981
3865
|
}
|
|
2982
3866
|
}
|
|
2983
3867
|
|
|
@@ -3212,13 +4096,44 @@ class MemoryEngine {
|
|
|
3212
4096
|
}
|
|
3213
4097
|
|
|
3214
4098
|
// ---------- 缓存刷新(串行队列:每次按序执行,最后一次生效) ----------
|
|
4099
|
+
/**
|
|
4100
|
+
* ★P2(2026-09-15 ALS 遗留②修复,用户已留痕裁定并入 P2 引擎隔离):
|
|
4101
|
+
* 旧实现直接读写 `this.state.loading` —— `state` 是 **ALS 敏感 getter**
|
|
4102
|
+
* (`get state()` → `currentRuntime().state`,见 :1003/:1007)。凡在 `withAgent` 之外调用
|
|
4103
|
+
* `refresh(agent)`(例如生命周期 `refreshAll` 路径),链的读写都落到 **default runtime**:
|
|
4104
|
+
* · per-agent 串行化意图失效(两个 agent 的刷新互不相识,甚至同一 agent 的两次刷新并发);
|
|
4105
|
+
* · `_doRefresh` 内部的 `this.state.ws = ...` 同样写错 runtime(既有 C 修复只在注入回调
|
|
4106
|
+
* 做了 withAgent 包裹,其余调用点仍是裸调)。
|
|
4107
|
+
* 修法:**串行链按 runtime 对象隔离**(`this._refreshChains` WeakMap,键 = 目标 runtime),
|
|
4108
|
+
* 且链体一律在 `_runtimeContext.run(rt, ...)` 内执行 —— 无论调用方是否已包 withAgent,
|
|
4109
|
+
* 状态写入与串行判定都归属**该 agent 的 runtime**。语义(按序执行、最后一次生效、不抛)不变。
|
|
4110
|
+
*/
|
|
3215
4111
|
async refresh(agent) {
|
|
3216
|
-
const
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
)
|
|
3221
|
-
this.
|
|
4112
|
+
const rt = this.runtimeFor(agent)
|
|
4113
|
+
if (!this._refreshChains) this._refreshChains = new WeakMap()
|
|
4114
|
+
const previous = this._refreshChains.get(rt) || Promise.resolve()
|
|
4115
|
+
const run = () => this._runtimeContext.run(rt, () => this._doRefresh(agent))
|
|
4116
|
+
const next = previous.then(run, run)
|
|
4117
|
+
this._refreshChains.set(rt, next)
|
|
4118
|
+
// ★P2 镜像:session-scoped refresh 的路径字段同步回 default runtime。
|
|
4119
|
+
// 消费方(GUI「当前工作区」、handoff-state/handoff-continue 路由)语义=「最近活跃会话」的工作区,
|
|
4120
|
+
// 它们在 ALS 上下文之外读 this.state ⇒ 只能读 default runtime。P2 修复前 refresh 全写 default
|
|
4121
|
+
// (「错得一致」);修复后若不镜像,路由会永远拿到空工作区(contseq 套件实证 17/3)。
|
|
4122
|
+
// 镜像只覆盖路径类字段且只进 default —— agent runtime 的状态单源不被触碰。
|
|
4123
|
+
if (rt !== this.runtimes.get(undefined)) {
|
|
4124
|
+
next.then(() => {
|
|
4125
|
+
try {
|
|
4126
|
+
const def = this.runtimes.get(undefined)
|
|
4127
|
+
if (!def || def.state) {
|
|
4128
|
+
const s = rt.state || {}
|
|
4129
|
+
const keys = ['ws', 'userDir', 'projectDir', 'notesPath', 'logPath', 'reflectDir', 'handoffDir', 'planPath']
|
|
4130
|
+
const patch = {}
|
|
4131
|
+
for (const k of keys) if (s[k] != null) patch[k] = s[k]
|
|
4132
|
+
if (Object.keys(patch).length) Object.assign(def.state, patch)
|
|
4133
|
+
}
|
|
4134
|
+
} catch (eMirror) {}
|
|
4135
|
+
}).catch(() => {})
|
|
4136
|
+
}
|
|
3222
4137
|
return next
|
|
3223
4138
|
}
|
|
3224
4139
|
|
|
@@ -3306,6 +4221,8 @@ class MemoryEngine {
|
|
|
3306
4221
|
}
|
|
3307
4222
|
this.state.workspaceMap = map
|
|
3308
4223
|
} catch (e) { this.state.workspaceMap = [] }
|
|
4224
|
+
// C5 三层注入:Tier-0 常驻目录 + 闸门下探段(纯文本派生,零额外 IO/零 LLM;I7 降级标注在内)
|
|
4225
|
+
this.buildTierLayerInjection(agent)
|
|
3309
4226
|
this.state.loadedAt = Date.now()
|
|
3310
4227
|
} catch (e) {
|
|
3311
4228
|
console.error('[dsh-auto-memory] refresh failed', e)
|
|
@@ -3403,19 +4320,36 @@ class MemoryEngine {
|
|
|
3403
4320
|
* 复查却不过、工具还把成功标识 `folded` 当失败原因报出来"的怪象(实机踩到)。
|
|
3404
4321
|
* 现在每轮都用**重新测量的实际文件大小**算缺口,复查不过就再来一轮;失败时返回**真实**原因。
|
|
3405
4322
|
*/
|
|
3406
|
-
async ensureBudget(agent, layer, text) {
|
|
4323
|
+
async ensureBudget(agent, layer, text, options = {}) {
|
|
3407
4324
|
const p = await this.resolvePaths(agent)
|
|
3408
|
-
|
|
4325
|
+
const raw = String(text == null ? '' : String(text))
|
|
4326
|
+
const replace = !!options.replace
|
|
4327
|
+
// issue #38 根因 B(额度误计):`replace` 是**整篇替换**,新正文不叠加在旧文件上
|
|
4328
|
+
// (见调用点:replace 分支直接 `writeFull(p, body)`,不 append)。旧实现对两种动作
|
|
4329
|
+
// 一律按 `当前长度 + 本次内容 ≤ 上限` 计费 ⇒ 文件已接近上限时,任何整体替换都会被
|
|
4330
|
+
// 判超限而拒绝,哪怕替换后的正文比原文**更短**。这里按动作分口径:
|
|
4331
|
+
// replace → 只按替换后的长度计费;
|
|
4332
|
+
// append → 沿用"当前 + 新增 ≤ 上限"(含标题行开销)。
|
|
4333
|
+
const curChars = await this.layerCharCount(layer, p)
|
|
4334
|
+
let acct = replace
|
|
4335
|
+
? (() => { const need = raw.length; return { ok: need <= this.capacityLimit(layer), limit: this.capacityLimit(layer), used: curChars, need, add: need - curChars } })()
|
|
4336
|
+
: this.capacityCheck(layer, curChars, text)
|
|
3409
4337
|
if (acct.ok) return { ok: true, acct }
|
|
3410
4338
|
let last = null
|
|
3411
4339
|
let compactedOnce = false
|
|
3412
4340
|
for (let round = 0; round < 3; round++) {
|
|
3413
4341
|
const cur = await this.layerCharCount(layer, p)
|
|
3414
|
-
|
|
4342
|
+
// replace 需要腾出的缺口 = 替换后长度 − 上限;append 的缺口含标题行开销。
|
|
4343
|
+
const need = replace
|
|
4344
|
+
? raw.length
|
|
4345
|
+
: cur + raw.length + this.appendOverheadChars()
|
|
3415
4346
|
last = await this.compactLayer(agent, layer, { needChars: need, round })
|
|
3416
4347
|
if (!last || !last.ok) break
|
|
3417
4348
|
compactedOnce = true
|
|
3418
|
-
|
|
4349
|
+
const cur2 = await this.layerCharCount(layer, p)
|
|
4350
|
+
acct = replace
|
|
4351
|
+
? (() => { const n = raw.length; return { ok: n <= this.capacityLimit(layer), limit: this.capacityLimit(layer), used: cur2, need: n, add: n - cur2 } })()
|
|
4352
|
+
: this.capacityCheck(layer, cur2, text)
|
|
3419
4353
|
if (acct.ok) return { ok: true, acct, compacted: true }
|
|
3420
4354
|
}
|
|
3421
4355
|
const reason = (!last || !last.ok)
|
|
@@ -3507,13 +4441,19 @@ class MemoryEngine {
|
|
|
3507
4441
|
const cur = String((layer === 'user' ? this.state.userText : this.state.notesText) || '')
|
|
3508
4442
|
if (!cur.trim()) return { ok: true, reason: 'empty' }
|
|
3509
4443
|
const segs = []
|
|
3510
|
-
let curSeg = { title: '(文件头)', body: [] }
|
|
4444
|
+
let curSeg = { title: '(文件头)', synthetic: true, body: [] }
|
|
3511
4445
|
for (const ln of cur.split('\n')) {
|
|
3512
4446
|
const m = ln.match(/^##\s+(.+)$/)
|
|
3513
|
-
if (m) { segs.push(curSeg); curSeg = { title: m[1].trim(), body: [] } } else { curSeg.body.push(ln) }
|
|
4447
|
+
if (m) { segs.push(curSeg); curSeg = { title: m[1].trim(), synthetic: false, body: [] } } else { curSeg.body.push(ln) }
|
|
3514
4448
|
}
|
|
3515
4449
|
segs.push(curSeg)
|
|
3516
|
-
|
|
4450
|
+
// issue #38 根因 A(净增长):旧实现无条件用 `'## ' + title + '\n'` 重新序列化**每一个**段落,
|
|
4451
|
+
// 于是只要正文首行直接是日期标题(没有文件头 preamble),占位段 `(文件头)` 就会被**凭空写出**
|
|
4452
|
+
// 一行 `## (文件头)`,使文件每整理一轮净增 10 字符且**永不收敛**,最终所有写入被永久拒绝。
|
|
4453
|
+
// 修法:占位段(`synthetic`,没有真实标题)按**纯正文**序列化,不制造 `## (文件头)` 行;
|
|
4454
|
+
// 真实标题段落照旧写 `## 标题`。空串段落在拼接时被 filter(Boolean) 丢弃。
|
|
4455
|
+
const seqOf = (s) => (s.synthetic ? s.body.join('\n') : ('## ' + s.title + '\n' + s.body.join('\n')))
|
|
4456
|
+
const spanOf = (s) => seqOf(s).length
|
|
3517
4457
|
const keep = new Array(segs.length).fill(false)
|
|
3518
4458
|
let kept = 0
|
|
3519
4459
|
for (let i = segs.length - 1; i >= 0; i--) {
|
|
@@ -3523,10 +4463,10 @@ class MemoryEngine {
|
|
|
3523
4463
|
break
|
|
3524
4464
|
}
|
|
3525
4465
|
const oldSegs = segs.filter((_, i) => !keep[i])
|
|
3526
|
-
const keptText = segs.filter((_, i) => keep[i]).map(
|
|
4466
|
+
const keptText = segs.filter((_, i) => keep[i]).map(seqOf).filter(Boolean).join('\n').trim()
|
|
3527
4467
|
if (!oldSegs.length) return { ok: false, reason: 'no-removable' }
|
|
3528
4468
|
|
|
3529
|
-
const oldText = oldSegs.map(
|
|
4469
|
+
const oldText = oldSegs.map(seqOf).filter(Boolean).join('\n').trim()
|
|
3530
4470
|
let folded = allowFold ? await this.foldTextToSummaryPre(oldText, layer, agent) : ''
|
|
3531
4471
|
if (!folded) {
|
|
3532
4472
|
// AI 不可用降级:把最早段落整段移入归档,直到剩余 ≤ keepBudget(保底不丢)
|
|
@@ -3535,11 +4475,13 @@ class MemoryEngine {
|
|
|
3535
4475
|
: path.join(p.projectDir, 'archive', 'notes-archived.md')
|
|
3536
4476
|
await mkdir(path.dirname(archiveFile), { recursive: true })
|
|
3537
4477
|
const rest = oldSegs.slice()
|
|
3538
|
-
let remainText = rest.map(
|
|
4478
|
+
let remainText = rest.map(seqOf).filter(Boolean).join('\n').trim()
|
|
3539
4479
|
while (remainText.length > keepBudget && rest.length > 1) {
|
|
3540
4480
|
const seg = rest.shift()
|
|
3541
|
-
|
|
3542
|
-
|
|
4481
|
+
// issue #38:归档同样走 seqOf —— 占位段不得在归档里凭空生成 `## (文件头)` 标题。
|
|
4482
|
+
const segText = seqOf(seg)
|
|
4483
|
+
if (segText) await this.appendText(archiveFile, '\n' + segText)
|
|
4484
|
+
remainText = rest.map(seqOf).filter(Boolean).join('\n').trim()
|
|
3543
4485
|
}
|
|
3544
4486
|
folded = remainText.slice(0, keepBudget)
|
|
3545
4487
|
} else {
|
|
@@ -3548,7 +4490,8 @@ class MemoryEngine {
|
|
|
3548
4490
|
? path.join(dshHome(), 'memory', 'archived-user.md')
|
|
3549
4491
|
: path.join(p.projectDir, 'archive', 'notes-archived.md')
|
|
3550
4492
|
await mkdir(path.dirname(archiveFile), { recursive: true })
|
|
3551
|
-
|
|
4493
|
+
const archText = oldSegs.map(seqOf).filter(Boolean).join('\n')
|
|
4494
|
+
if (archText) await this.appendText(archiveFile, '\n' + archText)
|
|
3552
4495
|
}
|
|
3553
4496
|
const body = [folded, keptText].filter(Boolean).join('\n\n')
|
|
3554
4497
|
await this.writeFull(layer === 'user' ? p.userFile : p.notesPath, body)
|
|
@@ -3695,16 +4638,378 @@ class MemoryEngine {
|
|
|
3695
4638
|
}
|
|
3696
4639
|
|
|
3697
4640
|
|
|
4641
|
+
/**
|
|
4642
|
+
* T0-2(2026-09-14 P0):取当前语料的 `memoryIndexVersion`(miv)—— **零重读**的实现。
|
|
4643
|
+
*
|
|
4644
|
+
* 为什么不用 `context-host`/`activation-host` 的 `CorpusRegistry.get()`:那个 `get()` 内部会
|
|
4645
|
+
* 为每个来源调 `sourceFingerprint`(`statSync`),并且一旦指纹变化就**整体重读并重建快照**
|
|
4646
|
+
* (`m4-corpus.js:150-166`)。注入装配是每轮热路径,不能顺手触发一次全量重建。
|
|
4647
|
+
*
|
|
4648
|
+
* 这里只用 `statSync` 的 `size:mtimeMs` 指纹判"有没有变":
|
|
4649
|
+
* - 三个来源指纹与上次一致(且未过期)⇒ 直接返回上次算出的 miv(零重读);
|
|
4650
|
+
* - 有任何一个变了 ⇒ 追加一个 `all` 版本标记,**不重算哈希**(miv 只需在"变了"时改动,
|
|
4651
|
+
* 精确值由真正建索引的那条路径负责,本轮因为 `miv !== 投递时的 miv` 会 fail closed 不复用 —— 安全侧)。
|
|
4652
|
+
*
|
|
4653
|
+
* 任何异常一律返回 `null`:调用方(`selectReusableTierHitsPre`)对 null 是 fail closed 不复用,
|
|
4654
|
+
* 不会因为拿不到 miv 就把旧候选当本轮的用。
|
|
4655
|
+
*/
|
|
4656
|
+
tierCurrentMivPre() {
|
|
4657
|
+
try {
|
|
4658
|
+
const s = this.state
|
|
4659
|
+
if (!s || !s.ws) return null
|
|
4660
|
+
const srcs = [
|
|
4661
|
+
s.userDir ? path.join(s.userDir, 'MEMORY.md') : null,
|
|
4662
|
+
s.notesPath || null,
|
|
4663
|
+
s.logPath || null,
|
|
4664
|
+
].filter(Boolean)
|
|
4665
|
+
if (!srcs.length) return null
|
|
4666
|
+
const fp = srcs.map((f) => String(f) + '=' + String(sourceFingerprint(f))).join('|')
|
|
4667
|
+
const now = Date.now()
|
|
4668
|
+
const cache = this._tierMivPre
|
|
4669
|
+
// 指纹一致且未过期 ⇒ 复用上次结果(零重读、零哈希)
|
|
4670
|
+
if (cache && cache.fp === fp && now - cache.at < 60000) return cache.miv
|
|
4671
|
+
// 指纹变化或首次:miv 用**确定性短哈希**(不读全文,只哈希"路径+指纹+秒")。
|
|
4672
|
+
// 同一秒内的重复查询命中上面的缓存分支,故不会每轮都变。
|
|
4673
|
+
const miv = 'idx_' + createHash('sha256').update('tier-miv\u0000' + fp).digest('hex').slice(0, 32)
|
|
4674
|
+
this._tierMivPre = { fp, miv, at: now }
|
|
4675
|
+
return miv
|
|
4676
|
+
} catch (_) { return null }
|
|
4677
|
+
}
|
|
4678
|
+
|
|
4679
|
+
/**
|
|
4680
|
+
* C5 三层注入装配(2026-09-14):把**已缓存语料**装成 Tier-0 常驻目录 + 闸门下探段(I7 显式降级标注)。
|
|
4681
|
+
* 纯文本处理(零额外 IO、零 LLM 调用 —— 契约 S9;目录生成器 = lib/tier0-catalog.js,
|
|
4682
|
+
* 装配 = lib/tier-layer-inject.js),产物挂 `state.tier0LayerText` 供 renderMemoryDynamic 读取。
|
|
4683
|
+
*
|
|
4684
|
+
* 输入:语料 = state.userText/notesText/logText/planText/latestReflection(本次 refresh 已读盘);
|
|
4685
|
+
* 命中 = `this._tierGateHits`(activation-host 投递时记录的本轮语义命中);
|
|
4686
|
+
* 索引状态 = `this._lastIndexDegrade`(context-host 在 index-not-ready 时记录 → I7 降级标注)。
|
|
4687
|
+
* 命中复用**必须过版本门**(T0-2,2026-09-14):`selectReusableTierHitsPre` 逐项比对
|
|
4688
|
+
* contextVersion / miv / sessionId / workspaceKey / observationId,任一不可证明即 fail closed 不复用
|
|
4689
|
+
* (旧实现只查 `Date.now()-at<30min`,会让 A 快照的候选进 B 快照的注入,违反契约 I6)。
|
|
4690
|
+
* 预算:Tier-0 ≤ B0=800 token,且 ≤ ceil(injectBudgetChars × tier0BudgetShare / 2) token
|
|
4691
|
+
* (目录不得把证据层挤空);超出部分由装配器裁"下探段"并显式标注,目录层与降级行永不裁。
|
|
4692
|
+
*/
|
|
4693
|
+
buildTierLayerInjection(agent) {
|
|
4694
|
+
const s = this.state
|
|
4695
|
+
try {
|
|
4696
|
+
const cfg = this.config || {}
|
|
4697
|
+
if (cfg.tier0CatalogEnabled === false) { s.tier0LayerText = ''; s.tier0Meta = null; return '' }
|
|
4698
|
+
const sources = []
|
|
4699
|
+
const add = (layer, text, p) => {
|
|
4700
|
+
const t = text == null ? '' : String(text)
|
|
4701
|
+
if (t.trim()) sources.push({ layer, text: t, path: p || '' })
|
|
4702
|
+
}
|
|
4703
|
+
add('user', s.userText, s.userDir ? path.join(s.userDir, 'MEMORY.md') : '')
|
|
4704
|
+
add('project', s.notesText, s.notesPath)
|
|
4705
|
+
add('log', s.logText, s.logPath)
|
|
4706
|
+
add('whiteboard', s.planText, s.planPath)
|
|
4707
|
+
add('reflection', s.latestReflection, (s.reflectDir && s.latestReflectionDate) ? path.join(s.reflectDir, s.latestReflectionDate + '.md') : '')
|
|
4708
|
+
const budgetChars = Math.max(Number(cfg.injectBudgetChars) || 1600, 400)
|
|
4709
|
+
const shareN = Number(cfg.tier0BudgetShare)
|
|
4710
|
+
const share = Number.isFinite(shareN) && shareN > 0 ? Math.min(shareN, 0.8) : 0.4
|
|
4711
|
+
const maxTotalChars = Math.max(200, Math.floor(budgetChars * share))
|
|
4712
|
+
const cfgTokens = Number(cfg.tier0MaxTokens) > 0 ? Number(cfg.tier0MaxTokens) : TIER_BUDGET_V1.B0
|
|
4713
|
+
const maxTokens = Math.min(cfgTokens, TIER_BUDGET_V1.B0, Math.max(120, Math.ceil(maxTotalChars / 2)))
|
|
4714
|
+
// T0-2(2026-09-14 P0):命中投影**不得只凭时间复用**。
|
|
4715
|
+
//
|
|
4716
|
+
// 旧实现(本函数改造前):
|
|
4717
|
+
// const fresh = !!gh && Date.now() - (Number(gh.at) || 0) < 30 * 60000
|
|
4718
|
+
// const sameSession = !agentSessionId || !gh || !gh.sessionId || String(gh.sessionId) === agentSessionId
|
|
4719
|
+
// 两个缺陷:① 只查时间、不查版本 ⇒「A 快照产生的候选到 B 快照才准备输出」时 A 的正文会进 B 的
|
|
4720
|
+
// 注入,违反契约 I6(三层必须同一 miv,混版视为错误);② 身份拿不到时 fail **open**(`!agentSessionId`
|
|
4721
|
+
// 直接算通过)⇒ 会拿别的会话的候选当本轮的用。
|
|
4722
|
+
// 现改为:时间门保留(挡"投影留太久"),但**版本、miv、会话、工作区、观测身份五道门全部要比对**,
|
|
4723
|
+
// 任一不能证明同版同源即 **fail closed 不复用**(代价只是本轮不下探 Tier-1 = 省 token 的安全侧)。
|
|
4724
|
+
const agentSessionId = String((agent && agent.session && agent.session.id) || '')
|
|
4725
|
+
// ★多工作区适配(2026-09-17):投影**按会话取**,不再读"最近一次投递"的单槽。
|
|
4726
|
+
// 旧实现 `this._tierGateHits` 是全局单槽:A 投递后被 B 覆盖 ⇒ A 下一轮取到 B 的投影
|
|
4727
|
+
// ⇒ 身份门判 session-mismatch ⇒ A **永远不下探 Tier-1**("谁也没法注入"的直接成因)。
|
|
4728
|
+
// 现在优先按当前会话取;取不到再退回兼容单槽(老数据/老调用方仍可工作)。
|
|
4729
|
+
// 判定口径(T0-2 五道门)一字未改:仍然逐项比对,任一不能证明同版同源即 fail closed。
|
|
4730
|
+
const gh = (this._tierGateHitsBySession && agentSessionId
|
|
4731
|
+
? (this._tierGateHitsBySession.get(agentSessionId) || null)
|
|
4732
|
+
: null) || this._tierGateHits || null
|
|
4733
|
+
// ⚠️ 这里**必须**用 `peekRuntime`(只读、不创建),不能用 `runtimeFor`(= `runtimes.get`,
|
|
4734
|
+
// 找不到时会 createSessionRuntime 并重新登记)。`buildTierLayerInjection` 在 refresh 之后
|
|
4735
|
+
// 可能对已 dispose 的 agent 执行,用 `runtimeFor` 会**复活已销毁的 runtime** ——
|
|
4736
|
+
// 实测病症:smoke-test-context-observer 的 P3f 报 `runtime B survived dispose`。
|
|
4737
|
+
const rtForCv = this.peekRuntime(agent)
|
|
4738
|
+
const reuse = selectReusableTierHitsPre({
|
|
4739
|
+
projection: gh,
|
|
4740
|
+
now: Date.now(),
|
|
4741
|
+
sessionId: agentSessionId,
|
|
4742
|
+
workspaceKey: s.ws,
|
|
4743
|
+
contextVersion: rtForCv ? rtForCv.contextVersion : undefined,
|
|
4744
|
+
miv: this.tierCurrentMivPre(),
|
|
4745
|
+
})
|
|
4746
|
+
const hits = reuse.reuse ? reuse.hits : []
|
|
4747
|
+
const question = reuse.reuse ? String(reuse.question || '') : ''
|
|
4748
|
+
// 不复用必须有据可查(I7:降级不静默)——但**只在"本来有投影却不给用"时**才记,
|
|
4749
|
+
// 免得"从未发生投递"这种常态每轮刷一条噪声。原因码与可读文本都进 tier0Meta,供面板/排障。
|
|
4750
|
+
const reuseBlocked = !!gh && !reuse.reuse && reuse.reason !== 'no-projection' ? reuse.reason : null
|
|
4751
|
+
const dg = (this._indexDegradeBySession && agentSessionId
|
|
4752
|
+
? (this._indexDegradeBySession.get(agentSessionId) || null)
|
|
4753
|
+
: null) || this._lastIndexDegrade || null
|
|
4754
|
+
// 多工作区适配(2026-09-17):优先按**当前会话**取降级记录。旧实现只读全局单槽 + 10 分钟窗
|
|
4755
|
+
// ⇒ B 会话的"未就绪"会把 A 会话污染十分钟(跨会话假降级)。现在各会话只看到自己的记录。
|
|
4756
|
+
const indexNotReady = dg && Date.now() - (Number(dg.at) || 0) < 10 * 60000 ? dg.reason : null
|
|
4757
|
+
const semanticArm = (typeof this._jsSemanticRank === 'function') ? true : 'unavailable'
|
|
4758
|
+
const res = composeTieredInjectionPre({
|
|
4759
|
+
sources, maxTokens, maxTotalChars, hits, question, indexNotReady, semanticArm,
|
|
4760
|
+
// 群反馈第 4 条:用户排除的来源在注入闸门之前挡下(见 injectExcludeSources 注释)。
|
|
4761
|
+
excludeSources: Array.isArray(cfg.injectExcludeSources) ? cfg.injectExcludeSources : [],
|
|
4762
|
+
extraDegradations: reuseBlocked
|
|
4763
|
+
? ['命中投影未复用(' + describeReuseReasonPre(reuseBlocked) + ')· 本轮不下探 Tier-1,仅常驻目录']
|
|
4764
|
+
: [],
|
|
4765
|
+
})
|
|
4766
|
+
s.tier0LayerText = res.text || ''
|
|
4767
|
+
s.tier0Meta = {
|
|
4768
|
+
version: 'tier_layer_inject_v1',
|
|
4769
|
+
tokens: res.tier0Tokens,
|
|
4770
|
+
maxTokens,
|
|
4771
|
+
maxTotalChars,
|
|
4772
|
+
items: res.tier0 && Array.isArray(res.tier0.items) ? res.tier0.items.length : 0,
|
|
4773
|
+
candidates: res.tier0 ? res.tier0.candidates : 0,
|
|
4774
|
+
dropped: res.tier0 ? res.tier0.dropped : 0,
|
|
4775
|
+
perLayer: res.tier0 && res.tier0.quota ? res.tier0.quota.perLayer : null,
|
|
4776
|
+
gate: res.gate,
|
|
4777
|
+
degradations: res.degradations.map((d) => d.code),
|
|
4778
|
+
trimmedLines: res.trimmedLines,
|
|
4779
|
+
// T0-2 复用账(不新增字段名冲突:statusFiltered 与 reuse 各自独立可观测)
|
|
4780
|
+
reuse: {
|
|
4781
|
+
ok: reuse.reuse,
|
|
4782
|
+
reason: reuse.reason,
|
|
4783
|
+
reasonText: describeReuseReasonPre(reuse.reason),
|
|
4784
|
+
blocked: reuseBlocked,
|
|
4785
|
+
projectionAt: gh ? gh.at : null,
|
|
4786
|
+
projectionCv: gh ? gh.contextVersion : null,
|
|
4787
|
+
projectionMiv: gh ? gh.miv : null,
|
|
4788
|
+
projectionObservationId: gh ? gh.observationId : null,
|
|
4789
|
+
currentCv: rtForCv ? rtForCv.contextVersion : null,
|
|
4790
|
+
currentMiv: reuse.snapshot && reuse.reuse ? reuse.snapshot.miv : this.tierCurrentMivPre(),
|
|
4791
|
+
},
|
|
4792
|
+
statusFiltered: res.hits ? { total: res.hits.total, current: res.hits.current, dropped: res.hits.droppedCount } : null,
|
|
4793
|
+
// 群反馈第 4 条排除账:面板/排障可区分「本来没命中」与「被用户排除项挡下」。
|
|
4794
|
+
excludedSources: res.excluded || { count: 0, total: 0, patterns: [] },
|
|
4795
|
+
at: Date.now(),
|
|
4796
|
+
}
|
|
4797
|
+
return s.tier0LayerText
|
|
4798
|
+
} catch (e) {
|
|
4799
|
+
// C5 fail-open:目录层出任何异常都不拖垮记忆快照(I7:但必须留下可见痕迹,不静默)
|
|
4800
|
+
try {
|
|
4801
|
+
s.tier0LayerText = TIER_MARK_V1.degrade + ' Tier-0 目录生成失败(' + ((e && e.message) || 'unknown') + ');本轮仅注入证据层'
|
|
4802
|
+
s.tier0Meta = { version: 'tier_layer_inject_v1', error: String((e && e.message) || 'unknown'), at: Date.now() }
|
|
4803
|
+
} catch (_) {}
|
|
4804
|
+
return s.tier0LayerText || ''
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4808
|
+
/**
|
|
4809
|
+
* ★2026-09-15(用户裁定):**分级注入的精简版**——每轮都在场的"索引 + 规矩 + 日程"。
|
|
4810
|
+
*
|
|
4811
|
+
* **为什么需要它**:注入调用方对完整快照做轮次节流(`snapshotMinGapRounds`,省 token 的设计意图)。
|
|
4812
|
+
* 旧实现在节流分支里直接 `return renderReflectionRequest()`,把整份快照跳过 ⇒ **规矩与索引在第 2–5 轮不在场**
|
|
4813
|
+
* (用户原话:「模型不是不听话,是没收到」;也是 P6A 要修的病 B)。
|
|
4814
|
+
* 用户提出的形态是「**不是不注入,而是精简注入**:第 1 轮完整,第 2–5 轮精简」——本函数即精简版:
|
|
4815
|
+
*
|
|
4816
|
+
* - 规则段(`rulesLayeringMode` 开启时):**每轮在场**,且不参与裁剪;
|
|
4817
|
+
* - **Tier-0 常驻目录**:这是"索引层",本身就是 Karpathy 式「先读索引再决定下探哪一页」,
|
|
4818
|
+
* 放在精简版里收益最高(模型知道**有什么**可读,需要时用 `memory_recall` / `memory_read` 取全文);
|
|
4819
|
+
* - 日历:短、时效强(deadline 错过不可逆),保留;
|
|
4820
|
+
* - 其余证据层(用户级/项目笔记/白板/账本/日志/反思/外部路径)**不进精简版**,由完整版每 N 轮给一次。
|
|
4821
|
+
*
|
|
4822
|
+
* **成本与缓存(如实标注)**:
|
|
4823
|
+
* - **不击穿前缀缓存**:动态快照走 `systemPrompt.context()`(user-role,**追加在历史尾部**),
|
|
4824
|
+
* system prompt 的静态纪律是另一通道且字节稳定 ⇒ 精简/完整交替只改变"追加到尾部的那一块",历史前缀不动;
|
|
4825
|
+
* - **有可预算的边际开销**:与"完全跳过"相比每轮多约 1k 字符(≈500 token)。
|
|
4826
|
+
* 以 1M 窗口计约 0.05%/轮;且**比"每轮都注入完整版"便宜得多**(后者是它的数倍)。
|
|
4827
|
+
*
|
|
4828
|
+
* **依赖边界(如实标注,不宣称已保证)**:本函数保证"规则与索引进入了**注入函数的返回值**且不受节流"。
|
|
4829
|
+
* "模型**真的收到了**"取决于宿主最终请求 messages 的确认(`MASTER-PLAN-3.0.md §7` 的 **U6**,尚未具备)
|
|
4830
|
+
* ⇒ 不得据此宣称"规则的遵守问题已解决"(T7-7 纪律:仅引导语不同不算问题解决)。
|
|
4831
|
+
*
|
|
4832
|
+
* 自包含:与 `renderMemoryDynamic` 一样只读 `state` 字段 + `config`,源码抽取测试可独立执行。
|
|
4833
|
+
*/
|
|
4834
|
+
renderSlimSnapshotPre(wsHint) {
|
|
4835
|
+
try {
|
|
4836
|
+
const s = this.state
|
|
4837
|
+
// ★2026-09-15(用户裁定 · 修「重启后首轮工作区显示 (未知)」):
|
|
4838
|
+
// `state.ws` 由 `_doRefresh`(异步)写入,而注入回调里的 `void engine.refresh(agent)`(:8192)
|
|
4839
|
+
// 是**发射后不管**(未 await)⇒ 本 turn 首次注入可能早于 `state.ws` 落地 ⇒ 头帧渲染出 `(未知)`。
|
|
4840
|
+
// 分级注入之前,"第 2–5 轮"整份跳过、只有完整版会渲染,而完整版受 `pre-step` 的
|
|
4841
|
+
// `await engine.refresh(agent)`(:8149)保护 ⇒ 从未暴露该竞态;精简版是 09-15 新增的渲染路径,
|
|
4842
|
+
// 不受那道保护 ⇒ 才显形(**不是新 bug,是新路径暴露了旧竞态**)。
|
|
4843
|
+
// 兜底口径 = `resolvePaths(agent)` 的第①优先级(:1735 `session.header.cwd`,同步零 IO),
|
|
4844
|
+
// 与 GUI 概览页 `currentWs()`(client.js:485 读 session.cwd)**同源**。
|
|
4845
|
+
// 语义约束(用户明确要求):**只管第一轮** —— 仅在 `s.ws` 为空时生效,就绪后自动走原路径,
|
|
4846
|
+
// 不写 state、不影响后续轮次,故不引入耦合。
|
|
4847
|
+
const wsEff = s.ws || wsHint || ''
|
|
4848
|
+
const cfg = this.config
|
|
4849
|
+
const L = (key, vars) => {
|
|
4850
|
+
const ov = cfg.promptLayerOverrides && cfg.promptLayerOverrides[key]
|
|
4851
|
+
let txt = (ov && ov.trim() !== '' ? ov : (DEFAULT_PROMPT_LAYERS[key] || ''))
|
|
4852
|
+
if (txt) for (const k of Object.keys(vars || {})) txt = txt.split('{' + k + '}').join(String(vars[k]))
|
|
4853
|
+
return txt
|
|
4854
|
+
}
|
|
4855
|
+
const d = this.memToday()
|
|
4856
|
+
const out = []
|
|
4857
|
+
// ① 规则段(每轮在场;未启用规则分层时为空)
|
|
4858
|
+
if (String(cfg.rulesLayeringMode || 'off').toLowerCase() !== 'off') {
|
|
4859
|
+
const layer = extractRulesLayerPre({
|
|
4860
|
+
userText: s.userText,
|
|
4861
|
+
notesText: s.notesText,
|
|
4862
|
+
rulesLayeringMode: cfg.rulesLayeringMode,
|
|
4863
|
+
})
|
|
4864
|
+
const sec = renderRulesSectionPre(layer, { title: L('snapshotRulesTitle'), guide: L('snapshotRulesGuide') })
|
|
4865
|
+
if (sec.text) out.push(sec.text)
|
|
4866
|
+
}
|
|
4867
|
+
// ② Tier-0 常驻目录(索引层:每轮都在场;refresh() 预计算)
|
|
4868
|
+
if (s.tier0LayerText) out.push('\n' + L('snapshotTier0Title') + '\n' + s.tier0LayerText)
|
|
4869
|
+
// ★2026-09-15(用户裁定):**白板 PLAN 与交接账本必须进精简版**。
|
|
4870
|
+
// 用户的理由(原话要点):每个轮次结束时理论上都要更新白板;轮次间若触发自动接续,
|
|
4871
|
+
// 也要强制更新白板与账本。**不把 PLAN/账本放进精简版,模型在 2–5 轮就看不见自己该更新什么** ——
|
|
4872
|
+
// 与「规矩每轮在场」是同一类病:不是不听话,是没收到。
|
|
4873
|
+
//
|
|
4874
|
+
// 与完整版的差别(**这是刻意的预算取舍**):完整版给 `handoffPlanChars`/`handoffLedgerChars`
|
|
4875
|
+
// 的常规额度;精简版给**更小的额度**(各 `slimPlanChars`/`slimLedgerChars`,默认 400/300),
|
|
4876
|
+
// 目的不是"看到全文"而是"看到当前白板与账本长什么样、以及它们已经旧了"。
|
|
4877
|
+
// 要全文仍走 `memory_read(kind=notes)` / 直接读文件(标题行已提示)。
|
|
4878
|
+
if (cfg.handoffEnabled !== false && (s.planText || s.latestHandoffText)) {
|
|
4879
|
+
const slimPlan = truncateLinesBounded(stripSensitiveSections(sanitizeForInjection(s.planText || '')), Math.max(Number(cfg.slimPlanChars) || 400, 100))
|
|
4880
|
+
if (slimPlan) out.push('\n' + L('snapshotPlanTitle') + '\n' + slimPlan)
|
|
4881
|
+
const slimLedger = truncateLinesBounded(stripSensitiveSections(sanitizeForInjection(s.latestHandoffText || '')), Math.max(Number(cfg.slimLedgerChars) || 300, 100))
|
|
4882
|
+
if (slimLedger) out.push('\n' + L('snapshotHandoffTitle') + '\n' + slimLedger)
|
|
4883
|
+
}
|
|
4884
|
+
// ③ ★2026-09-15 审核修正:**日历不进精简版**。
|
|
4885
|
+
// 原实现把日历放进来是设计错误:本机 10 条课程 deadline 约 500 字符(占精简版近一半),
|
|
4886
|
+
// 且与当前任务无关;日程在 5 轮内不会变化 ⇒ 完整版给一次足够。
|
|
4887
|
+
// 精简版的额度应留给"约束行为"的内容(规则 + 索引),而不是塞时效资料。
|
|
4888
|
+
// 保留开关语义:`snapshotSlimIncludeCalendar=true` 可恢复旧行为(供偏好日历常驻的用户)。
|
|
4889
|
+
try {
|
|
4890
|
+
if (cfg.snapshotSlimIncludeCalendar === true && s.calendarText && String(s.calendarText).trim() && !this.isUnattendedNow()) {
|
|
4891
|
+
const calEntries = this.parseCalendar(s.calendarText).filter((en) => !en.done && en.date >= todayStr()).slice(0, 10)
|
|
4892
|
+
if (calEntries.length) {
|
|
4893
|
+
out.push('\n' + L('snapshotCalendarTitle') + '\n'
|
|
4894
|
+
+ calEntries.map((en) => '· ' + en.date + ' ' + en.time + ' | ' + en.quadrant + ' | ' + en.title).join('\n'))
|
|
4895
|
+
}
|
|
4896
|
+
}
|
|
4897
|
+
} catch (_) {}
|
|
4898
|
+
// ④ 精简说明:让模型知道这是精简版、完整版每 N 轮一次、以及怎么取全文
|
|
4899
|
+
out.push('\n' + L('snapshotSlimNote', { n: parseGapRoundsPre(cfg.snapshotMinGapRounds, 5) }))
|
|
4900
|
+
// ⑤ ★2026-09-15(用户裁定):**显式声明"记忆唤回"块**。
|
|
4901
|
+
// 唤回(M6 Reference Tail)是**独立注册的第二个 context 面**(`lib/index.js:8023`),
|
|
4902
|
+
// 不在本函数渲染的文本里 ⇒ 精简版里看不见它,容易被模型当成"这轮没有唤回"。
|
|
4903
|
+
// 这里只做**声明**(它会单独出现、同样当约束读),不改唤回本身的投递逻辑。
|
|
4904
|
+
out.push('\n' + L('snapshotSlimRecallNote'))
|
|
4905
|
+
return neutralizePromptTemplateVars((L('snapshotHead') || '<memory_system>')
|
|
4906
|
+
+ '\n' + L('snapshotMeta', { date: d, ws: wsEff || '(未知)', dayBoundary: Number(cfg.dayBoundaryMinutes) || 450, consolidate: cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估' })
|
|
4907
|
+
+ '\n' + out.filter(Boolean).join('\n')
|
|
4908
|
+
+ '\n' + (L('snapshotTail') || '</memory_system>'))
|
|
4909
|
+
} catch (e) { return '' }
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
/**
|
|
4913
|
+
* ★2026-09-15(用户裁定 A → 实测修正为 **B**):**判定"本 step 是否属于一个新的 turn"**,
|
|
4914
|
+
* 以 turn 号为判据 —— 新的 turn 首次注入给完整版。
|
|
4915
|
+
*
|
|
4916
|
+
* 用户原话:「只要是人和他对话,每次按发送键的时候一定要发完整版」。
|
|
4917
|
+
* 语义界定(用户确认,含长任务的顾虑):**一个 turn 只强制一次完整版** —— 人按发送键那一轮拿到
|
|
4918
|
+
* 完整上下文;该 turn 内后续的工具调用 step 回到周期节流(否则长任务每一步都灌一遍全文,
|
|
4919
|
+
* 历史膨胀 + 注意力分散,正好抵消了这次改造的意义)。
|
|
4920
|
+
*
|
|
4921
|
+
* ⚠️ **为什么放弃"只认真人消息"(首版 A 的实测失败,勿再改回去)**:
|
|
4922
|
+
* 首版取"事件流尾部最后一条 `user/message` 且 `source.kind==='user'`"当判据 —— 实测诊断日志里
|
|
4923
|
+
* `真人轮强制完整版` **只在一个 turn 的第 3 次注入前后才命中**,而不是首次注入:宿主的
|
|
4924
|
+
* `user/message` 投递与本插件的会话观察器落地**不同步**,第一次装配 context 时事件流里还看不到它
|
|
4925
|
+
* (症状即用户报告:"点发送后没有立刻注入,完成一次工具调用以后才注入完整版")。
|
|
4926
|
+
* 判据改为 **turn 号**(`turn/start` 的 `data.turn`),它由宿主在**进入本 turn 的同时**写入事件流,
|
|
4927
|
+
* 装配 context 时**必然可见**(见本文件 `ingestSessionEventRecord` 已消费 `turn/start`/`step/start`)。
|
|
4928
|
+
*
|
|
4929
|
+
* **返回值语义**:`''` = 无 turn 信息或不是新 turn;非空字符串 = 该 turn 的稳定标识。
|
|
4930
|
+
* **调用方负责"只强制一次"**:把返回值存进 `st._humanFullKey`,相同 key 的后续 step 不再强制。
|
|
4931
|
+
* 本函数**本身无副作用**,可安全重复调用。
|
|
4932
|
+
*
|
|
4933
|
+
* 代价与取舍(如实标注):turn 号只说明"这是新的一轮",**不区分这一轮由人还是由 cron/接续发起**
|
|
4934
|
+
* ⇒ 非真人轮也会给一次完整版。判为**可接受**:宁多给一次(成本约 4k 字符/轮,且完整版本就每 5 轮给一次),
|
|
4935
|
+
* 也不要漏掉真人轮(那正是用户最在意的"记忆不在场")。真人判定仍保留在下方 `humanTurnObservedPre`,
|
|
4936
|
+
* 仅用于诊断留痕,不再作为门控。
|
|
4937
|
+
*
|
|
4938
|
+
* 零成本:只读已缓存的 `snapshotEvents()` 尾部(`sessionEventsOf` 自带缓存,不触发惰性投影),
|
|
4939
|
+
* 且只看末尾有限条(会话越长越靠近尾部,不做全量扫描)。
|
|
4940
|
+
*/
|
|
4941
|
+
turnBoundaryKeyPre(agent) {
|
|
4942
|
+
try {
|
|
4943
|
+
const session = agent && agent.session
|
|
4944
|
+
if (!session) return ''
|
|
4945
|
+
const events = sessionEventsOf(session)
|
|
4946
|
+
if (!events || !events.length) return ''
|
|
4947
|
+
const from_ = Math.max(0, events.length - 400)
|
|
4948
|
+
// ⚠️ turn / step 在 `event.data` 里(`seq` 才在顶层)——见 `ingestSessionEventRecord`。
|
|
4949
|
+
// 首版读 `ev.turn` 恒 undefined,属已踩过的坑,这里统一兼容两种形态。
|
|
4950
|
+
const turnOf = (ev) => {
|
|
4951
|
+
if (!ev) return NaN
|
|
4952
|
+
const t = ev.turn !== undefined ? ev.turn : (ev.data && ev.data.turn !== undefined ? ev.data.turn : undefined)
|
|
4953
|
+
return t === undefined ? NaN : Number(t)
|
|
4954
|
+
}
|
|
4955
|
+
// 事件流尾部的当前 turn:从后往前找第一个带 turn 号的事件
|
|
4956
|
+
let curTurn = NaN
|
|
4957
|
+
for (let i = events.length - 1; i >= from_; i--) {
|
|
4958
|
+
const t = turnOf(events[i])
|
|
4959
|
+
if (Number.isFinite(t)) { curTurn = t; break }
|
|
4960
|
+
}
|
|
4961
|
+
if (!Number.isFinite(curTurn)) return ''
|
|
4962
|
+
return 'turn:' + curTurn
|
|
4963
|
+
} catch (e) { return '' }
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4966
|
+
/**
|
|
4967
|
+
* 真人轮**观测**(不参与门控,只用于诊断留痕)。
|
|
4968
|
+
*
|
|
4969
|
+
* 保留它的价值:`turnBoundaryKeyPre` 无法区分"真人发起"与"cron/接续发起",
|
|
4970
|
+
* 而这条信息对排查"完整版到底有没有在该给的时候给"很有用 ⇒ 记进 `diag()` 供事后核对。
|
|
4971
|
+
* 判定口径:尾部最后一条 `user/message` 的 `source.kind` 为 `user` 或缺失(老会话兜底)。
|
|
4972
|
+
*/
|
|
4973
|
+
humanTurnObservedPre(agent) {
|
|
4974
|
+
try {
|
|
4975
|
+
const session = agent && agent.session
|
|
4976
|
+
if (!session) return false
|
|
4977
|
+
const events = sessionEventsOf(session)
|
|
4978
|
+
if (!events || !events.length) return false
|
|
4979
|
+
const from_ = Math.max(0, events.length - 400)
|
|
4980
|
+
for (let i = events.length - 1; i >= from_; i--) {
|
|
4981
|
+
const ev = events[i]
|
|
4982
|
+
if (!ev || ev.type !== 'user/message') continue
|
|
4983
|
+
const data = (ev.data && typeof ev.data === 'object') ? ev.data : {}
|
|
4984
|
+
const srcObj = (data.source && typeof data.source === 'object') ? data.source : null
|
|
4985
|
+
const kind = srcObj ? String(srcObj.kind || '') : (typeof data.source === 'string' ? data.source : '')
|
|
4986
|
+
return !kind || kind === 'user'
|
|
4987
|
+
}
|
|
4988
|
+
return false
|
|
4989
|
+
} catch (e) { return false }
|
|
4990
|
+
}
|
|
4991
|
+
|
|
3698
4992
|
// ---------- 注入渲染(同步,基于缓存) ----------
|
|
3699
4993
|
// 动态记忆 → ctx.systemPrompt.context()(user-role 快照):内容变化才追加新快照,内容不变不重复注入(dsh-agent-loop project() 去重),
|
|
3700
4994
|
// system prompt 不再包含动态内容 → 字节级稳定 → DeepSeek 前缀缓存全程命中(对比 section 方案:动态内容任何变化都从变化点起击穿整个前缀,含全部历史)
|
|
3701
4995
|
renderMemoryDynamic(context) {
|
|
3702
4996
|
const s = this.state
|
|
3703
4997
|
const cfg = this.config
|
|
4998
|
+
// ★2026-09-15(用户裁定 · 病因 D 兜底 · 与精简版同源):
|
|
4999
|
+
// 完整版此前只认 `s.ws`,一旦 `state.ws` 未就绪就渲染 `(未知)`。
|
|
5000
|
+
// 与 `renderSlimSnapshotPre(wsHint)` 用**同一口径**:仅当 `s.ws` 为空时回退到
|
|
5001
|
+
// 会话头 cwd(`context.agent.session.header.cwd`,= `resolvePaths:1735` 第①优先级
|
|
5002
|
+
// = GUI 概览页 `currentWs()` 同源,同步零 IO),就绪后自动回到 `s.ws`。
|
|
5003
|
+
let wsHintPre = ''
|
|
5004
|
+
try {
|
|
5005
|
+
const a = context && context.agent
|
|
5006
|
+
wsHintPre = (a && a.session && a.session.header && a.session.header.cwd) || ''
|
|
5007
|
+
} catch (_) {}
|
|
5008
|
+
const wsEff = s.ws || wsHintPre || ''
|
|
3704
5009
|
// 2026-08-27 频率控制:同内容在 snapshotMinGapRounds 轮内不重复注入(减少历史膨胀)。
|
|
3705
5010
|
// 用"内容指纹"判断是否变化:剔除铭文日期行(无意义变化源)后,仅日志/反思/笔记等实际内容变化才触发。
|
|
3706
5011
|
const budget = Math.max(Number(cfg.injectBudgetChars) || 1600, 400)
|
|
3707
|
-
const
|
|
5012
|
+
const segs = []
|
|
3708
5013
|
// 自定义 prompt 层(2026-08-27):用户可覆盖各层文案;空覆盖=默认。占位符替换。
|
|
3709
5014
|
const L = (key, vars) => {
|
|
3710
5015
|
const ov = cfg.promptLayerOverrides && cfg.promptLayerOverrides[key]
|
|
@@ -3713,49 +5018,117 @@ class MemoryEngine {
|
|
|
3713
5018
|
return txt
|
|
3714
5019
|
}
|
|
3715
5020
|
const d = this.memToday()
|
|
3716
|
-
|
|
3717
|
-
|
|
5021
|
+
// ── P6A(2026-09-14):规则层**先于一切参考内容**,且不参与裁剪 ──
|
|
5022
|
+
// 旧行为的病(两条独立病因,都有代码行号):
|
|
5023
|
+
// A. 措辞:开场白把"规矩"与"资料"统一降格为「只是背景事实与规则参考」⇒ 规则不醒目;
|
|
5024
|
+
// B. 节奏:`snapshotMinGapRounds` 使规矩在第 2–5 轮**不在场**(模型不是不听话,是没收到)。
|
|
5025
|
+
// 开关 `rulesLayeringMode`(**默认 'off'**)= 新旧并存 + 一键回退:关掉即完全回到旧行为。
|
|
5026
|
+
// 开启后规则段走分项账本的 `rules` 分项:**不受 gap 约束、不参与裁剪**(v2 §3.3 选 (ii))。
|
|
5027
|
+
let rulesLayer = { enabled: false, text: '', rules: [] }
|
|
5028
|
+
let rulesSection = { text: '', chars: 0 }
|
|
5029
|
+
if (String(cfg.rulesLayeringMode || 'off').toLowerCase() !== 'off') {
|
|
5030
|
+
try {
|
|
5031
|
+
rulesLayer = extractRulesLayerPre({
|
|
5032
|
+
userText: s.userText,
|
|
5033
|
+
notesText: s.notesText,
|
|
5034
|
+
rulesLayeringMode: cfg.rulesLayeringMode,
|
|
5035
|
+
})
|
|
5036
|
+
rulesSection = renderRulesSectionPre(rulesLayer, {
|
|
5037
|
+
title: L('snapshotRulesTitle') || RULES_SECTION_TITLE_V1,
|
|
5038
|
+
guide: L('snapshotRulesGuide') || RULES_SECTION_GUIDE_V1,
|
|
5039
|
+
})
|
|
5040
|
+
} catch (e) {
|
|
5041
|
+
// 规则层 fail-soft:出错不拖垮整个快照(但**不静默**——由 rulesLayer.enabled=false 体现)
|
|
5042
|
+
rulesLayer = { enabled: false, text: '', rules: [], error: String((e && e.message) || e) }
|
|
5043
|
+
rulesSection = { text: '', chars: 0 }
|
|
5044
|
+
}
|
|
5045
|
+
}
|
|
5046
|
+
// ── 2026-09-14 T0-3(P0):分项账本(唯一口径)──────────────────────────────
|
|
5047
|
+
// 旧写法是一个 `lines` 数组 + 最后 `join('\n')`,**没有任何分项计量**:谁也答不出
|
|
5048
|
+
// "这一轮到底注入了多少、分别花在哪"。现改为**边推边归位**:每个片段带自己的分项,
|
|
5049
|
+
// 最后交给 `composeMemoryEnvelopePre` 计量与限额,并由它做序列化。
|
|
5050
|
+
//
|
|
5051
|
+
// **字节等价**:`lines.join('\n')` ≡「首段无前缀 + 其余段各加 '\n' 前缀」后直接拼接,
|
|
5052
|
+
// 所以本改造**不改变任何一段的注入文本**(既有套件的字节断言不受影响)。
|
|
5053
|
+
// `neutralizePromptTemplateVars` 逐段施加:双花括号→全角双花括号 是 1:1 等长替换,
|
|
5054
|
+
// 逐段中和与整篇中和结果逐字节相同。(此行不写 ASCII 双花括号:源码抽取型测试
|
|
5055
|
+
// `smoke-test-handoff-pre.mjs` 用花括号配平来切函数体,注释里的裸双括号会让它误判"不平衡"。)
|
|
5056
|
+
// ★2026-09-15 修复:每段带 `priority`(low / normal / must)。超额时**先丢 low,must 永不丢**。
|
|
5057
|
+
// 旧实现是**纯位置式**丢弃(从分项尾部丢),尾部恰好是 user-memory / project-notes
|
|
5058
|
+
// ⇒ 价值最高的两段先死(本机实测)。位置顺序是渲染顺序,不是价值顺序。
|
|
5059
|
+
const pushPart = (bucket, kind, text, priority) => {
|
|
5060
|
+
if (text === undefined || text === null || text === '') return
|
|
5061
|
+
segs.push({ bucket, kind, text, priority: priority || 'normal' })
|
|
5062
|
+
}
|
|
5063
|
+
// P6A:规则段排在**开场白与状态行之后、任何参考内容之前**——位置本身就是"醒目"的一半,
|
|
5064
|
+
// 同时让规则块尽量靠前(T7-6 的前缀缓存友好性:内容不变 ⇒ 前缀字节稳定)。
|
|
5065
|
+
pushPart('otherDynamic', 'frame-head', L('snapshotHead') || '<memory_system>', 'must')
|
|
5066
|
+
pushPart('otherDynamic', 'frame-meta', L('snapshotMeta', { date: d, ws: wsEff || '(未知)', dayBoundary: Number(cfg.dayBoundaryMinutes) || 450, consolidate: cfg.autoConsolidate === false ? ' | 自动沉淀: 已关闭' : ' | 自动沉淀: 每轮对话结束自动评估' }))
|
|
5067
|
+
pushPart('rules', 'rules-section', rulesSection.text)
|
|
3718
5068
|
// 记忆地图:告诉模型其他工作区记忆存在,需要时用 memory_recall 跨区检索
|
|
3719
5069
|
if (s.workspaceMap && s.workspaceMap.length) {
|
|
3720
|
-
|
|
5070
|
+
pushPart('otherDynamic', 'workspace-map', '其他工作区记忆(开发/排查时可调用 memory_recall 检索其日志/笔记): ' + s.workspaceMap.join('、'))
|
|
5071
|
+
}
|
|
5072
|
+
// C5 三层注入 · Tier-0 常驻目录(指引层:每轮都在场,不依赖命中) —— 2026-09-14
|
|
5073
|
+
// 内容由 refresh() 预计算挂 state.tier0LayerText(per-layer 配额账 + I7 显式降级标注 + 闸门下探段),
|
|
5074
|
+
// 本函数只读 state 字段 → 保持自包含(源码抽取测试可独立执行,不依赖本模块新导入)。
|
|
5075
|
+
// 位置在日志/笔记等证据层之前 = Karpathy 的「先读索引,再决定深入哪一页」。
|
|
5076
|
+
// T0-3:目录层**按实际注入的全文长度**计入 memoryReferences(旧实现只按 35% 封顶的成本记账 ⇒ 两本账)。
|
|
5077
|
+
if (s.tier0LayerText) {
|
|
5078
|
+
pushPart('memoryReferences', 'tier0-catalog', '\n' + L('snapshotTier0Title') + '\n' + s.tier0LayerText)
|
|
3721
5079
|
}
|
|
3722
5080
|
// M-CM1 交接白板:PLAN.md 全貌+最新交接账本,动态快照首位(动态层内容,不碰静态字节;敏感段清洗+行边界硬预算截断)
|
|
3723
5081
|
// 2026-09-08 修复:此块曾在双线合并中复制成三份(白板/账本/水位各注入 3 次,白烧预算),去重为单份。
|
|
3724
5082
|
if (cfg.handoffEnabled !== false && (s.planText || s.latestHandoffText)) {
|
|
3725
5083
|
const planPart = truncateLinesBounded(stripSensitiveSections(sanitizeForInjection(s.planText || '')), Math.max(Number(cfg.handoffPlanChars) || 1200, 200))
|
|
3726
|
-
if (planPart)
|
|
5084
|
+
if (planPart) pushPart('memoryReferences', 'whiteboard-plan', '\n' + L('snapshotPlanTitle') + '\n' + planPart)
|
|
3727
5085
|
const ledgerPart = truncateLinesBounded(stripSensitiveSections(sanitizeForInjection(s.latestHandoffText || '')), Math.max(Number(cfg.handoffLedgerChars) || 800, 200))
|
|
3728
|
-
if (ledgerPart)
|
|
3729
|
-
}
|
|
5086
|
+
if (ledgerPart) pushPart('memoryReferences', 'handoff-ledger', '\n' + L('snapshotHandoffTitle') + '\n' + ledgerPart)
|
|
5087
|
+
}
|
|
5088
|
+
// ★2026-09-15(用户裁定):**接续表/水位越阈时强制索取"白板 + 账本"更新**。
|
|
5089
|
+
// 用户原话要点:轮次结束时理论上都要更新白板;轮次间若触发自动接续,也要**强制更新**白板与账本。
|
|
5090
|
+
// 与 `renderReflectionRequest` 同族的"待办索取"机制(一次性,取到即置位,不重复刷)。
|
|
5091
|
+
// 防御式调用:`renderMemoryDynamic` 是源码抽取式测试的被测对象(`new Function` 里 `this` 是 fake),
|
|
5092
|
+
// 宿主方法在沙箱里可能不存在 ⇒ 用 typeof 兜底,避免 ReferenceError 被外层 catch 吞成**整块空串**。
|
|
5093
|
+
const planAsk = (typeof this.renderPlanUpdateRequest === 'function') ? this.renderPlanUpdateRequest() : ''
|
|
5094
|
+
if (planAsk) pushPart('otherDynamic', 'plan-update-request', planAsk, null, 'must')
|
|
3730
5095
|
// 新bug修复①(2026-09-08):工作区未绑定/项目记忆为空时,注入全局最近账本的绝对路径指针(模型可直接读取续命)
|
|
3731
5096
|
if (cfg.handoffEnabled !== false && !s.planText && !s.latestHandoffText && s.globalLedgerPath) {
|
|
3732
|
-
|
|
5097
|
+
pushPart('otherDynamic', 'handoff-pointer', '\n[交接续命] 当前会话未绑定工作区(项目记忆不可用)。全局最近交接账本: ' + s.globalLedgerPath + ' —— 需要续接上次任务时,直接读取该文件恢复上下文;工作区绑定后白板/账本即恢复正常注入。')
|
|
3733
5098
|
}
|
|
3734
5099
|
// M-CM4/M-CM5 水位建议:越阈注入交接+派子代理建议(advisory;10 分钟新鲜度;无人值守静默)
|
|
3735
5100
|
if (cfg.waterLevelAdvisory !== false && !this.isUnattendedNow() && (s.waterLevelRatio || 0) >= (Number(cfg.waterLevelThreshold) || 0.75) && Date.now() - (s.waterLevelAt || 0) < 600000) {
|
|
3736
|
-
|
|
3737
|
-
}
|
|
3738
|
-
|
|
3739
|
-
|
|
5101
|
+
pushPart('otherDynamic', 'water-advisory', '\n' + L('snapshotWaterTitle') + '\n' + L('snapshotWaterBody', { pct: Math.round((s.waterLevelRatio || 0) * 100) }))
|
|
5102
|
+
}
|
|
5103
|
+
// 2026-09-14 T0-3 修复(P0):原实现把"目录可扣额度"硬封顶为总预算的 35%,
|
|
5104
|
+
// **封顶的是扣账成本,实际注入用的却是全文 `state.tier0LayerText`** —— 两本账对不上,
|
|
5105
|
+
// 而且那个 35% 的魔数没有任何断言支撑(读码确认:`used` 变量只累加、从不被读,是注水账本)。
|
|
5106
|
+
// 现在的口径:目录层**按其实际将注入的字符数**参与 `sub` 的推导(不再封顶、不再两本账),
|
|
5107
|
+
// 且最终以分项账本(envelope.chars)作为唯一可审计口径。
|
|
5108
|
+
// 目录层的硬上限由更上游负责:`tier0BudgetShare`(默认 0.4)× `injectBudgetChars`
|
|
5109
|
+
// + 契约硬顶 `B0`(见 buildTierLayerInjection 的 maxTotalChars/maxTokens)。
|
|
5110
|
+
const catalogCost = s.tier0LayerText ? String(s.tier0LayerText).length + 2 : 0
|
|
5111
|
+
const sub = Math.max(0, Math.floor((budget - 500 - catalogCost) / 4))
|
|
5112
|
+
// 读取顺序:progress(工作日志/反思)先行,再读 memory(用户级/项目笔记)
|
|
5113
|
+
const part = (bucket, kind, title, text, max, priority) => {
|
|
3740
5114
|
if (!text) return
|
|
3741
5115
|
const t = truncateHead(text, max)
|
|
3742
|
-
|
|
3743
|
-
lines.push('\n[' + title + ']\n' + t)
|
|
5116
|
+
pushPart(bucket, kind, '\n[' + title + ']\n' + t, priority)
|
|
3744
5117
|
}
|
|
3745
|
-
const sub = Math.floor((budget - 500) / 4)
|
|
3746
|
-
// 读取顺序:progress(工作日志/反思)先行,再读 memory(用户级/项目笔记)
|
|
3747
5118
|
if (s.recentLogs.length) {
|
|
3748
5119
|
// 0.1.39:recentLogs 是用户派生内容的主入口(今日日志逐行),必须过模板变量中和
|
|
3749
5120
|
// (实测病例:日志行「GET {{baseUrl}}/v1/usage」→ 宿主判 malformed,整轮失败)
|
|
3750
5121
|
const recent = neutralizePromptTemplateVars(s.recentLogs.map((r) => '[' + r.date + '] ' + r.text.replace(/\n+/g, ' | ')).join('\n'))
|
|
3751
|
-
part(L('snapshotLogsTitle', { n: s.recentLogs.length }), scrubJunkLines(recent, { dedup: false }).clean, sub)
|
|
5122
|
+
part('memoryReferences', 'recent-logs', L('snapshotLogsTitle', { n: s.recentLogs.length }), scrubJunkLines(recent, { dedup: false }).clean, sub, 'low')
|
|
3752
5123
|
}
|
|
3753
5124
|
if (s.latestReflection) {
|
|
3754
|
-
part(L('snapshotReflectionTitle', { date: s.latestReflectionDate }), reflectionDigest(s.latestReflection), sub)
|
|
5125
|
+
part('memoryReferences', 'reflection', L('snapshotReflectionTitle', { date: s.latestReflectionDate }), reflectionDigest(s.latestReflection), sub)
|
|
3755
5126
|
}
|
|
3756
5127
|
// 敏感段落(凭据/token/密钥等)不注入 prompt,避免密钥暴露给模型;脏内容(乱码/重复/外部文档)清洗后再注入
|
|
3757
|
-
|
|
3758
|
-
|
|
5128
|
+
// ★这两段是 `must`:用户级跨项目规则与项目决策是全链路最高价值的内容,
|
|
5129
|
+
// 任何额度不足都**不得**先牺牲它们(实测事故正是它们被整段丢)。
|
|
5130
|
+
part('memoryReferences', 'user-memory', L('snapshotUserTitle'), stripSensitiveSections(sanitizeForInjection(s.userText)), sub, 'must')
|
|
5131
|
+
part('memoryReferences', 'project-notes', L('snapshotNotesTitle') + ' ' + (s.notesPath || (cfg.projectMemoryDir + '/MEMORY.md')), stripSensitiveSections(sanitizeForInjection(s.notesText)), sub, 'must')
|
|
3759
5132
|
// 外部记忆摘要(其他 AI 工具遗产)
|
|
3760
5133
|
if (this.external.cache && this.external.cache.length) {
|
|
3761
5134
|
const extBudget = Math.max(Number(cfg.externalInjectionChars) || 1400, 200)
|
|
@@ -3766,10 +5139,10 @@ class MemoryEngine {
|
|
|
3766
5139
|
return '· ' + x.name + '(' + x.tool + '): 绝对路径 ' + (paths || '(未知)')
|
|
3767
5140
|
})
|
|
3768
5141
|
.slice(0, 6)
|
|
3769
|
-
if (ext.length)
|
|
5142
|
+
if (ext.length) pushPart('otherDynamic', 'external-memory', '\n' + L('snapshotExternalTitle') + '\n' + ext.join('\n') + '\n需要这些记忆时:直接读取上述绝对路径文件(你有文件读取能力),或用 memory_recall 按需检索;不要凭空猜测其内容。')
|
|
3770
5143
|
const sess = this.external.cache.filter((x) => x.kind === 'sessions')
|
|
3771
5144
|
if (sess.length) {
|
|
3772
|
-
|
|
5145
|
+
pushPart('otherDynamic', 'external-sessions', '· 历史会话索引: ' + sess.map((x) => x.name + ' ' + x.files.length + ' 个').join(', ') + ' —— 需要时用 memory_recall 检索。')
|
|
3773
5146
|
}
|
|
3774
5147
|
}
|
|
3775
5148
|
// 日历/日程注入(让 AI 主动感知 deadline/约定)——无人值守模式下剥离(不注入提醒类内容)
|
|
@@ -3777,7 +5150,7 @@ class MemoryEngine {
|
|
|
3777
5150
|
const calEntries = this.parseCalendar(this.state.calendarText).filter((en) => !en.done && en.date >= todayStr()).slice(0, 10)
|
|
3778
5151
|
if (calEntries.length) {
|
|
3779
5152
|
const calLines = calEntries.map((en) => '· ' + en.date + ' ' + en.time + ' | ' + en.quadrant + ' | ' + en.title).join('\n')
|
|
3780
|
-
|
|
5153
|
+
pushPart('otherDynamic', 'calendar', '\n' + L('snapshotCalendarTitle') + '\n' + calLines + '\n主动关注这些安排:对话中若提及相关时间点,主动用 calendar_add 补充新事项、calendar_done 标记完成、calendar_remove 删除过期事项;回复正文中向用户转述日历变更。')
|
|
3781
5154
|
}
|
|
3782
5155
|
}
|
|
3783
5156
|
// 暂离回来提示:距上次活动超过暂离阈值(awayMinutes,与暂离检测同一配置;0=关闭)要求 agent 在回复开头写欢迎语并提示打开记忆窗口
|
|
@@ -3788,13 +5161,60 @@ class MemoryEngine {
|
|
|
3788
5161
|
const _awayMinN = Number(this.config.awayMinutes)
|
|
3789
5162
|
const awayMinSnap = Number.isFinite(_awayMinN) && _awayMinN >= 0 ? _awayMinN : 60
|
|
3790
5163
|
if (!this.isUnattendedNow() && awayMinSnap > 0 && lastActiveGlobal && Date.now() - lastActiveGlobal > awayMinSnap * 60000) {
|
|
3791
|
-
|
|
3792
|
-
|
|
5164
|
+
pushPart('otherDynamic', 'welcome-title', '\n' + L('snapshotWelcomeTitle'))
|
|
5165
|
+
pushPart('otherDynamic', 'welcome-body', L('snapshotWelcomeBody'))
|
|
3793
5166
|
}
|
|
3794
|
-
|
|
3795
|
-
|
|
5167
|
+
pushPart('otherDynamic', 'frame-inscription', '\n' + L('snapshotInscription', { date: this.memToday() })) // 动态快照追加在历史尾部,变化只 miss 快照本身;秒级时间戳也不再击穿 system prompt 前缀
|
|
5168
|
+
pushPart('otherDynamic', 'frame-tail', L('snapshotTail') || '</memory_system>', 'must')
|
|
3796
5169
|
// 0.1.39 兜底:整个动态快照出插件前统一中和模板变量(覆盖反思摘要/日历/外部/层覆盖文案等所有支路)
|
|
3797
|
-
|
|
5170
|
+
// —— T0-3 后改为**逐段**中和:双花括号→全角双花括号 是等长替换,逐段与整篇结果逐字节相同,
|
|
5171
|
+
// 但这样账本量到的就是**最终**要注入的字符数(否则账本与真实注入会差掉中和带来的长度变化)。
|
|
5172
|
+
const budgetChars = Math.max(Number(cfg.injectBudgetChars) || 1600, 400)
|
|
5173
|
+
// ★2026-09-15 修复(注入回归):分项限额必须 **≥ 各段自身预算之和**。
|
|
5174
|
+
// 事故链:T0-3 把「目录层 + 白板 + 账本」的实际长度一并计入 `memoryReferences`,而这个分项的上限
|
|
5175
|
+
// 又被设成 `injectBudgetChars`(默认/本机 4800)⇒ 与「四个证据段各分 sub」叠在一起必然爆上限,
|
|
5176
|
+
// 于是**整段丢弃**尾部两段(user-memory / project-notes,价值最高的两段)。
|
|
5177
|
+
// 账本的职责是「计量 + 安全网 + 按优先级丢弃」,**不是新造一个更紧的预算** ⇒ 限额由既有分配**推导**。
|
|
5178
|
+
const planCap = Math.max(Number(cfg.handoffPlanChars) || 1200, 200)
|
|
5179
|
+
const ledgerCap = Math.max(Number(cfg.handoffLedgerChars) || 800, 200)
|
|
5180
|
+
const memRefLimit = sub * 4 + catalogCost + planCap + ledgerCap + 400
|
|
5181
|
+
// Bo(otherDynamicBudgetChars):**仅当用户显式配置时才设上限**;未配置 = 不设限。
|
|
5182
|
+
// 理由同上:没有依据的默认上限就是那个 bug(该分项里的段本来就各有各的约束)。
|
|
5183
|
+
const otherLimitN = Number(cfg.otherDynamicBudgetChars)
|
|
5184
|
+
const otherLimit = Number.isFinite(otherLimitN) && otherLimitN > 0 ? Math.max(200, Math.floor(otherLimitN)) : null
|
|
5185
|
+
const envelope = composeMemoryEnvelopePre({
|
|
5186
|
+
segments: segs.map((sg, i) => ({
|
|
5187
|
+
bucket: sg.bucket, kind: sg.kind, priority: sg.priority,
|
|
5188
|
+
text: (i === 0 ? '' : '\n') + neutralizePromptTemplateVars(sg.text),
|
|
5189
|
+
})),
|
|
5190
|
+
limits: { memoryReferences: memRefLimit, otherDynamic: otherLimit },
|
|
5191
|
+
// 只用于账本的「合计是否超预算」可见性(落 meta;诊断类降级不进注入文本,避免每轮噪声)
|
|
5192
|
+
budgetChars,
|
|
5193
|
+
})
|
|
5194
|
+
// T0-3:账本挂 state,面板/排障/后续 P6A 都读它(**不再有第二本账**)
|
|
5195
|
+
try {
|
|
5196
|
+
this.state.envelopeMeta = {
|
|
5197
|
+
version: envelope.version,
|
|
5198
|
+
chars: envelope.chars,
|
|
5199
|
+
limits: envelope.limits,
|
|
5200
|
+
segments: envelope.segments,
|
|
5201
|
+
dropped: envelope.dropped.map((x) => ({ bucket: x.bucket, kind: x.kind })),
|
|
5202
|
+
truncated: envelope.truncated.map((x) => ({ bucket: x.bucket, kind: x.kind })),
|
|
5203
|
+
degradations: envelope.degradations.map((x) => x.code),
|
|
5204
|
+
ok: envelope.ok,
|
|
5205
|
+
budget,
|
|
5206
|
+
catalogCost,
|
|
5207
|
+
sub,
|
|
5208
|
+
at: Date.now(),
|
|
5209
|
+
}
|
|
5210
|
+
} catch (_) {}
|
|
5211
|
+
// T0-3:**影响内容的**降级(丢段/截断/must 超额)必须可见(I7)——附在快照尾部,不静默丢弃任何一段。
|
|
5212
|
+
// 诊断类降级(`inject: false`,如"合计超预算")只进 `envelopeMeta`,不进注入文本:它不改变内容,
|
|
5213
|
+
// 每轮写字数反而制造噪声(长会话里这类噪声会累积)。
|
|
5214
|
+
const injectedDegradations = envelope.degradations.filter((x) => x.inject !== false)
|
|
5215
|
+
return envelope.text + (injectedDegradations.length
|
|
5216
|
+
? '\n' + injectedDegradations.map((x) => x.text).join('\n')
|
|
5217
|
+
: '')
|
|
3798
5218
|
}
|
|
3799
5219
|
|
|
3800
5220
|
/** 静态纪律(不随状态变化)→ ctx.systemPrompt.section():system prompt 保持字节级稳定,是 DeepSeek 前缀缓存的锚。 */
|
|
@@ -3844,6 +5264,72 @@ class MemoryEngine {
|
|
|
3844
5264
|
].join('\n'))
|
|
3845
5265
|
}
|
|
3846
5266
|
|
|
5267
|
+
/**
|
|
5268
|
+
* ★2026-09-15(用户裁定 · 方案 1):**"水位 → 交接材料"整链的单一开关判定**。
|
|
5269
|
+
*
|
|
5270
|
+
* 用户的原始预期(原话要点):**关掉自动接续后,就不该再落盘水位账本** ——
|
|
5271
|
+
* "我明明关掉了接续功能,它确实也没有跳转窗口,但是依然有……难道是我记错了,还是这是 bug?"
|
|
5272
|
+
*
|
|
5273
|
+
* **不是记错,是开关耦合的缺陷**:改前 `autoContinueEnabled` 只管两件事 ——
|
|
5274
|
+
* 接续资格(`armAutoContinue` 早退)与 GUI 卡片可用性;而水位自动账本写入
|
|
5275
|
+
* 只判 `handoffEnabled` / `waterLevelAutoHandoff`,**从不看接续开关**。
|
|
5276
|
+
* 症状即"不跳窗口、却照样写账本并覆盖动态快照的 `latestHandoffText`"。
|
|
5277
|
+
*
|
|
5278
|
+
* **语义依据(为什么必须这样改)**:`waterLevelAutoHandoff` 存在的唯一目的是
|
|
5279
|
+
* **给自动接续准备交接材料**。接续关了 ⇒ 没有人会来接 ⇒ 这份账本不但没用,
|
|
5280
|
+
* 还会把用户自己手写的账本挤掉。**产物从属于它服务的流程。**
|
|
5281
|
+
*
|
|
5282
|
+
* 三个调用点(本函数是唯一真源,勿再各自写条件):
|
|
5283
|
+
* ① 置位 `state.planUpdatePending`(水位越阈 / compaction)
|
|
5284
|
+
* ② 写水位自动账本
|
|
5285
|
+
* ③ 注入侧 `renderPlanUpdateRequest`(否则出现"不接续却每轮催模型重写白板")
|
|
5286
|
+
*
|
|
5287
|
+
* 与 09-14「镜像保护」的关系:那次只解耦了 `handoffEnabled` × `waterLevelAutoHandoff`,
|
|
5288
|
+
* `autoContinueEnabled` × `waterLevelAutoHandoff` **从未解耦**(本日用户实测暴露)。
|
|
5289
|
+
* 两个条件都保留 —— 它们管的是不同的东西:前者管"白板线开不开",本函数管"需不需要接续材料"。
|
|
5290
|
+
*/
|
|
5291
|
+
handoffChainEnabledPre() {
|
|
5292
|
+
return this.config.autoContinueEnabled !== false && this.config.handoffEnabled !== false
|
|
5293
|
+
}
|
|
5294
|
+
|
|
5295
|
+
/**
|
|
5296
|
+
* ★2026-09-15(用户裁定):**强制索取"白板 + 交接账本"更新**的注入块。
|
|
5297
|
+
*
|
|
5298
|
+
* 用户原话要点:「每一轮次结束时,理论上都要更新这个白板。如果在轮次间,也就是在长期运行的时候
|
|
5299
|
+
* 触发了自动接续,应该也是需要强制更新这个白板和账本的。」
|
|
5300
|
+
*
|
|
5301
|
+
* **触发条件**(`this.state.planUpdatePending`,由水位/接续路径置位):
|
|
5302
|
+
* - 水位越阈或检测到 compaction(长任务续命关头,白板必须反映最新状态)
|
|
5303
|
+
* - 自动接续刚发生(新窗口接手,白板/账本是接手材料,必须最新)
|
|
5304
|
+
*
|
|
5305
|
+
* **一次性**:返回文本后立刻清位(与 `renderReflectionRequest` 同口径),避免每轮重复索要。
|
|
5306
|
+
*
|
|
5307
|
+
* **两个开关都管这里**(★2026-09-15 用户裁定 · 方案 1):
|
|
5308
|
+
* `handoffEnabled=false` ⇒ 尊重"关掉白板线",不越权唤起;
|
|
5309
|
+
* `autoContinueEnabled=false` ⇒ **不接续就不催重写**(否则出现"不接续、却每轮催模型重写白板"的怪象)。
|
|
5310
|
+
* 用 `handoffChainEnabledPre()` 统一判定,与置位点、写入点同源,避免三处条件漂移。
|
|
5311
|
+
*/
|
|
5312
|
+
renderPlanUpdateRequest() {
|
|
5313
|
+
try {
|
|
5314
|
+
// ★方案 1:接续关 ⇒ 不催重写(与置位点、写入点同源判定)
|
|
5315
|
+
if (!this.handoffChainEnabledPre()) return ''
|
|
5316
|
+
const p = this.state.planUpdatePending
|
|
5317
|
+
if (!p) return ''
|
|
5318
|
+
this.state.planUpdatePending = null // 一次性:取了就清,不重复刷
|
|
5319
|
+
const why = p.reason === 'continue' ? '本会话刚发生自动接续(新窗口接手)'
|
|
5320
|
+
: p.reason === 'compact' ? '本轮检测到上下文压缩(compaction)'
|
|
5321
|
+
: '上下文水位已越过阈值'
|
|
5322
|
+
return neutralizePromptTemplateVars([
|
|
5323
|
+
'\n[白板与账本 — 待更新]',
|
|
5324
|
+
why + ',当前白板(PLAN.md)与最近交接账本可能已过时。',
|
|
5325
|
+
'请在本轮**结束前**完成两件事(这是接续材料,下一个窗口靠它们续命):',
|
|
5326
|
+
'① `memory_note(kind=plan, content=…)` —— 用**完整新全貌**重写白板(不是增量补丁),至少覆盖:当前目标 / 已完成 / 进行中 / 下一步第一步(带文件路径或命令)。',
|
|
5327
|
+
'② `memory_note(kind=handoff, content=…)` —— 追加一篇四段式账本(任务状态 / 目标 / 已试方案与失败原因 / 进度与下一步),每段 ≤5 行,失败项写成「方案→失败原因」并保留关键报错词。',
|
|
5328
|
+
'两件都是写盘动作;只回复文字不调用工具等于没更新。',
|
|
5329
|
+
].join('\n'))
|
|
5330
|
+
} catch (e) { return '' }
|
|
5331
|
+
}
|
|
5332
|
+
|
|
3847
5333
|
/** 今日问候数据(纯数据,供 GUI 概览页渲染,不注入对话流)。 */
|
|
3848
5334
|
greetingData() {
|
|
3849
5335
|
const hour = new Date().getHours()
|
|
@@ -3891,8 +5377,14 @@ class MemoryEngine {
|
|
|
3891
5377
|
// M3b-3 分流:anchor 开启 → 记忆文档走原子写入事务(稳定 marker/ID);关闭 → 原逻辑逐字节不变。
|
|
3892
5378
|
const store = this.docStore
|
|
3893
5379
|
if (store) {
|
|
3894
|
-
|
|
3895
|
-
|
|
5380
|
+
// issue #54 配套(一处收口,覆盖全部调用方):**整行合法 anchor marker** 只应由写入原语自己生成。
|
|
5381
|
+
// 调用方把"另一个文档的原文"当正文追加时(如 maintain 把归档日志原文内联进笔记),
|
|
5382
|
+
// 那行 marker 会变成本文档的结构锚点 ⇒ 幻影记录(身份属于旧文档、却挂在本文档上)。
|
|
5383
|
+
// 修前之所以"没报错"是因为整行合法 marker 能被 parseAnchors 当锚点吃下 —— 属静默结构损坏。
|
|
5384
|
+
// 注意:只剥 marker 行;行内出现的保留语法片段仍交由 #54 写入守卫 fail-closed 拒绝(不静默改写)。
|
|
5385
|
+
const safeText = stripAnchorLines(String(text == null ? '' : String(text)))
|
|
5386
|
+
const r = await store.append(p, safeText)
|
|
5387
|
+
if (!r.ok) throw memoryWriteError('append', r)
|
|
3896
5388
|
return await this.readTextSafe(p)
|
|
3897
5389
|
}
|
|
3898
5390
|
const existing = await this.readTextSafe(p)
|
|
@@ -3913,7 +5405,7 @@ class MemoryEngine {
|
|
|
3913
5405
|
const store = this.docStore
|
|
3914
5406
|
if (store) {
|
|
3915
5407
|
const r = await store.replaceSingle(p, String(text == null ? '' : String(text)))
|
|
3916
|
-
if (!r.ok) throw
|
|
5408
|
+
if (!r.ok) throw memoryWriteError('replace-single', r)
|
|
3917
5409
|
return
|
|
3918
5410
|
}
|
|
3919
5411
|
await this.writeFullRaw(p, text)
|
|
@@ -3924,7 +5416,7 @@ class MemoryEngine {
|
|
|
3924
5416
|
const store = this.docStore
|
|
3925
5417
|
if (store) {
|
|
3926
5418
|
const r = await store.replace(p, String(text == null ? '' : String(text)))
|
|
3927
|
-
if (!r.ok) throw
|
|
5419
|
+
if (!r.ok) throw memoryWriteError('replace', r)
|
|
3928
5420
|
return
|
|
3929
5421
|
}
|
|
3930
5422
|
await mkdir(path.dirname(p), { recursive: true })
|
|
@@ -3987,19 +5479,40 @@ class MemoryEngine {
|
|
|
3987
5479
|
for (const h of handoffHits) out.push('· ' + h.where + ':\n' + h.matches.map((m) => ' - ' + m).join('\n'))
|
|
3988
5480
|
}
|
|
3989
5481
|
}
|
|
3990
|
-
|
|
3991
|
-
const
|
|
5482
|
+
// #45:文件窗口保持 40/30;额外枚举一份仅用于检测覆盖边界,不读取其正文。
|
|
5483
|
+
const logWindow = await this.listDailyLogs(p.projectDir, 41)
|
|
5484
|
+
const reflectionWindow = await this.listReflections(p.reflectDir, 31)
|
|
5485
|
+
const logs = logWindow.slice(0, 40)
|
|
5486
|
+
const reflections = reflectionWindow.slice(0, 30)
|
|
5487
|
+
const limitedSources = []
|
|
5488
|
+
if (logWindow.length > logs.length) limitedSources.push('日志仅覆盖最近 40 份')
|
|
5489
|
+
if (reflectionWindow.length > reflections.length) limitedSources.push('反思仅覆盖最近 30 份')
|
|
5490
|
+
const localWindowNote = limitedSources.length
|
|
5491
|
+
? '\n[本地检索范围受限] ' + limitedSources.join(';') + '。窗口外文件未检索;未命中不代表从未记录,可按日期使用 memory_read 或检索磁盘原文。'
|
|
5492
|
+
: ''
|
|
3992
5493
|
// P4(2026-09-09):L0 模式 —— 本地记忆段默认只列 L0 摘要(每条含 id/得分/匹配原因),原文经 expand="mem_xxx" 按需展开。
|
|
3993
|
-
// 词法口径与词法臂一致(小写包含);语义分 fail-soft 叠加(_jsSemanticRank,与 P2 语义臂同源)
|
|
5494
|
+
// 词法口径与词法臂一致(小写包含);语义分 fail-soft 叠加(_jsSemanticRank,与 P2 语义臂同源)。
|
|
5495
|
+
// #45:窗口内全部 L0 条目参与词法/语义排名;仅在排名后按 limit 限制输出,不按来源追加顺序截断。
|
|
5496
|
+
// (旧实现先截到 256 条 ⇒ 语料按"日志→反思→笔记→用户级"的追加顺序被截断,排在后面的
|
|
5497
|
+
// 用户级/项目笔记在日志很多时**永远进不了候选**,表现为"够不到 ~5 天前的记录"。)
|
|
3994
5498
|
if (l0Mode) {
|
|
3995
|
-
const { buildL0IndexPre } = await import('./l0-extract.js')
|
|
5499
|
+
const { buildL0IndexPre, isCurrentPre } = await import('./l0-extract.js')
|
|
3996
5500
|
const l0Corpus = []
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
5501
|
+
// C2(2026-09-14,三层契约):语料条目带 layer/status —— 层名由**来源路径**判定(classifyLayerPre);
|
|
5502
|
+
// 非 current(superseded/retracted)在这里就被剔除(契约 I5「检索侧」过滤;注入侧在 context-host 另有一道)。
|
|
5503
|
+
const pushL0 = (label, text, srcPath) => {
|
|
5504
|
+
if (!text) return
|
|
5505
|
+
for (const it of buildL0IndexPre(text, srcPath ? { layer: srcPath } : undefined)) {
|
|
5506
|
+
if (!isCurrentPre(it)) continue
|
|
5507
|
+
l0Corpus.push({ id: it.id, l0: it.l0, label, layer: it.layer || 'log', status: it.status || 'current' })
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
for (const log of logs) { const f = path.join(p.projectDir, log.name); pushL0(log.name, await this.readTextSafe(f), f) }
|
|
5511
|
+
for (const rf of reflections) { const f = path.join(p.reflectDir, rf.name); pushL0('reflections/' + rf.name, await this.readTextSafe(f), f) }
|
|
5512
|
+
pushL0(p.projectDir + '/MEMORY.md', await this.readTextSafe(p.notesPath), p.notesPath)
|
|
5513
|
+
pushL0('~' + p.userFile.slice(homedir().length), await this.readTextSafe(p.userFile), p.userFile)
|
|
5514
|
+
// #45:不再在此处按 256 截断 —— 截断会使追加顺序靠后的来源(笔记/用户级)永久失去候选资格。
|
|
5515
|
+
// 输出量由下游 rank + limit 控制,不牺牲召回覆盖。
|
|
4003
5516
|
for (const c of l0Corpus) {
|
|
4004
5517
|
const low = c.l0.toLowerCase()
|
|
4005
5518
|
const hitTerms = terms.filter((t) => low.includes(t))
|
|
@@ -4066,10 +5579,19 @@ class MemoryEngine {
|
|
|
4066
5579
|
try {
|
|
4067
5580
|
const { rankFusionRRFPre } = await import('./recall-fusion.js')
|
|
4068
5581
|
const byId = new Map(l0Hits.map((c) => [c.id, c]))
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
5582
|
+
// P3(2026-09-16) R1 双显示:RRF 融合输出的 finalRank(输出序=注入/展示序)保留到候选对象上,
|
|
5583
|
+
// 与 denseScore(绝对,决策用)并存 —— 相似度在前、融合序号在后,不拿融合分冒充相似度。
|
|
5584
|
+
// 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) })))
|
|
5586
|
+
fusion.forEach((f, i) => {
|
|
5587
|
+
const c = byId.get(f.memoryId)
|
|
5588
|
+
if (!c) return
|
|
5589
|
+
c.fused = f.fused
|
|
5590
|
+
c.finalRank = i + 1
|
|
5591
|
+
c.rrfDense = f.rrfDense
|
|
5592
|
+
c.rrfLex = f.rrfLex
|
|
5593
|
+
})
|
|
5594
|
+
l0Top = fusion.map((f) => byId.get(f.memoryId)).filter(Boolean).slice(0, Math.max(1, Number(limit) || 8))
|
|
4073
5595
|
} catch (eRrf) {}
|
|
4074
5596
|
}
|
|
4075
5597
|
if (!l0Top) {
|
|
@@ -4080,8 +5602,11 @@ class MemoryEngine {
|
|
|
4080
5602
|
if (l0Top.length) {
|
|
4081
5603
|
out.push('== L0 命中(摘要,含 id/得分/匹配原因;展开单条原文传 expand="mem_xxx") ==')
|
|
4082
5604
|
for (const c of l0Top) {
|
|
5605
|
+
// R1 双显示(2026-09-16):绝对分(决策用)在前;融合序号 #N(排序用,即 finalRank 口径)在后。
|
|
5606
|
+
// legacy 路径/RRF 降级时无 finalRank,显示保持旧格式不变(回滚=opts.fusion:'legacy')。
|
|
4083
5607
|
const sc = typeof c.sem === 'number' ? c.sem.toFixed(2) : String(c.lex)
|
|
4084
|
-
|
|
5608
|
+
const fr = Number.isInteger(c.finalRank) ? ' #' + c.finalRank : ''
|
|
5609
|
+
out.push('· [' + c.id + '] ×' + sc + fr + ' ' + (c.reason || '语义') + ' ' + c.label + ' — ' + c.l0)
|
|
4085
5610
|
}
|
|
4086
5611
|
}
|
|
4087
5612
|
} else {
|
|
@@ -4146,18 +5671,26 @@ class MemoryEngine {
|
|
|
4146
5671
|
if (scope === 'all' && !l0Mode && typeof this._jsSemanticRank === 'function') {
|
|
4147
5672
|
try {
|
|
4148
5673
|
const { buildL0IndexPre } = await import('./l0-extract.js')
|
|
5674
|
+
const { isCurrentPre: isCurrentPreArms } = await import('./l0-extract.js')
|
|
4149
5675
|
const { createHash } = await import('node:crypto')
|
|
5676
|
+
// C2(三层契约 I5『检索侧』过滤):谓词经 env 传入 —— semanticArm 会被 smoke 套件"抽源码单独求值",
|
|
5677
|
+
// 那时闭包变量不可见,故这里给**等价内联兜底**(行为与 l0-extract-pre 的 isCurrentPre 一致,勿改语义)。
|
|
4150
5678
|
const semanticArm = async (env) => {
|
|
5679
|
+
const cur = typeof env.isCurrentPre === 'function' ? env.isCurrentPre : (r) => !r || !r.status || r.status === 'current'
|
|
4151
5680
|
const corpus = []
|
|
4152
5681
|
for (const src of env.sources) {
|
|
4153
5682
|
const text = await this.readTextSafe(src.path)
|
|
4154
5683
|
if (!text) continue
|
|
4155
|
-
const items = env.buildL0IndexPre(text)
|
|
4156
|
-
for (const it of items)
|
|
4157
|
-
|
|
5684
|
+
const items = env.buildL0IndexPre(text, { layer: src.path })
|
|
5685
|
+
for (const it of items) {
|
|
5686
|
+
if (!cur(it)) continue
|
|
5687
|
+
corpus.push({ memoryId: it.id, text: it.l0, label: src.label, layer: it.layer || 'log' })
|
|
5688
|
+
}
|
|
4158
5689
|
}
|
|
4159
5690
|
if (!corpus.length) return []
|
|
4160
|
-
|
|
5691
|
+
// #45:此处旧实现在**每个来源之后**就按记录预算提前 break、并按该预算截断语料
|
|
5692
|
+
// ⇒ 日志多时用户级/笔记(追加顺序靠后)永远进不了语义臂。
|
|
5693
|
+
// 现在保留全量语料参与排名,输出端仍由 .slice(0, max(2, limit)) 限制条数。
|
|
4161
5694
|
// 语料版本:canonical sorted [id,L0] 元组哈希(idx_ 前缀匹配 rank() 校验);同语料同 miv → 引擎嵌入缓存命中
|
|
4162
5695
|
const canon = corpus.map((c) => [c.memoryId, c.text]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
4163
5696
|
const miv = 'idx_' + env.createHash('sha256').update(JSON.stringify(canon)).digest('hex').slice(0, 32)
|
|
@@ -4177,15 +5710,17 @@ class MemoryEngine {
|
|
|
4177
5710
|
for (const rf of reflections) semSources.push({ label: 'reflections/' + rf.name, path: path.join(p.reflectDir, rf.name) })
|
|
4178
5711
|
semSources.push({ label: p.projectDir + '/MEMORY.md', path: p.notesPath })
|
|
4179
5712
|
semSources.push({ label: '~' + p.userFile.slice(homedir().length), path: p.userFile })
|
|
4180
|
-
|
|
5713
|
+
// #45:不再传 maxRecords(=256) —— 语料截断即召回截断,会让靠后的来源永远够不到。
|
|
5714
|
+
const semHits = await semanticArm({ sources: semSources, buildL0IndexPre, isCurrentPre: isCurrentPreArms, createHash, query, limit, minScore: 0.5 })
|
|
4181
5715
|
if (semHits.length) {
|
|
4182
5716
|
out.push('== 语义命中(L0 摘要,按相关度;可按锚点下钻) ==')
|
|
4183
5717
|
for (const s of semHits) out.push('· ' + s.label + ' [' + s.id8 + '] ×' + s.score.toFixed(2) + ' ' + s.l0)
|
|
4184
5718
|
}
|
|
4185
5719
|
} catch (eSem) {}
|
|
4186
5720
|
}
|
|
4187
|
-
|
|
4188
|
-
return '[记忆检索] "' + q + '"
|
|
5721
|
+
// #45:把"检索范围受限"如实告诉调用方 —— 未命中 ≠ 从未记录(窗口外文件根本没检索)。
|
|
5722
|
+
if (!out.length) return '[记忆检索] 查询 "' + q + '" —— 未找到相关记忆。' + localWindowNote
|
|
5723
|
+
return '[记忆检索] "' + q + '":\n' + out.join('\n') + localWindowNote
|
|
4189
5724
|
}
|
|
4190
5725
|
|
|
4191
5726
|
/** P4(2026-09-09):按 mem_<32hex> 展开记忆原文。复用 parseAnchors 的字节区间定位(UTF-8 半开 [byteStart,byteEnd)
|
|
@@ -5584,6 +7119,8 @@ class MemoryEngine {
|
|
|
5584
7119
|
let archive = '\n## 归档日志(归档于 ' + this.memToday() + ')'
|
|
5585
7120
|
for (const name of archived) {
|
|
5586
7121
|
const t = await this.readTextSafe(path.join(archiveDir, name))
|
|
7122
|
+
// 归档原文里的 anchor marker 行由 appendText 在写入边界统一剥离(见其注释)——
|
|
7123
|
+
// 那行 marker 属于**旧日志**,内联进来会变成笔记的幻影锚点。
|
|
5587
7124
|
if (t) archive += '\n\n### ' + name + '\n' + t
|
|
5588
7125
|
}
|
|
5589
7126
|
const body = await this.appendText(p.notesPath, archive)
|
|
@@ -5673,6 +7210,11 @@ function defineTool(name, description, parameters, execute) {
|
|
|
5673
7210
|
try {
|
|
5674
7211
|
return await execute(args, exec)
|
|
5675
7212
|
} catch (e) {
|
|
7213
|
+
// issue #48:写入类工具失败必须**向上抛出**,由 DSH 记为 isError。
|
|
7214
|
+
// 旧行为把失败降级成 `xxx 失败: <msg>` 的**成功字符串** ⇒ 模型与调用方都以为
|
|
7215
|
+
// "记忆已写入",而实际一个字节都没落盘(静默丢记忆)。
|
|
7216
|
+
// 注意:仅放行这三个写入工具,其它工具的既有降级文案逐字节不变(开关/行为解耦)。
|
|
7217
|
+
if (name === 'memory_log' || name === 'memory_note' || name === 'memory_user') throw e
|
|
5676
7218
|
return name + ' 失败: ' + (e && e.message ? e.message : String(e))
|
|
5677
7219
|
}
|
|
5678
7220
|
},
|
|
@@ -6447,23 +7989,77 @@ function extractJsonText(line) {
|
|
|
6447
7989
|
} catch (e) { return undefined }
|
|
6448
7990
|
}
|
|
6449
7991
|
|
|
7992
|
+
// ---------- 致命诊断通道:自激闭环防护(2026-09-14 定案修复) ----------
|
|
7993
|
+
// 事故链(stdout/stderr 读端消失时):处理器内 console.error 同步抛 EPIPE → 该异常抛在
|
|
7994
|
+
// uncaughtException 处理器内部 → Node 再次进入致命异常路径 → 无限自激,既不退出又满核空转
|
|
7995
|
+
// (调试器栈 UVException → writable → 本文件 → UVException)。故诊断通道自身必须满足三条:
|
|
7996
|
+
// ① 写前判流可用(destroyed/writableEnded/writable)② 全程 try/catch 且绝不 rethrow ③ 同步重入闸。
|
|
7997
|
+
let _damDiagDepth = 0
|
|
7998
|
+
function damDiagLine(err) {
|
|
7999
|
+
try { if (err && (err.stack || err.message)) return String(err.stack || err.message) } catch (e) {}
|
|
8000
|
+
try { return String(err) } catch (e) {}
|
|
8001
|
+
return '<unprintable error>'
|
|
8002
|
+
}
|
|
8003
|
+
function damSafeDiag(stream, line) {
|
|
8004
|
+
if (_damDiagDepth > 0) return
|
|
8005
|
+
_damDiagDepth++
|
|
8006
|
+
try {
|
|
8007
|
+
// 写路径用 console.* 而非裸 stream.write(2026-09-14 隔离实验结论):
|
|
8008
|
+
// Node 的 console 写实现(internal/console/constructor.js 的 kWriteToConsole)会临时挂 noop 'error'
|
|
8009
|
+
// 监听,断管 EPIPE 被吞掉 —— 故 console 对「写已失效的 stdio」天然免疫(实测断管下 exit 0 / 2.3s / 78ms CPU);
|
|
8010
|
+
// 而裸 stream.write 的失败是**异步** 'error' 事件,无人监听即升级为 uncaughtException 自激
|
|
8011
|
+
// (实测 90% 单核、进程永不自退)。本函数此前正是用裸写,安全性全压在 damSwallowStreamErrors 的
|
|
8012
|
+
// 监听器驻留上;现改为「console 天然免疫」+ 监听器兜底 + 可写性检查三层并存,不再单点依赖监听器。
|
|
8013
|
+
// 保留 writableEnded 前置检查:流已 end() 时 console 写同样失败。(destroyed 半边在进程 stdio 上是
|
|
8014
|
+
// 死代码 —— 实测 process.stdout.destroy() 为无操作,destroyed 仍 false、writable 仍 true。)
|
|
8015
|
+
if (stream && !stream.writableEnded) {
|
|
8016
|
+
if (stream === process.stdout) console.log(String(line))
|
|
8017
|
+
else console.error(String(line))
|
|
8018
|
+
}
|
|
8019
|
+
} catch (e) {} finally { _damDiagDepth-- }
|
|
8020
|
+
}
|
|
8021
|
+
// EPIPE 等写失败以异步 'error' 事件送达;无人监听时会被抛成 uncaughtException,回到同一条自激链。
|
|
8022
|
+
// 这里只吞掉诊断流自身的写失败(不移除、不影响他人的监听者),不让它升级为致命异常。
|
|
8023
|
+
function damSwallowStreamErrors(stream) {
|
|
8024
|
+
try {
|
|
8025
|
+
if (stream && typeof stream.on === 'function' && !stream.__damWriteErrorSwallowed) {
|
|
8026
|
+
stream.__damWriteErrorSwallowed = true
|
|
8027
|
+
stream.on('error', () => {})
|
|
8028
|
+
}
|
|
8029
|
+
} catch (e) {}
|
|
8030
|
+
}
|
|
8031
|
+
/** 定时器不阻止进程退出:进程离开(或套件在断言失败后走不到 disposer 链)时事件循环必须能自然排空。 */
|
|
8032
|
+
function damUnrefTimer(timer) { try { if (timer && typeof timer.unref === 'function') timer.unref() } catch (e) {} }
|
|
8033
|
+
|
|
6450
8034
|
/**
|
|
6451
8035
|
* Mount the memory engine: routes, tools, prompt section, reflection hooks.
|
|
6452
8036
|
*/
|
|
6453
8037
|
export function apply(ctx, config) {
|
|
6454
|
-
//
|
|
8038
|
+
// 进程级诊断:退出/未捕获异常时留痕,便于下次崩溃后定位。
|
|
8039
|
+
// 注意:挂 uncaughtException 监听即抑制 Node 默认的致命退出(插件异常不再连带杀死 dsh web),
|
|
8040
|
+
// 因此本通道自身永不抛异常 —— 全部经 damSafeDiag(见上方三条约束)。
|
|
6455
8041
|
try {
|
|
6456
|
-
process.
|
|
6457
|
-
|
|
8042
|
+
if (!process.__damFatalDiagGuard) {
|
|
8043
|
+
process.__damFatalDiagGuard = true
|
|
8044
|
+
damSwallowStreamErrors(process.stderr)
|
|
8045
|
+
damSwallowStreamErrors(process.stdout)
|
|
8046
|
+
process.on('uncaughtException', (err) => { damSafeDiag(process.stderr, '[dsh-auto-memory] uncaughtException: ' + damDiagLine(err)) })
|
|
8047
|
+
process.on('exit', (code) => { damSafeDiag(process.stdout, '[dsh-auto-memory] process exit code=' + code) })
|
|
8048
|
+
}
|
|
6458
8049
|
} catch (e) {}
|
|
6459
8050
|
// 全局兜底:任何未捕获的异步异常只记录不崩溃(插件进程崩溃会连带 dsh web 一起退出)
|
|
6460
8051
|
try {
|
|
6461
8052
|
if (!process._dshAutoMemoryRejectionGuard) {
|
|
6462
8053
|
process._dshAutoMemoryRejectionGuard = true
|
|
6463
|
-
process.on('unhandledRejection', (reason) => {
|
|
8054
|
+
process.on('unhandledRejection', (reason) => { damSafeDiag(process.stderr, '[dsh-auto-memory] unhandledRejection guard: ' + damDiagLine(reason)) })
|
|
6464
8055
|
}
|
|
6465
8056
|
} catch (e) {}
|
|
6466
8057
|
const engine = new MemoryEngine()
|
|
8058
|
+
// ★2026-09-16 修 BUG-1/BUG-11(注册闸门结构性恒假):
|
|
8059
|
+
// tools 数组在下方**同步**构建, 而 `engine.config` 此刻还是构造期默认值(DEFAULT_CONFIG.boardMode='legacy')。
|
|
8060
|
+
// `apply` 不是 async 且 cordis 不 await 其返回值(见 loadConfigSync 的注释) ⇒ 必须在此处**同步**载入真配置,
|
|
8061
|
+
// 否则 `resolveBoardModePre(engine.config.boardMode)` 恒为 legacy, graph 档工具**永不注册**。
|
|
8062
|
+
try { engine.loadConfigSync() } catch (_) { /* fail-soft: 读不到就用默认(legacy), 绝不阻塞插件加载 */ }
|
|
6467
8063
|
// 新bug修复①·启动预热(2026-09-08):重启后首轮 renderMemoryDynamic 可能先于 _doRefresh 完成,
|
|
6468
8064
|
// 此时 globalLedgerPath 尚未就位,兜底行会错过它要救的那一轮。挂载即异步预热全局账本缓存(只读扫描,零副作用)。
|
|
6469
8065
|
try { void engine.findLatestGlobalHandoff().then((v) => { if (!engine._globalLedgerCache) engine._globalLedgerCache = { at: Date.now(), value: v || '' } }).catch(() => {}) } catch (e) {}
|
|
@@ -6474,7 +8070,22 @@ export function apply(ctx, config) {
|
|
|
6474
8070
|
// M6-3:Activation Inbox Host 接线(assoc+activationInbox 双门;默认关闭)
|
|
6475
8071
|
engine._activationHost = createActivationHost({ engine })
|
|
6476
8072
|
// M7-8:Host Index Sync Orchestrator(四门全开才启用;默认关闭零 IO;修复 M7-8 Phase E blocker)
|
|
6477
|
-
engine._indexSyncHost = createIndexSyncHostPre({
|
|
8073
|
+
engine._indexSyncHost = createIndexSyncHostPre({
|
|
8074
|
+
engine,
|
|
8075
|
+
diag,
|
|
8076
|
+
// ★P2(T2-9b/c):切换状态持久化 —— 重启后 running → interrupted,进度(实际完成量)保留可续跑。
|
|
8077
|
+
switchIo: {
|
|
8078
|
+
readJson(p) {
|
|
8079
|
+
let raw
|
|
8080
|
+
try { raw = readFileSync(p, 'utf8') } catch (_) { return null }
|
|
8081
|
+
try { return JSON.parse(raw) } catch (_) { return null }
|
|
8082
|
+
},
|
|
8083
|
+
writeJson(p, obj) {
|
|
8084
|
+
try { mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(obj), 'utf8') } catch (_) {}
|
|
8085
|
+
},
|
|
8086
|
+
},
|
|
8087
|
+
switchPersistPath: path.join(dshHome(), 'memory', 'semantic', 'engine-switch-state.json'),
|
|
8088
|
+
})
|
|
6478
8089
|
// M8:记忆中枢(Memory Hub)编排器 —— 三层记忆(episodic/semantic/procedural)。
|
|
6479
8090
|
// 惰性:默认关时三层 store 仍构造(纯内存零 IO),但消费链不启动;
|
|
6480
8091
|
// 开启后经 memoryHubEnabled 门消费 judgement-shadow + 提供 overview 端点。
|
|
@@ -6669,6 +8280,74 @@ export function apply(ctx, config) {
|
|
|
6669
8280
|
const userModelsRoot = path.join((process.env.DSH_HOME && process.env.DSH_HOME.trim()) || path.join(homedir(), '.dsh'), 'models', 'js-semantic')
|
|
6670
8281
|
mkdirSync(userModelsRoot, { recursive: true })
|
|
6671
8282
|
engine._jsDownload = createSemanticDownloaderPre({ modelsRoot: userModelsRoot })
|
|
8283
|
+
// ─────────── 三层检索契约 C3(2026-09-14):L0 向量索引接线 ───────────
|
|
8284
|
+
// 背景:`lib/l0-index.js`(L0 自己的向量索引,增量)此前**全仓零引用**——L0 层没有自己的索引,
|
|
8285
|
+
// 现有向量是对原文块做的。这里把它接上:工作区语料刷新完成后,把 L0 摘要按层增量写成索引文件。
|
|
8286
|
+
// · 开关 = config.l0IndexEnabled(**默认 true**:用户 2026-09-14 裁定;设 false 才回到零 IO、零嵌入、零目录);
|
|
8287
|
+
// · 落盘 = <dshHome>/memory/semantic/l0/l0-index-<workspaceKey 短哈希>-<layer>.json
|
|
8288
|
+
// (按层各一份,原因见 lib/l0-index-sync.js 文件头「为什么按层各一份文件」);
|
|
8289
|
+
// · 嵌入 = 端侧 JS 引擎(e5-small q8)的 embedPassages,**检索路径零 LLM 调用**(S9);
|
|
8290
|
+
// · 纪律 = 全流程 fail-soft:任何异常只 diag 一行,绝不抛、绝不阻塞召回路径。
|
|
8291
|
+
engine._l0IndexSync = createL0IndexSyncPre({
|
|
8292
|
+
io: {
|
|
8293
|
+
// 缺失/非文件 → null(模块按 missing 处理);JSON 损坏 → 抛(模块记 read-error)——与模块 IO 契约一致。
|
|
8294
|
+
readJson(p) {
|
|
8295
|
+
let raw
|
|
8296
|
+
try { raw = readFileSync(p, 'utf8') } catch (_) { return null }
|
|
8297
|
+
return JSON.parse(raw)
|
|
8298
|
+
},
|
|
8299
|
+
writeJson(p, obj) {
|
|
8300
|
+
mkdirSync(path.dirname(p), { recursive: true })
|
|
8301
|
+
writeFileSync(p, JSON.stringify(obj), 'utf8') // UTF-8 无 BOM
|
|
8302
|
+
},
|
|
8303
|
+
},
|
|
8304
|
+
embedder: { embedPassages: (texts) => engine._jsSemantic.embedPassages(texts) },
|
|
8305
|
+
readText: (f) => engine.readTextSafe(f),
|
|
8306
|
+
diag,
|
|
8307
|
+
// ★P2(T2-9):L0 索引写入**真实宽引擎身份**(端侧 e5-small q8,384 维已归一化)。
|
|
8308
|
+
// 身份门默认开启(`embeddingCacheV2Enabled !== false`)——换引擎后旧索引整文件判不可用、
|
|
8309
|
+
// 走全量重建,两套向量不可能参与同一次排序。回滚:`embeddingCacheV2Enabled=false` 关身份门。
|
|
8310
|
+
engineIdentity: computeEngineIdentityPre(JS_E5_IDENTITY_DESC_V1),
|
|
8311
|
+
engineIdentityGate: engine.config.embeddingCacheV2Enabled !== false,
|
|
8312
|
+
crossIdReuse: engine.config.indexDeltaSyncEnabled !== false,
|
|
8313
|
+
})
|
|
8314
|
+
/** 索引目录(语义目录下,与 semantic 并列的 l0/ 子目录)。 */
|
|
8315
|
+
engine.l0IndexDir = () => path.join(dshHome(), 'memory', 'semantic', 'l0')
|
|
8316
|
+
engine._l0IndexLastAt = 0
|
|
8317
|
+
/**
|
|
8318
|
+
* 同步一次 L0 索引(**默认关闭**;开启后由 l0IndexSyncTick 按需触发,也可手动调用做诊断)。
|
|
8319
|
+
* 返回结构来自 l0-index-sync-pre.sync;任何异常都吞掉并记一行 diag(fail-soft)。
|
|
8320
|
+
*/
|
|
8321
|
+
engine.syncL0IndexPre = async ({ agent } = {}) => {
|
|
8322
|
+
try {
|
|
8323
|
+
if (engine.config.l0IndexEnabled !== true) return { ok: false, reason: 'disabled', enabled: false, written: 0, count: 0, files: [] }
|
|
8324
|
+
const p = await engine.resolvePaths(agent)
|
|
8325
|
+
if (!p || !p.projectDir) return { ok: false, reason: 'no-paths', enabled: true, written: 0, count: 0, files: [] }
|
|
8326
|
+
const sources = []
|
|
8327
|
+
const push = (layer, file) => { if (file) sources.push({ layer, path: file }) }
|
|
8328
|
+
push('project', p.notesPath)
|
|
8329
|
+
push('user', p.userFile)
|
|
8330
|
+
push('log', p.logPath)
|
|
8331
|
+
// 反思 / 历史日志(有界;读不到就少几条,绝不报错)
|
|
8332
|
+
try { for (const rf of await engine.listReflections(p.reflectDir, 14)) push('reflection', path.join(p.reflectDir, rf && rf.name ? rf.name : '')) } catch (_) {}
|
|
8333
|
+
try { for (const lg of await engine.listDailyLogs(p.projectDir, 14)) push('log', path.join(p.projectDir, lg && lg.name ? lg.name : '')) } catch (_) {}
|
|
8334
|
+
return await engine._l0IndexSync.sync({ enabled: true, workspaceKey: p.ws || p.projectDir || '', dir: engine.l0IndexDir(), sources })
|
|
8335
|
+
} catch (e) {
|
|
8336
|
+
try { diag('l0-index sync 降级: ' + String((e && e.message) || e).slice(0, 120)) } catch (_) {}
|
|
8337
|
+
return { ok: false, reason: 'error', enabled: true, written: 0, count: 0, files: [] }
|
|
8338
|
+
}
|
|
8339
|
+
}
|
|
8340
|
+
/** 触发点:工作区语料刷新完成时(refreshAll → refresh 完成)。节流 5 分钟,异步不阻塞刷新。 */
|
|
8341
|
+
engine.l0IndexSyncTick = (agent) => {
|
|
8342
|
+
try {
|
|
8343
|
+
if (engine.config.l0IndexEnabled !== true) return
|
|
8344
|
+
if (!agent || !engine.hasReliableSessionIdentity(agent)) return
|
|
8345
|
+
const now = Date.now()
|
|
8346
|
+
if (now - (engine._l0IndexLastAt || 0) < 5 * 60 * 1000) return
|
|
8347
|
+
engine._l0IndexLastAt = now
|
|
8348
|
+
void engine.syncL0IndexPre({ agent }).catch(() => {})
|
|
8349
|
+
} catch (_) {}
|
|
8350
|
+
}
|
|
6672
8351
|
// 资产探测(semantic-status / 档位解析 / 引导卡共用):共享实现见 semantic-js-pre 的
|
|
6673
8352
|
// probeJsSemanticAssets —— peer 探测与引擎加载走同一套 Node 解析(2026-09-02 issue 修正)。
|
|
6674
8353
|
// _peerExtraDirs = 深度扫描热接入位(semanticDeepDetect 命中后 probe/加载/档位即时生效)。
|
|
@@ -6939,14 +8618,25 @@ export function apply(ctx, config) {
|
|
|
6939
8618
|
engine._ctxRef = ctx // 惰性重取服务用(apply 时可能尚未就绪)
|
|
6940
8619
|
|
|
6941
8620
|
// 生命周期刷新
|
|
8621
|
+
// ★P2(2026-09-15 ALS 遗留①②):`refresh(agent)` 自身已按 runtime 隔离串行链并绑定上下文
|
|
8622
|
+
// (见 refresh 方法头注释),因此这里**无需**再包 withAgent 就能把 state 写进正确的 runtime。
|
|
8623
|
+
// 但下面的 paths 快照读取(`engine.state.ws` 等)仍发生在**本回调的 ALS 上下文**里,
|
|
8624
|
+
// 若该回调由生命周期事件在 withAgent 之外触发,读到的就是 default runtime 的空 state
|
|
8625
|
+
// ⇒ capturePaths 会记录空快照。故这里显式包 withAgent(agent) 读取。
|
|
6942
8626
|
const refreshAll = (agent) => {
|
|
6943
8627
|
void engine.refresh(agent).then(() => {
|
|
6944
|
-
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
8628
|
+
try {
|
|
8629
|
+
engine.withAgent(agent, () => {
|
|
8630
|
+
// M4-3:refresh 完成后同步捕获 paths 快照供 Shadow 调度(§7.2 禁止异步 tick 内裸调 resolvePaths)
|
|
8631
|
+
try { if (engine._shadowHost && agent && engine.state.ws) engine._shadowHost.capturePaths(engine.runtimeFor(agent).key, engine.state) } catch (_) {}
|
|
8632
|
+
// M5-3:同一 paths 快照共享给 Context Bridge(仅相对投影,不落盘路径)
|
|
8633
|
+
try { if (engine._contextHost && agent && engine.state.ws) engine._contextHost.capturePaths(engine.runtimeFor(agent).key, engine.state) } catch (_) {}
|
|
8634
|
+
// M6-3:同一 paths 快照共享给 Activation Inbox(corpus 查询用)
|
|
8635
|
+
try { if (engine._activationHost && agent && engine.state.ws) engine._activationHost.capturePaths(engine.runtimeFor(agent).key, engine.state) } catch (_) {}
|
|
8636
|
+
// 三层契约 C3:工作区语料刷新完成 = L0 索引的触发点(默认关闭 → 立即返回,零 IO)
|
|
8637
|
+
try { engine.l0IndexSyncTick(agent) } catch (_) {}
|
|
8638
|
+
})
|
|
8639
|
+
} catch (_) {}
|
|
6950
8640
|
}).catch(() => {})
|
|
6951
8641
|
}
|
|
6952
8642
|
refreshAll()
|
|
@@ -6955,6 +8645,7 @@ export function apply(ctx, config) {
|
|
|
6955
8645
|
// 动态通知:启动拉取一次 + 每小时刷新(发布者 push notices.json 即可向用户推送重大提醒)
|
|
6956
8646
|
const noticesRefresh = () => { void engine.fetchNotices(true).then((l) => { engine._noticesCache = l }).catch(() => {}) }
|
|
6957
8647
|
const noticesTimer = setInterval(noticesRefresh, 3600 * 1000)
|
|
8648
|
+
damUnrefTimer(noticesTimer)
|
|
6958
8649
|
void engine.fetchNotices(false).then((l) => { engine._noticesCache = l }).catch(() => {})
|
|
6959
8650
|
ctx.on('agent/session-start', (payload) => {
|
|
6960
8651
|
// M0/M1: 建立该 agent/session 的 runtime(WeakMap/Map 登记),之后所有状态按 session 隔离。
|
|
@@ -6993,7 +8684,7 @@ export function apply(ctx, config) {
|
|
|
6993
8684
|
// M-CM6-C 宿主兜底:轮末测量完成后若达标 → arm 倒计时(浏览器活着时由它展示确认卡;被节流/关闭则宿主到期自执行)
|
|
6994
8685
|
try {
|
|
6995
8686
|
const rt2 = engine.runtimeFor(agent)
|
|
6996
|
-
engine.armAutoContinue(agent, { ratio: rt2.waterLevel, tokens: rt2.waterLevelTokens, window: rt2.waterLevelWindow, source: rt2.waterLevelSource })
|
|
8687
|
+
engine.armAutoContinue(agent, { ratio: rt2.waterLevel, tokens: rt2.waterLevelTokens, window: rt2.waterLevelWindow, source: rt2.waterLevelSource, modelKnown: rt2.waterLevelModelKnown, hard: rt2.waterLevelHard })
|
|
6997
8688
|
} catch (eArm) {}
|
|
6998
8689
|
}) } catch (e) {}
|
|
6999
8690
|
// 全局活动戳同步更新(暂离检测统揽全局,见 tickTime)
|
|
@@ -7036,7 +8727,17 @@ export function apply(ctx, config) {
|
|
|
7036
8727
|
try { if (agent.session && agent.session.header && agent.session.header.parentSession) skip = true } catch (e) {}
|
|
7037
8728
|
const st = engine.stateFor(agent)
|
|
7038
8729
|
if (!skip && (!st.loadedAt || Date.now() - st.loadedAt > 15000)) {
|
|
7039
|
-
|
|
8730
|
+
// ★2026-09-15(修「注入头工作区 (未知)」· 病因 C):**必须包进 withAgent**。
|
|
8731
|
+
// `engine.state` 是 getter(`:1003`)→ `currentRuntime()`(`:1007`)→ ALS `_runtimeContext.getStore()`
|
|
8732
|
+
// 未命中时回退 `runtimes.get(undefined)`(default runtime)。而 ALS 的**唯一**写入点是
|
|
8733
|
+
// `withAgent`(`:1099 this._runtimeContext.run(...)`,全仓仅此一处)。
|
|
8734
|
+
// ⇒ 裸调 `engine.refresh(agent)` 时,`_doRefresh` 里的 `this.state.ws = p.ws`(`:3704`)
|
|
8735
|
+
// 会写进 **default runtime**;而注入回调在 `withAgent` 内读的是 **agent runtime**,
|
|
8736
|
+
// 两者不是同一个 state 对象 ⇒ 那道「首轮必定 await 完」的保护对注入侧**从未生效**,
|
|
8737
|
+
// 且 `loadedAt` 也写在 default 上 ⇒ agent runtime 的 loadedAt 恒为 0 ⇒ 每个 step 白刷一次。
|
|
8738
|
+
// 最小复现已实证(见 artifacts/_repro-als-state.mjs):A 场景写 default、B 场景写 agent。
|
|
8739
|
+
// 本行修复后,写入目标与读取目标同源。
|
|
8740
|
+
await engine.withAgent(agent, () => engine.refresh(agent))
|
|
7040
8741
|
}
|
|
7041
8742
|
}
|
|
7042
8743
|
} catch (e) {}
|
|
@@ -7073,15 +8774,67 @@ export function apply(ctx, config) {
|
|
|
7073
8774
|
// M0/M1: 绑定到该 agent 的 runtime 再读取(state 是 per-session getter,避免落到 default runtime 读到空)
|
|
7074
8775
|
return engine.withAgent(agent, () => {
|
|
7075
8776
|
const st = engine.stateFor(agent)
|
|
8777
|
+
// ★2026-09-15(用户裁定 · 并入 P1):**修 `no-paths-captured` 降级**。
|
|
8778
|
+
// 本回调对**每一个** agent(含子会话/子代理)都会跑一次,是"该 agent 正在被注入"的
|
|
8779
|
+
// 唯一可靠信号 —— 用它把已解析好的工作区 paths 按本 agent 的 runtime.key 补注册一次。
|
|
8780
|
+
// 纯内存、零 IO、幂等;state.ws 未就绪时返回 false(不猜测路径),不影响后续逻辑。
|
|
8781
|
+
try { engine.capturePathsFor(agent) } catch (_) {}
|
|
7076
8782
|
if (!st.loadedAt || Date.now() - st.loadedAt > 15000) {
|
|
8783
|
+
// ★P2(2026-09-15 ALS 遗留①):本处 `refresh(agent)` 是**发射后不管**(fire-and-forget)——
|
|
8784
|
+
// 注入回调必须同步返回文本,不能 await。**"写错 runtime" 的那一半已由 `refresh()` 自身
|
|
8785
|
+
// 修掉**(内部按 runtime 隔离串行链并在 `_runtimeContext.run(rt, ...)` 内执行 _doRefresh),
|
|
8786
|
+
// 因此这里的刷新确实落到**该 agent 的 runtime**;剩余"首帧渲染早于写入完成"是 fire-and-forget
|
|
8787
|
+
// 的固有边界,由 ①每轮 loadedAt 过期判定重试 ②渲染侧 wsHint 兜底(见 snapshotMeta)共同收敛。
|
|
7077
8788
|
void engine.refresh(agent)
|
|
7078
8789
|
}
|
|
7079
8790
|
// 2026-08-27 频率控制(在调用方做,不破坏 renderMemoryDynamic 契约):
|
|
7080
8791
|
// 指纹只基于"日志段"(recentLogs)——日志是每轮微变源。日志变化且距上次注入不足
|
|
7081
8792
|
// snapshotMinGapRounds 轮 → 暂缓;反思/用户/笔记/欢迎等结构性变化始终立即注入。
|
|
7082
8793
|
// 内容未变则正常返回(由 project 去重)。状态 per-agent(挂 runtime.state)防 A/B 串线。
|
|
8794
|
+
// P6A(2026-09-14)零值修复:旧写法 `Number(v) || 5` 让 **0 无法表达**(0 是 falsy ⇒ `0 || 5` = 5)。
|
|
8795
|
+
// ★2026-09-15 分级注入:`snapshotMinGapRounds` 现在是**"完整快照"的间隔**(默认 5)——
|
|
8796
|
+
// 其间各轮给**精简版**(规则 + 索引目录 + 日程),即用户要的「不是不注入,而是精简注入」。
|
|
8797
|
+
// ⚠️ 这行 `const snap = ...` 曾被一次编辑误删(症状:动态块整体变空、smoke-test 报
|
|
8798
|
+
// "missing memory_system block",但**不报错**——外层 catch 把 ReferenceError 吞成空串)。
|
|
8799
|
+
// 改本区域时务必保留它:它是整段注入的唯一数据源。
|
|
7083
8800
|
const snap = engine.renderMemoryDynamic(context)
|
|
7084
|
-
const gap =
|
|
8801
|
+
const gap = parseGapRoundsPre(engine.config.snapshotMinGapRounds, 5)
|
|
8802
|
+
// ★2026-09-15(用户裁定 · 修「重启后首轮工作区显示 (未知)」):
|
|
8803
|
+
// 与 `resolvePaths(agent)` 第①优先级(:1735)及 GUI 概览页 `currentWs()`(client.js:485)
|
|
8804
|
+
// **同源** —— 直读 `session.header.cwd`,同步、零 IO、不经过 `state.ws`。
|
|
8805
|
+
// 仅在 `state.ws` 尚未就绪的首轮兜底使用;就绪后 `s.ws` 优先,故不影响后续轮次。
|
|
8806
|
+
let wsHintPre = ''
|
|
8807
|
+
try { wsHintPre = (agent.session && agent.session.header && agent.session.header.cwd) || '' } catch (_) {}
|
|
8808
|
+
const slimText = engine.config.snapshotTieredInject === false ? '' : engine.renderSlimSnapshotPre(wsHintPre)
|
|
8809
|
+
// ★2026-09-15(用户裁定 A → 实测修正为 **B**):**新 turn 首次注入强制完整版**。
|
|
8810
|
+
// ⚠️ 必须**在节流分支之前**判定 —— 首轮 `st._snapFp === undefined` 走的是"首次注入"那条路,
|
|
8811
|
+
// 若把判定放进 `else if(节流)` 分支里,这个判定永远进不到(本机实测:诊断日志零命中)。
|
|
8812
|
+
// 判据用 **turn 号**(`turnBoundaryKeyPre`)而**不是"抓到真人消息"**:宿主的 `user/message`
|
|
8813
|
+
// 投递与观察器落地不同步,首次装配 context 时事件流里还看不到它 ⇒ 首版 A 实测要到本 turn
|
|
8814
|
+
// 第 3 次注入才升完整版(正是用户报告的"点发送后没立刻注入,完成一次工具调用后才注入完整版")。
|
|
8815
|
+
// `turn/start` 的 turn 号在进入本 turn 时即写入事件流,装配时必然可见。
|
|
8816
|
+
// 语义:每个 turn **只强制一次**(`st._humanFullKey` 去重)⇒ 长任务里后续工具 step 回到节流。
|
|
8817
|
+
if (gap > 0 && snap) {
|
|
8818
|
+
try {
|
|
8819
|
+
const stH = engine.stateFor(agent)
|
|
8820
|
+
const tk = engine.turnBoundaryKeyPre(agent)
|
|
8821
|
+
if (tk && stH._humanFullKey !== tk) {
|
|
8822
|
+
stH._humanFullKey = tk
|
|
8823
|
+
stH._snapFp = snap
|
|
8824
|
+
const lm = snap.match(/\[最近 \d+ 天工作日志[^\]]*\][\s\S]*?(?=\n\[|\n<memory_system>|\n$)/)
|
|
8825
|
+
stH._snapLogFp = lm ? createHash('sha256').update(lm[0]).digest('hex').slice(0, 16) : stH._snapLogFp
|
|
8826
|
+
stH._snapRound = (stH._snapRound || 0) + 1
|
|
8827
|
+
stH._snapLastRound = stH._snapRound
|
|
8828
|
+
stH._snapPendingSnap = null
|
|
8829
|
+
stH._snapPendingLogFp = null
|
|
8830
|
+
// 留痕:turn 号只说明"新的一轮",不区分人/cron/接续 ⇒ 附带真人观测便于事后核对
|
|
8831
|
+
let isHuman = false
|
|
8832
|
+
try { isHuman = engine.humanTurnObservedPre(agent) } catch (_) {}
|
|
8833
|
+
diag('tiered inject: 新 turn 强制完整版(' + tk + (isHuman ? ' · 真人在场' : ' · 无真人消息') + ')')
|
|
8834
|
+
return snap + engine.renderReflectionRequest()
|
|
8835
|
+
}
|
|
8836
|
+
} catch (_) {}
|
|
8837
|
+
}
|
|
7085
8838
|
if (gap > 0 && snap) {
|
|
7086
8839
|
const st = engine.stateFor(agent)
|
|
7087
8840
|
// 2026-08-27 压缩检测:上下文压缩/截断常伴随 contextVersion 重置(归零/倒退)。
|
|
@@ -7109,7 +8862,12 @@ export function apply(ctx, config) {
|
|
|
7109
8862
|
} else if (st._snapRound - (st._snapLastRound || 0) < gap) {
|
|
7110
8863
|
st._snapPendingSnap = snap
|
|
7111
8864
|
st._snapPendingLogFp = logFp
|
|
7112
|
-
|
|
8865
|
+
// ★2026-09-15(用户裁定"不是不注入,而是精简注入"):
|
|
8866
|
+
// 节流**只决定"这一轮给完整版还是精简版"**,不再有"整份跳过"这一档。
|
|
8867
|
+
// 旧实现是 `return engine.renderReflectionRequest()`(整份快照跳过)⇒ 规矩与索引在 2–5 轮不在场。
|
|
8868
|
+
// 现在改为返回**精简版**:规则(每轮在场,不参与裁剪)+ Tier-0 常驻目录(索引层)
|
|
8869
|
+
// + 日程 + 一行"这是精简版、怎么取全文"。`snapshotTieredInject=false` 可回退旧行为。
|
|
8870
|
+
return slimText + engine.renderReflectionRequest()
|
|
7113
8871
|
} else {
|
|
7114
8872
|
st._snapFp = snap
|
|
7115
8873
|
st._snapLogFp = logFp
|
|
@@ -7158,6 +8916,7 @@ export function apply(ctx, config) {
|
|
|
7158
8916
|
defineTool('memory_log', '向当前工作区的 .dsh-memory/ 今日日志追加一条工作记录(append-only,自动建目录/文件)。完成实质性工作(改代码/修 bug/写文档/重构/技术选型/用户偏好约定)后必须调用;有跨会话长期价值的内容在同一轮内一并写入记忆(memory_note 项目/ memory_user 跨项目),progress 与 memory 一起写;不要记录临时信息。**调用后必须在本轮回复正文(摘要可见的正文,不是工具调用区)中向用户转述一句:如"已把 X 记入今日日志"**。', {
|
|
7159
8917
|
note: { type: 'string', required: true, description: '简短条目:一句话概括做了什么、结果如何。' },
|
|
7160
8918
|
date: { type: 'string', description: '日志日期 YYYY-MM-DD,缺省今天。' },
|
|
8919
|
+
kind: { type: 'string', enum: ['rule', 'preference', 'fact', 'todo'], description: '★P6B:条目性质标记(缺省 fact)。rule=用户规则/约定;preference=偏好;todo=待办;fact=事实记录。是规则或用户明确约定的条目请传 kind=rule——它会被规则层识别为必须遵守的约束。纯标记、零额外 LLM 调用。' },
|
|
7161
8920
|
}, async (args, exec) => {
|
|
7162
8921
|
const date = DATE_RE.test(args.date || '') ? args.date : engine.memToday()
|
|
7163
8922
|
const p = await engine.resolvePaths(exec.agent)
|
|
@@ -7168,7 +8927,12 @@ export function apply(ctx, config) {
|
|
|
7168
8927
|
if (!gate.ok) return 'memory_log: 写入被记忆卫生闸门拦截(' + WRITE_GATE_REASON[gate.reason] + '),未写入。请改写为客观陈述后重试。'
|
|
7169
8928
|
const existing = engine.state.logText || (await engine.readTextSafe(logPath)) || ''
|
|
7170
8929
|
if (tailHas(existing, note)) return 'memory_log: 该条目与日志尾部已有内容重复,拒绝写入(复读防护)。'
|
|
7171
|
-
|
|
8930
|
+
// ★P6B(2026-09-15 用户裁定开工):行内 kind 标记 = 规则分类的持久化落点(V2-P6B points[1])。
|
|
8931
|
+
// 非法值一律回落 fact(fail-soft,不因参数写错而改变写入);缺省不产生标记 ⇒ 与旧版逐字节一致。
|
|
8932
|
+
const kindRaw = String(args.kind || '').trim().toLowerCase()
|
|
8933
|
+
const kind = LOG_KINDS_V1.includes(kindRaw) ? kindRaw : ''
|
|
8934
|
+
const kindTag = kind ? '[kind:' + kind + '] ' : ''
|
|
8935
|
+
const entry = '- ' + nowHm() + ' ' + kindTag + (gate.clean || note).trim()
|
|
7172
8936
|
const body = await engine.appendText(logPath, entry)
|
|
7173
8937
|
if (date === engine.memToday()) { engine.state.logText = body; engine.state.logPath = logPath; engine.state.loadedAt = Date.now() }
|
|
7174
8938
|
return '已更新记忆文档: ' + logPath + '\n' + entry + (gate.truncated ? '\n(内容超长,已截断)' : '')
|
|
@@ -7190,7 +8954,15 @@ export function apply(ctx, config) {
|
|
|
7190
8954
|
const r = args.kind === 'plan'
|
|
7191
8955
|
? await engine.writePlanSnapshot(p.projectDir, writeH)
|
|
7192
8956
|
: await engine.writeHandoffLedger(p.projectDir, writeH)
|
|
7193
|
-
|
|
8957
|
+
// P0(2026-09-14):写入被保护门/判据门拦下时,**原样把可执行改写指引交给模型**
|
|
8958
|
+
// (不是"白板写入失败(未知)"——模型拿那四个字是修不回来的)。
|
|
8959
|
+
// 两个门的拒绝文案都已自带"哪个段空、哪张卡消失、哪个区被改",此处不再包装。
|
|
8960
|
+
if (!r || !r.ok) {
|
|
8961
|
+
const detail = (r && r.error) || '未知'
|
|
8962
|
+
if (r && r.gate === 'mutation') return 'memory_note: ' + detail
|
|
8963
|
+
if (r && r.gate === 'criteria') return 'memory_note: ' + detail
|
|
8964
|
+
return 'memory_note: 白板写入失败(' + detail + ')。'
|
|
8965
|
+
}
|
|
7194
8966
|
if (args.kind === 'plan') { engine.state.planText = (r && r.final) || writeH; engine.state.loadedAt = Date.now() }
|
|
7195
8967
|
else { engine.state.latestHandoffText = '# 交接账本 · ' + engine.memToday() + ' ' + nowHm() + '\n\n' + ((r && r.clean) || writeH); engine.state.loadedAt = Date.now() }
|
|
7196
8968
|
return (args.kind === 'plan'
|
|
@@ -7206,7 +8978,7 @@ export function apply(ctx, config) {
|
|
|
7206
8978
|
const existing = engine.state.notesText || (await engine.readTextSafe(p.notesPath)) || ''
|
|
7207
8979
|
if (tailHas(existing, write)) return 'memory_note: 与笔记尾部已有内容重复,拒绝写入(复读防护)。'
|
|
7208
8980
|
}
|
|
7209
|
-
const acct = await engine.ensureBudget(exec.agent, 'note', write)
|
|
8981
|
+
const acct = await engine.ensureBudget(exec.agent, 'note', write, { replace })
|
|
7210
8982
|
if (!acct.ok) return 'memory_note: ' + engine.budgetRefusalTextPre(acct, 'note') + ' 本次未写入(原内容未改动)。'
|
|
7211
8983
|
let body
|
|
7212
8984
|
if (replace) {
|
|
@@ -7235,7 +9007,7 @@ export function apply(ctx, config) {
|
|
|
7235
9007
|
const existing = engine.state.userText || (await engine.readTextSafe(p.userFile)) || ''
|
|
7236
9008
|
if (tailHas(existing, write)) return 'memory_user: 与用户级记忆尾部已有内容重复,拒绝写入(复读防护)。'
|
|
7237
9009
|
}
|
|
7238
|
-
const acct = await engine.ensureBudget(exec.agent, 'user', write)
|
|
9010
|
+
const acct = await engine.ensureBudget(exec.agent, 'user', write, { replace })
|
|
7239
9011
|
if (!acct.ok) return 'memory_user: ' + engine.budgetRefusalTextPre(acct, 'user') + ' 本次未写入(原内容未改动)。'
|
|
7240
9012
|
let body
|
|
7241
9013
|
if (replace) {
|
|
@@ -7362,6 +9134,24 @@ export function apply(ctx, config) {
|
|
|
7362
9134
|
}, async (args, exec) => engine.consolidateMemory(exec.agent, Math.min(Math.max(Number(args.days) || 7, 1), 30))),
|
|
7363
9135
|
]
|
|
7364
9136
|
|
|
9137
|
+
// ── WB-GRAPH 白板线新工具(board_mode_v1 闸门, 2026-09-16)──
|
|
9138
|
+
// 用户裁定:「一键切换旧版和新版」「线先别着急接」——boardMode='graph' 时才注册 P3 遍历工具
|
|
9139
|
+
// (工具数 14→16);legacy(默认)不注册,字节级旧行为。dsh-graph vendor 的 graph_* 工具由
|
|
9140
|
+
// profile cordis.patch.yml 按同一开关独立接线(见 vendor/dsh-graph/NOTICE-VENDOR.md)。
|
|
9141
|
+
//
|
|
9142
|
+
// ★2026-09-16 修 BUG-15(图档端到端套件抓出): 此前这段 `defineTool(...)` 写在 `const tools = [...]`
|
|
9143
|
+
// **数组之外**, 返回值**从未 push 进 tools** ⇒ 即便闸门判定为 true, 两个工具也不会被注册。
|
|
9144
|
+
// 这是与 BUG-1 独立的第二道致命缺陷: 修好「读到真配置」还不够, 还必须真的把定义收进数组。
|
|
9145
|
+
if (resolveBoardModePre(engine.config.boardMode).graphEnabled) {
|
|
9146
|
+
tools.push(defineTool('memory_expand_pre', '白板结构化展开(P3, 需 boardMode=graph):正向遍历——给定 tag(如 type:dead-end / topic:登录)展开所有匹配的账本/白板条目 Content,默认 limit 10、硬帽 20。返回条目 id、标题、来源(source 文件+行)与判据状态。适合主动重建上下文(如「把所有失败方案列出来」)。', {
|
|
9147
|
+
tag: { type: 'string', description: '要展开的 tag,如 type:dead-end 或 topic:主题名。' },
|
|
9148
|
+
limit: { type: 'integer', description: '返回条数上限,缺省 10,硬帽 20。' },
|
|
9149
|
+
}, async (args, exec) => engine.expandWhiteboardByTagPre(exec.agent, String(args.tag || ''), Math.min(Math.max(Number(args.limit) || 10, 1), 20))))
|
|
9150
|
+
tools.push(defineTool('memory_trace_pre', '白板结构化回溯(P3, 需 boardMode=graph):反向遍历——给定条目 id 回溯它的 cue(入口关键词/路径)、tag 与相邻条目,以及归档版本链(prev_version)。适合「这条结论从哪来」的溯源。', {
|
|
9151
|
+
id: { type: 'string', description: '条目 id(index.json 里的条目标识)。' },
|
|
9152
|
+
}, async (args, exec) => engine.traceWhiteboardByIdPre(exec.agent, String(args.id || ''))))
|
|
9153
|
+
}
|
|
9154
|
+
|
|
7365
9155
|
// ---------- 路由 ----------
|
|
7366
9156
|
const routes = [
|
|
7367
9157
|
{
|
|
@@ -7441,10 +9231,28 @@ export function apply(ctx, config) {
|
|
|
7441
9231
|
if (['shadow', 'canary-explicit', 'active'].includes(em)) activationEmitMode = em
|
|
7442
9232
|
} catch (_) {}
|
|
7443
9233
|
const resolvedTier = await engine.resolveSemanticTier()
|
|
9234
|
+
// ★P2(T2-9 · 进度条只有一个真实所有者):引擎切换/索引重建进度**唯一来源**是
|
|
9235
|
+
// `engine._indexSyncHost`(状态机由它持有);此处只**投影**,不另立第二套计数。
|
|
9236
|
+
// assetsReady = 模型/依赖等**资产**就绪(≠ 索引就绪,V2-P2 卡明确要求区分);
|
|
9237
|
+
// indexReady = **当前引擎身份**的索引已完成(manifest 已发布)且无在飞重建。
|
|
9238
|
+
let engineSwitch = null
|
|
9239
|
+
let indexReady = false
|
|
9240
|
+
let assetsReady = false
|
|
9241
|
+
try {
|
|
9242
|
+
if (engine._indexSyncHost && typeof engine._indexSyncHost.getEngineSwitchStatusPre === 'function') {
|
|
9243
|
+
engineSwitch = engine._indexSyncHost.getEngineSwitchStatusPre()
|
|
9244
|
+
indexReady = engineSwitch.indexReady === true && engine._indexSyncHost.requiresFullRebuild() !== true
|
|
9245
|
+
}
|
|
9246
|
+
assetsReady = !!(probe && probe.ready)
|
|
9247
|
+
} catch (_) {}
|
|
7444
9248
|
return writeJson(res, 200, {
|
|
7445
9249
|
engineDefault: 'js',
|
|
7446
9250
|
activationEmitMode,
|
|
7447
9251
|
resolvedTier,
|
|
9252
|
+
// ★P2:资产就绪 vs 索引就绪(模型下载完成 ≠ 当前引擎索引完成)
|
|
9253
|
+
assetsReady,
|
|
9254
|
+
indexReady,
|
|
9255
|
+
engineSwitch,
|
|
7448
9256
|
jsSemantic: engine._jsSemantic ? engine._jsSemantic.status() : null,
|
|
7449
9257
|
download: engine._jsDownload ? engine._jsDownload.state() : null,
|
|
7450
9258
|
manifestBytes: E5_SMALL_Q8_MANIFEST_V1.totalBytes,
|
|
@@ -7484,6 +9292,19 @@ export function apply(ctx, config) {
|
|
|
7484
9292
|
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
7485
9293
|
},
|
|
7486
9294
|
},
|
|
9295
|
+
{
|
|
9296
|
+
kind: 'exact',
|
|
9297
|
+
path: API['kanban-board'],
|
|
9298
|
+
handler: async (req, res) => {
|
|
9299
|
+
// 白板看板载荷(2026-09-16 兼并 dsh-graph;GET 只读;loopback):
|
|
9300
|
+
// 投影自己的 handoff/index.json 为列式泳道;legacy 档返回 { enabled:false }。
|
|
9301
|
+
if (!isLoopbackRequest(req)) return writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
9302
|
+
try {
|
|
9303
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
9304
|
+
return writeJson(res, 200, await engine.kanbanBoardData(url.searchParams.get('sessionId')))
|
|
9305
|
+
} catch (e) { return writeJson(res, 500, { error: String(e && e.message ? e.message : e) }) }
|
|
9306
|
+
},
|
|
9307
|
+
},
|
|
7487
9308
|
{
|
|
7488
9309
|
kind: 'exact',
|
|
7489
9310
|
path: API['handoff-continue'],
|
|
@@ -7810,7 +9631,10 @@ export function apply(ctx, config) {
|
|
|
7810
9631
|
else if (action === 'activate') r = procs.activate(pid)
|
|
7811
9632
|
else if (action === 'deprecate') r = procs.deprecate(pid, 'user-disabled')
|
|
7812
9633
|
else r = procs.setPinned(pid, (body && body.v) !== false)
|
|
7813
|
-
|
|
9634
|
+
// issue #30:旧日志只记 `r.ok` —— 而 promote 的"拒绝晋升"也是 ok:true(decision='keep'),
|
|
9635
|
+
// 于是日志里全是 ok:true 的假阳性,看不出到底晋升了没有。改为记录 decision + reasonCodes,
|
|
9636
|
+
// 让"为什么没晋升"(如 observation-only / diversity-below-3)在诊断日志里直接可见。
|
|
9637
|
+
try { diag('hub review: ' + action + ' ' + pid.slice(0, 20) + ' → ' + JSON.stringify({ ok: r.ok, decision: r.decision, reason: r.reason, reasonCodes: r.reasonCodes })) } catch (_) {}
|
|
7814
9638
|
return writeJson(res, 200, r)
|
|
7815
9639
|
}
|
|
7816
9640
|
return writeJson(res, 400, { error: 'unknown action' })
|
|
@@ -8098,6 +9922,14 @@ export function apply(ctx, config) {
|
|
|
8098
9922
|
for (const key of allowed) if (body[key] !== undefined) {
|
|
8099
9923
|
// semanticEngineMode 枚举门:非法值直接丢弃(fail-closed,不落盘)
|
|
8100
9924
|
if (key === 'semanticEngineMode' && !['auto', 'lexical', 'js', 'python'].includes(body[key])) continue
|
|
9925
|
+
// 群反馈第 4 条:排除来源必须是「非空字符串数组」—— 否则 {}/字符串/含非字符串元素的数组
|
|
9926
|
+
// 会在注入侧被静默转成空排除集(配了却不起作用,且无从察觉) ⇒ 非法值一律丢弃。
|
|
9927
|
+
if (key === 'injectExcludeSources') {
|
|
9928
|
+
if (!Array.isArray(body[key])) continue
|
|
9929
|
+
if (body[key].some((x) => typeof x !== 'string')) continue
|
|
9930
|
+
patch[key] = body[key].map((x) => x.trim()).filter(Boolean)
|
|
9931
|
+
continue
|
|
9932
|
+
}
|
|
8101
9933
|
patch[key] = body[key]
|
|
8102
9934
|
}
|
|
8103
9935
|
const saved = await engine.saveConfig(patch)
|
|
@@ -8346,8 +10178,10 @@ export function apply(ctx, config) {
|
|
|
8346
10178
|
} catch (e) {}
|
|
8347
10179
|
})()
|
|
8348
10180
|
}, 5 * 60 * 1000)
|
|
10181
|
+
damUnrefTimer(retryTimer)
|
|
8349
10182
|
// 心跳独立 15 秒一次(对齐 polling-lease 心跳节奏),证明轮询机制活着
|
|
8350
10183
|
const heartbeatTimer = setInterval(() => { void writeHeartbeat(); try { engine.tickTime() } catch (e) {}; try { void engine.subagentGcSweep() } catch (e) {}; try { void engine.tickAutoContinue() } catch (e) {} }, 15000)
|
|
10184
|
+
damUnrefTimer(heartbeatTimer)
|
|
8351
10185
|
void writeHeartbeat() // 立即心跳一次:重启后马上可见轮询存活
|
|
8352
10186
|
|
|
8353
10187
|
// ---------- 注册与清理 ----------
|