@x-otto/memory 0.0.1-alpha.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["log","logger","log","defaultEstimateTokens","logger","defaultEstimateTokens","logger","logger","defaultEstimateTokens","CONFLICT_RING_SIZE","logger"],"sources":["../src/prompts.ts","../src/persistent-memory.ts","../src/token-estimator.ts","../src/constants.ts","../src/defaults.ts","../src/prune.ts","../src/compaction.ts","../src/archive.ts","../src/memory-manager.ts","../src/http-memory-store.ts","../src/overlay-memory-store.ts","../src/agents-discovery.ts","../src/auto-memory.ts","../src/memory-extractor.ts","../src/auto-extract.ts","../src/file-state-snapshot.ts","../src/operational-learning.ts"],"sourcesContent":["export const SUMMARIZATION_PROMPT = `You are a conversation summarizer for an AI coding assistant. Your task is to create a structured summary of the conversation that preserves all information needed to continue the work.\n\nCreate the summary in the following format:\n\n## Goal\n[What is the user trying to accomplish?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements the user mentioned]\n\n## All User Requests\n- [List EVERY distinct request/instruction the user made, in order, that is not a tool result. This is the authoritative record of user intent — quote the user's own wording; do not paraphrase away specifics.]\n\n## Progress\n### Done\n- [x] [Completed work items]\n### In Progress\n- [ ] [Work in progress]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [Planned next steps. For the immediate next action, include a VERBATIM quote of the most recent user message or your own last stated intent it derives from — anchors the task, prevents drift.]\n\n## File Operations\n### Read\n- [Files that were read]\n### Modified\n- [Files that were modified]\n\n## Critical Context\n- [Any critical context needed to continue the work]\n\nIMPORTANT:\n- Preserve exact file paths, function names, error messages, and code snippets\n- Keep technical details precise — do not generalize\n- Include all tool call results that affect the current state\n- Note any pending or failed operations`\n\nexport const UPDATE_SUMMARIZATION_PROMPT = `You are updating an existing conversation summary with new information. The existing summary and new messages are provided below.\n\nProduce a single UPDATED summary that stays BOUNDED — a summary that grows without limit across rounds eventually gets hard-truncated by the output cap, silently losing whatever fell past the limit. Keep it tight by actively triaging, not by appending.\n\nKeep (never drop):\n- Every distinct user request/instruction (the \"All User Requests\" record is authoritative)\n- Unfinished, in-progress, or blocked work\n- Decisions not yet verified, and anything the user corrected you on\n- Exact file paths, function names, error messages, code snippets still in play\n\nDrop / compress (make room):\n- Work that is both completed AND verified — collapse to a one-line outcome\n- Superseded intermediate attempts and abandoned approaches\n- Redundant restatements already captured elsewhere in the summary\n\nRules:\n- Move completed \"In Progress\" items to \"Done\", then compress \"Done\" per the above\n- Merge new work items, decisions, and context; do not simply append\n- Keep the same structured format (including \"All User Requests\")\n- If any information conflicts, use the newer version`\n\nexport const TURN_PREFIX_SUMMARIZATION_PROMPT = `You are summarizing the FIRST PART of an assistant turn that was split during conversation compaction. The remaining part of this turn is still in the conversation.\n\nCreate a brief summary in this format:\n\n## Original Request\n[What did the user ask for in this turn?]\n\n## Early Progress\n- [Key actions completed in the prefix portion]\n\n## Context for Remaining Messages\n- [Information needed to understand the remaining suffix messages]\n\nKeep it concise — this will be prepended to the remaining messages of this turn.`\n\n/**\n * RFC-339 D3:游标感知提示词的三个变体(选择逻辑见 `pickVariant`)。\n *\n * 缺陷背景(RFC-339 §1.3 缺陷 D):`prepareCompaction` 早已算出 `preservedMessages`\n * (切点之后原样保留、会拼在摘要之后一起送回模型的近期消息),但提示词从不告诉模型\n * 这个边界的存在。后果:①模型把保留区内容也概括进摘要 → 重复占位、概括版与原文版\n * 并存易矛盾;②模型无法产出「读懂保留区所需的前置信息」这种面向续作的摘要。\n *\n * - FULL:无保留区(全量折叠,罕见)。等价于原 SUMMARIZATION_PROMPT。\n * - WITH_PRESERVED_TAIL:**主路径**。显式告知其后 N 条原样保留、不要概括它们,\n * 并要求产出「保留区依赖的前置信息」段。\n * - SEGMENT:分批(map-reduce)的 map 阶段单段摘要,显式告知「这是第 i/N 段、不是\n * 全部对话」,禁止输出面向全局的 next-step 判断(那属于 reduce 合并阶段)。\n */\n\n/** 摘要正文的公共结构(三变体共享,避免复述)。 */\nconst SUMMARY_BODY_FORMAT = `## Goal\n[What is the user trying to accomplish?]\n\n## Constraints & Preferences\n- [Any constraints, preferences, or requirements the user mentioned]\n\n## All User Requests\n- [List EVERY distinct request/instruction the user made, in order, that is not a tool result. This is the authoritative record of user intent — multi-round compaction must never let an earlier request silently drop. Quote the user's own wording for each; do not paraphrase away specifics.]\n\n## Progress\n### Done\n- [x] [Completed work items]\n### In Progress\n- [ ] [Work in progress]\n\n## Key Decisions\n- **[Decision]**: [Brief rationale]\n\n## Next Steps\n1. [Planned next steps. For the immediate next action, include a VERBATIM quote of the most recent user message or your own last stated intent that it derives from — this anchors the task and prevents interpretation drift across compactions.]\n\n## File Operations\n### Read\n- [Files that were read]\n### Modified\n- [Files that were modified]\n\n## Critical Context\n- [Any critical context needed to continue the work]`\n\nconst SUMMARY_FIDELITY_RULES = `IMPORTANT:\n- Preserve exact file paths, function names, error messages, and code snippets\n- Keep technical details precise — do not generalize\n- Include all tool call results that affect the current state\n- Note any pending or failed operations`\n\nexport const WITH_PRESERVED_TAIL_PROMPT = `You are a conversation summarizer for an AI coding assistant. You are summarizing the EARLIER portion of a conversation. The most recent messages are NOT shown to you here — they are kept verbatim and will follow your summary unchanged.\n\nYour summary and those preserved recent messages together become the assistant's full context going forward.\n\nCreate the summary in the following format:\n\n${SUMMARY_BODY_FORMAT}\n\n## Context the Preserved Tail Depends On\n- [Facts a reader MUST know to correctly understand the preserved recent messages that follow: decisions already made, file paths and their current state, conventions/constraints agreed earlier, mistakes already corrected, and any partially-done work the recent messages continue. This section is the whole point of this summary — be specific.]\n\n${SUMMARY_FIDELITY_RULES}\n- Do NOT re-summarize the preserved recent messages themselves — they are kept verbatim after your summary. Summarize only the EARLIER conversation and surface what the tail depends on.`\n\nexport const SEGMENT_SUMMARIZATION_PROMPT = `You are summarizing ONE SEGMENT of a longer conversation for an AI coding assistant. This is a partial view — you are NOT seeing the whole conversation, and other segments are being summarized separately. Your segment summary will later be merged with the others.\n\nCreate a concise summary of THIS SEGMENT in the following format:\n\n${SUMMARY_BODY_FORMAT}\n\n${SUMMARY_FIDELITY_RULES}\n- Do NOT emit conclusions that require seeing the whole conversation (e.g. a single global \"next step\"). Report only what THIS segment establishes; the merge step will reconcile across segments.`\n\nexport const SEGMENT_MERGE_PROMPT = `You are merging several segment summaries of one conversation into a single coherent summary for an AI coding assistant. Each input below is a summary of a different (chronological) segment of the same conversation.\n\nReconcile them into one summary in the following format:\n\n${SUMMARY_BODY_FORMAT}\n\n${SUMMARY_FIDELITY_RULES}\n- Resolve conflicts in favour of the LATER segment.\n- Merge duplicate items; do not simply concatenate.`\n\n/**\n * RFC-339 D3:摘要提示词变体标识。调用方据压缩形态选择(见 `compaction.ts`)。\n * - `full`:无保留区的全量折叠(或有旧摘要走 UPDATE 路径,见 buildSummarizationPrompt)。\n * - `preserved-tail`:主路径,切点之后有原样保留的近期消息。\n * - `segment`:map-reduce 的单段摘要。\n * - `merge`:map-reduce 的合并阶段(输入是段摘要,非原始对话)。\n */\nexport type SummarizationVariant = 'full' | 'preserved-tail' | 'segment' | 'merge'\n\n/**\n * RFC-339 D3:切点上下文——让提示词感知「保留区」的存在。\n * `preservedCount` = 切点之后原样保留的消息条数(0 = 全量折叠)。\n */\nexport interface SummarizationContext {\n variant: SummarizationVariant\n preservedCount?: number\n}\n\n/**\n * 构造摘要提示词。RFC-339 D3:新增可选 `context` 参数使提示词感知切点/批次形态;\n * **不传时行为与改造前逐字节等价**(向后兼容——现有测试与未接线调用点不受影响)。\n *\n * 变体优先级:\n * - `context.variant === 'segment'` → SEGMENT_SUMMARIZATION_PROMPT(map 阶段,忽略 previousSummary)\n * - 有 `previousSummary` → UPDATE_SUMMARIZATION_PROMPT(增量更新,最高优先,兼容旧行为)\n * - `context.variant === 'preserved-tail'` → WITH_PRESERVED_TAIL_PROMPT(主路径)\n * - 否则 → SUMMARIZATION_PROMPT(FULL,全量折叠)\n *\n * 注:UPDATE 优先于 preserved-tail 是刻意的——增量补摘(有旧摘要)时,模型的任务是\n * 把新增消息并入旧摘要,UPDATE 模板的「保留旧摘要全部信息」语义比 preserved-tail 更贴切;\n * 保留区提示由 `preservedCount` 仍通过尾注补充(见下)。\n */\nexport function buildSummarizationPrompt(\n messages: string,\n previousSummary?: string,\n customInstructions?: string,\n context?: SummarizationContext,\n): string {\n const parts: string[] = []\n\n if (context?.variant === 'segment') {\n parts.push(SEGMENT_SUMMARIZATION_PROMPT)\n } else if (previousSummary) {\n parts.push(UPDATE_SUMMARIZATION_PROMPT)\n parts.push(`\\n<existing_summary>\\n${previousSummary}\\n</existing_summary>`)\n if (context?.preservedCount && context.preservedCount > 0) {\n parts.push(preservedTailNotice(context.preservedCount))\n }\n } else if (context?.variant === 'preserved-tail') {\n parts.push(WITH_PRESERVED_TAIL_PROMPT)\n } else {\n parts.push(SUMMARIZATION_PROMPT)\n }\n\n if (customInstructions) {\n parts.push(`\\n<additional_instructions>\\n${customInstructions}\\n</additional_instructions>`)\n }\n\n parts.push(`\\n<conversation>\\n${messages}\\n</conversation>`)\n\n return parts.join('\\n')\n}\n\n/** UPDATE 路径下补充「其后有 N 条原样保留」的尾注(WITH_PRESERVED_TAIL 模板已内含此语义)。 */\nfunction preservedTailNotice(preservedCount: number): string {\n return `\\nNOTE: The ${preservedCount} most recent message(s) are kept verbatim and will follow your summary — do NOT re-summarize them; focus on the earlier conversation and what those recent messages depend on.`\n}\n\n/**\n * RFC-339 D3:map-reduce 合并阶段的提示词。输入是各段摘要(非原始对话),\n * 包在 `<segment_summaries>` 标签内——修正改造前 reduce 阶段误用 `<conversation>`\n * 标签(内容类型与标签语义不符)的问题。\n */\nexport function buildSegmentMergePrompt(\n segmentSummaries: string,\n previousSummary?: string,\n customInstructions?: string,\n): string {\n const parts: string[] = [SEGMENT_MERGE_PROMPT]\n\n if (previousSummary) {\n parts.push(`\\n<existing_summary>\\n${previousSummary}\\n</existing_summary>`)\n }\n if (customInstructions) {\n parts.push(`\\n<additional_instructions>\\n${customInstructions}\\n</additional_instructions>`)\n }\n\n parts.push(`\\n<segment_summaries>\\n${segmentSummaries}\\n</segment_summaries>`)\n\n return parts.join('\\n')\n}\n\nexport function buildTurnPrefixPrompt(turnPrefixMessages: string): string {\n return `${TURN_PREFIX_SUMMARIZATION_PROMPT}\\n\\n<turn_prefix>\\n${turnPrefixMessages}\\n</turn_prefix>`\n}\n\n/**\n * `<agent_memory>` 段的默认总预算(tokens,粗估)。\n *\n * 本仓正常场景(AGENTS.md 三源 + AutoMemory 索引)实测 ~3.4k tokens;monorepo\n * `discoverAgentsSources` 理论上限是 10 文件 × 64KB = 640KB,若真的堆到那个量级会把\n * system prompt 撑爆。这里的预算不是替代 discoverAgentsSources 的单文件/文件数上限\n * (那层继续管),而是在全部来源(含它管不到的 writable AGENTS.md / AutoMemory 索引)\n * 汇总之后再把一道总闸——`buildMemoryInjection` 是唯一收口点,别处管不到的这里管。\n *\n * 取 3.4k 实测值的 ~2.3 倍留余量,同时把极端场景压掉 95%+。\n */\nexport const DEFAULT_MEMORY_INJECTION_BUDGET_TOKENS = 8_000\n\n/** 粗粒度 token 估算(对齐 lesson-injection.ts 的 estimatedTokens 同款 CJK 加权口径)。 */\nfunction estimatedTokens(text: string): number {\n let cjk = 0\n for (const ch of text) {\n if (/[\\u3000-\\u9fff\\uff00-\\uffef]/.test(ch)) cjk++\n }\n return Math.ceil(cjk + (text.length - cjk) / 4)\n}\n\n/** 按预估 token 数截断字符串到 code-point 边界(不切断代理对)。 */\nfunction truncateToTokenBudget(text: string, remainingTokens: number): string {\n if (remainingTokens <= 0) return ''\n if (estimatedTokens(text) <= remainingTokens) return text\n\n // `remainingTokens * 4` 对 ASCII 合理,却会把 CJK(约一字一 token)超发四倍。\n // 以与预算记账相同的 estimator 二分收敛,保证 <agent_memory> 总量真受预算约束。\n const chars = Array.from(text)\n let low = 0\n let high = chars.length\n let best = ''\n while (low <= high) {\n const mid = Math.floor((low + high) / 2)\n const candidate = chars.slice(0, mid).join('')\n if (estimatedTokens(candidate) <= remainingTokens) {\n best = candidate\n low = mid + 1\n } else {\n high = mid - 1\n }\n }\n return best\n}\n\nexport function buildMemoryInjection(\n memories: Map<string, string>,\n budgetTokens: number = DEFAULT_MEMORY_INJECTION_BUDGET_TOKENS,\n): string {\n if (memories.size === 0) {\n return ''\n }\n\n const parts: string[] = ['<agent_memory>']\n let usedTokens = 0\n let skippedCount = 0\n\n for (const [path, content] of memories) {\n const trimmed = content.trim()\n if (!trimmed) continue\n\n const remaining = budgetTokens - usedTokens\n if (remaining <= 0) {\n skippedCount++\n continue\n }\n\n const contentTokens = estimatedTokens(trimmed)\n if (contentTokens <= remaining) {\n parts.push(`# ${path}`)\n parts.push(trimmed)\n parts.push('')\n usedTokens += contentTokens\n } else {\n // 超预算:截断而非静默丢弃或跳过——模型至少能看到\"这条被截断\"的显式信号,\n // 不会误把不完整内容当作全部(本仓 RFC-142 上下文裁剪红线的同款纪律:\n // 裁剪必须可见,不伪装成完整数据)。\n const truncated = truncateToTokenBudget(trimmed, remaining)\n parts.push(`# ${path}`)\n parts.push(truncated)\n parts.push(`\\n> ⚠ [truncated — content exceeded the memory injection budget (${budgetTokens} tokens); remainder omitted]`)\n parts.push('')\n usedTokens = budgetTokens\n }\n }\n\n if (skippedCount > 0) {\n parts.push(\n `> ⚠ [${skippedCount} additional memory source(s) omitted — total injection budget (${budgetTokens} tokens) reached]`,\n )\n parts.push('')\n }\n\n parts.push('</agent_memory>')\n\n parts.push('')\n parts.push(`<memory_guidelines>\nWhen you learn something from this interaction that should be remembered, use the memory_record tool — never edit/write .otto state files directly (they are protected; see RFC-228). Choose scope:\n- 'workspace' (default): durable structured facts portable across machines (preferences, gotchas, decisions).\n- 'local': machine-specific facts that should never sync to other devices (local paths, installed plugins, environment quirks).\n- 'project-shared': team-visible project conventions that belong in AGENTS.md (build/test commands, code style, architecture decisions all contributors should see).\n\n**Should remember**:\n- User-specified preferences and constraints\n- Patterns learned from feedback (code style, architecture preferences)\n- Key tool parameters (API endpoints, non-sensitive config)\n- Project-specific build/test commands\n\n**Should NOT remember**:\n- Temporary task details\n- One-off Q&A\n- API keys, passwords, or sensitive information\n- Casual conversation\n</memory_guidelines>`)\n\n return parts.join('\\n')\n}\n\n/**\n * 跨会话经验抽取提示词。要求模型从一段会话切片里提炼可复用的「经验」\n * (trigger=何时适用 / insight=怎么做 / tags=召回关键词),严格输出 JSON 数组。\n * 内容边界(D7/R3)对齐 `buildMemoryInjection` 的 should/should-NOT remember——不存可从\n * repo/git/AGENTS.md 推导的事实、不存 ephemeral 任务态、不存密钥。无可存则输出 []。\n */\nexport const LESSON_EXTRACTION_PROMPT = `You are a learning extractor for an AI coding assistant. Read the conversation below and extract durable, cross-session \"lessons\" — operational knowledge that will help the assistant do better on FUTURE, unrelated sessions in this project.\n\nA lesson captures a reusable cause→action:\n- \"trigger\": the situation/context that should recall this lesson (when it applies)\n- \"insight\": the actionable guidance learned (what to do)\n- \"tags\": 1-5 lowercase keywords for retrieval\n\nSAVE these (durable, reusable):\n- A user correction that reveals a lasting preference (\"user prefers X over Y\")\n- A non-obvious gotcha discovered the hard way (\"running Z without flag W silently fails\")\n- A project-specific workflow or command that isn't written down elsewhere\n- A constraint or convention the user insisted on\n\nDO NOT save these (they pollute recall):\n- Anything derivable from the repo, git history, or AGENTS.md/README — do not restate the obvious\n- Ephemeral task state (\"currently editing foo.ts\") — lessons must outlive this session\n- One-off Q&A, casual conversation, or a summary of what happened\n- Secrets, API keys, passwords, tokens, or any sensitive value\n\nOutput ONLY a JSON array, nothing else. Each element: {\"trigger\": string, \"insight\": string, \"tags\": string[]}.\nIf there is nothing durable worth saving, output exactly: []`\n\nexport function buildExtractionPrompt(conversationText: string): string {\n return `${LESSON_EXTRACTION_PROMPT}\\n\\n<conversation>\\n${conversationText}\\n</conversation>`\n}\n\n/**\n * RFC-340 D2 方案 B(M2 兑现):压缩摘要的上下文引用。\n *\n * 演进史(两步,勿回退):\n * - **原始文案**承诺 \"can be accessed via the **read** tool\"——断头路:通用 read 工具\n * 不认识 `archive:` 前缀,`MemoryManager.readArchive()` 零工具暴露,模型照做只会\n * 拿到\"文件不存在\"。\n * - **M1** 据\"不做无法兑现的承诺\"删掉可读表述,只留溯源标识。\n * - **M2(本版)** 把召回能力真正做出来(`memory_archive_read`),故把承诺加回——\n * 但指向**具体工具名**而非泛泛的 \"read tool\",这正是原文案出错的地方。\n *\n * 工具可能未注册(`archiveRecall` 能力未注入,如 V 形态/无持久化会话)。此时文案仍\n * 提到该工具是可接受的:模型调用不存在的工具会得到明确的 unknown-tool 反馈,\n * 比\"以为取不回而不去尝试\"损失更小;且未注册场景本身就是少数派配置。\n */\nexport function buildArchiveReference(archivePath: string, summary: string): string {\n return `Earlier conversation history has been compacted into the summary below. If you need details the summary does not cover, call the memory_archive_read tool with archive id \"${archivePath}\" to pull back the original messages.\n\n<summary>\n${summary}\n</summary>`\n}\n\n/**\n * RFC-340 D1:低覆盖摘要的前置标注文本。\n *\n * 目的是**如实告知而非静默降级**:模型据此知道这段摘要并未覆盖全部历史,遇到摘要里\n * 没有的细节应当向用户确认,而不是当作\"历史里就没发生过\"。\n */\nexport function buildLowCoverageNotice(coverage: number): string {\n const pct = Math.max(0, Math.round(coverage * 100))\n return `[Low-fidelity summary — only about ${pct}% of the original history could be fed into summarization. Significant details are missing. Treat this summary as incomplete: if you need details it does not cover, call memory_archive_read with the archive id below to pull back the original messages, rather than guessing or asking the user to repeat themselves.]`\n}\n","import { createLogger } from '@x-otto/shared'\nimport { buildMemoryInjection } from './prompts'\n\nimport type { MemorySource, MemoryStore } from './types'\n\nconst log = createLogger('@x-otto/memory:persistent')\n\nexport const DEFAULT_MEMORY_SOURCES: MemorySource[] = [\n { path: '~/.otto/AGENTS.md', writable: true },\n { path: './.otto/AGENTS.md', writable: true },\n { path: './AGENTS.md', writable: false },\n]\n\nexport class PersistentMemory {\n private memories = new Map<string, string>()\n private unwatch?: () => void\n\n constructor(\n private readonly store: MemoryStore,\n private readonly sources: MemorySource[] = DEFAULT_MEMORY_SOURCES,\n ) {}\n\n async load(): Promise<void> {\n this.memories = await this.store.load(this.sources)\n log.info(`Loaded ${this.memories.size} memory source(s)`)\n }\n\n async reload(): Promise<void> {\n await this.load()\n }\n\n getInjection(): string {\n return buildMemoryInjection(this.memories)\n }\n\n getMemories(): ReadonlyMap<string, string> {\n return this.memories\n }\n\n /**\n * 写入可写源(M13-03):让 MemorySource.writable 标记成真。\n * 仅 sources 中声明且 writable: true 的路径可写;写后同步内存缓存(无需全量 reload)。\n */\n async write(sourcePath: string, content: string): Promise<void> {\n const source = this.sources.find((candidate) => candidate.path === sourcePath)\n if (!source) {\n throw new Error(`Unknown memory source: ${sourcePath}`)\n }\n if (!source.writable) {\n throw new Error(`Memory source is read-only: ${sourcePath}`)\n }\n\n await this.store.write(sourcePath, content)\n this.memories.set(sourcePath, content)\n log.info(`Memory source written: ${sourcePath}`)\n }\n\n watching(): void {\n if (this.unwatch || !this.store.watch) {\n return\n }\n this.unwatch = this.store.watch(this.sources, (path) => {\n log.info(`Memory source changed: ${path}, reloading...`)\n void this.reload()\n })\n }\n\n stop(): void {\n this.unwatch?.()\n this.unwatch = undefined\n }\n\n dispose(): void {\n this.stop()\n this.memories.clear()\n }\n}\n\nexport class InMemoryMemoryStore implements MemoryStore {\n private data = new Map<string, string>()\n\n set(path: string, content: string): void {\n this.data.set(path, content)\n }\n\n async load(sources: MemorySource[]): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n for (const source of sources) {\n const content = this.data.get(source.path)\n if (content !== undefined) {\n result.set(source.path, content)\n }\n }\n return result\n }\n\n async write(path: string, content: string): Promise<void> {\n this.data.set(path, content)\n }\n}\n\nexport class FileSystemMemoryStore implements MemoryStore {\n private cwd: string\n /**\n * `~/` 前缀路径展开的用户主目录 override(缺省调用真实 `os.homedir()`)。\n *\n * **测试隔离缺陷修复**(终局 memory 审计发现的持续污染源):此前 `resolvePath()` 硬编码\n * 调用 `homedir()`,完全忽略构造函数传入的 `cwd`——测试传 `localDir: TEST_DIR` 只对不带\n * `~/` 前缀的相对路径生效,但 `DEFAULT_MEMORY_SOURCES`/测试用例大量使用 `~/.otto/AGENTS.md`\n * 这类带 `~/` 前缀的路径,一律写穿到真实用户主目录(`http-memory-store.test.ts` 的\n * \"remote user\"/\"memory index content\" 等 fixture 字符串因此持续覆盖真实 `~/.otto/AGENTS.md`)。\n * 生产环境不传时行为完全不变(仍用真实 `homedir()`),仅测试可注入隔离目录。\n */\n private readonly homeDirOverride?: string\n\n constructor(cwd = process.cwd(), homeDirOverride?: string) {\n this.cwd = cwd\n this.homeDirOverride = homeDirOverride\n }\n\n async load(sources: MemorySource[]): Promise<Map<string, string>> {\n const { readFile } = await import('node:fs/promises')\n const { resolve, isAbsolute } = await import('node:path')\n const { homedir } = await import('node:os')\n\n const result = new Map<string, string>()\n\n for (const source of sources) {\n const resolvedPath = this.resolvePath(source.path, resolve, isAbsolute, homedir)\n try {\n const content = await readFile(resolvedPath, 'utf-8')\n result.set(source.path, content)\n } catch {\n }\n }\n\n return result\n }\n\n async write(path: string, content: string): Promise<void> {\n const { mkdir } = await import('node:fs/promises')\n const { resolve, dirname, isAbsolute } = await import('node:path')\n const { homedir } = await import('node:os')\n const { atomicWriteFile } = await import('@x-otto/persistence')\n\n const resolvedPath = this.resolvePath(path, resolve, isAbsolute, homedir)\n await mkdir(dirname(resolvedPath), { recursive: true })\n await atomicWriteFile(resolvedPath, content)\n }\n\n private resolvePath(\n filePath: string,\n resolve: (base: string, ...paths: string[]) => string,\n isAbsolute: (p: string) => boolean,\n homedir: () => string,\n ): string {\n const resolveHome = this.homeDirOverride ?? homedir()\n const expanded = filePath.startsWith('~/') ? filePath.replace('~/', `${resolveHome}/`) : filePath\n\n return isAbsolute(expanded) ? expanded : resolve(this.cwd, expanded)\n }\n}\n\n","import { createLogger } from '@x-otto/shared'\nimport type { Message, AssistantMessage } from '@x-otto/ai'\nimport type { ContextTokenEstimate } from './types'\n\nconst logger = createLogger('@x-otto/memory:token-estimator')\n\nlet _tiktoken: { encode: (text: string) => number[] } | null = null\nlet _tiktokenLoading: Promise<void> | null = null\n\n/**\n * 确保 tokenizer 已加载(异步)。多次调用安全。\n * 可在应用启动时调用一次,避免首次估算时的加载延迟。\n */\nexport async function ensureTokenizer(): Promise<void> {\n if (_tiktoken) return\n if (_tiktokenLoading) {\n // 修复:此前在途加载直接 return(不等待),调用方 await 后 tokenizer 仍可能未就绪。\n await _tiktokenLoading\n return\n }\n\n _tiktokenLoading = (async () => {\n try {\n const { getEncoding } = await import('js-tiktoken')\n const enc = getEncoding('cl100k_base')\n _tiktoken = {\n encode: (text: string) => {\n if (typeof text !== 'string' || text.length === 0) return []\n return enc.encode(text)\n },\n }\n logger.info('Tokenizer loaded: cl100k_base')\n } catch (err) {\n logger.warn(\n 'Failed to load js-tiktoken, falling back to heuristic: %s',\n (err as Error).message,\n )\n _tiktoken = null\n } finally {\n _tiktokenLoading = null\n }\n })()\n\n await _tiktokenLoading\n}\n\nconst CJK_TOKENS_PER_CHAR = 1\nconst DEFAULT_TOKENS_PER_CHAR = 0.25\nconst ASCII_SYMBOL_TOKENS_PER_CHAR = 0.5\n/**\n * 图片 block 的固定 token 估算值(非精确计算,刻意保守)。\n *\n * Anthropic 官方公式:tokens = (width px * height px) / 750,图片被 resize 到最大\n * 2000x2000px 上限时对应约 5333 tokens。本估算刻意采用远低于该上限的保守值,\n * 宁可估算偏大触发提前压缩,也不要估算偏小导致压缩滞后(沿用\n * IMAGE_MAX_TOKEN_SIZE=2000 的保守设计)。\n *\n * 注意:该常量上调会通过 estimateMessageTokens → needsCompaction 双阈值判断\n * 使含图会话的压缩触发时机提前,这是预期效果而非副作用。\n */\nexport const IMAGE_BLOCK_TOKEN_ESTIMATE = 2000\n\nfunction isAsciiSymbol(cp: number): boolean {\n return (\n (cp >= 0x21 && cp <= 0x2f) ||\n (cp >= 0x3a && cp <= 0x40) ||\n (cp >= 0x5b && cp <= 0x60) ||\n (cp >= 0x7b && cp <= 0x7e)\n )\n}\n\nfunction isCjkCodePoint(cp: number): boolean {\n return (\n (cp >= 0x3040 && cp <= 0x30ff) ||\n (cp >= 0x3400 && cp <= 0x4dbf) ||\n (cp >= 0x4e00 && cp <= 0x9fff) ||\n (cp >= 0xf900 && cp <= 0xfaff) ||\n (cp >= 0xac00 && cp <= 0xd7a3) ||\n (cp >= 0xff00 && cp <= 0xffef) ||\n (cp >= 0x20000 && cp <= 0x2ebef)\n )\n}\n\nexport function estimateTokensHeuristic(text: string): number {\n let cjkCount = 0\n let symbolCount = 0\n let otherCount = 0\n\n for (const ch of text) {\n const cp = ch.codePointAt(0) ?? 0\n if (isCjkCodePoint(cp)) {\n cjkCount += 1\n } else if (isAsciiSymbol(cp)) {\n symbolCount += 1\n } else {\n otherCount += 1\n }\n }\n\n return Math.ceil(\n cjkCount * CJK_TOKENS_PER_CHAR +\n symbolCount * ASCII_SYMBOL_TOKENS_PER_CHAR +\n otherCount * DEFAULT_TOKENS_PER_CHAR,\n )\n}\n\n/**\n * js-tiktoken 病理输入护栏(TUI 卡顿钉问,2026-07-03 Node bench 定界):\n * BPE merge 循环对「同字符长 run」是 O(run²)——'='.repeat(2048) 单串 encode 实测 286ms,\n * 表格分隔线('─'×120 行)密集的工具输出实测单次全量扫描 1.6s+。这类内容在真实\n * 工具结果(bash 表格、分隔线、box-drawing)里极常见。run ≥64 或超长文本一律走\n * 启发式(符号 0.5/char 偏大估算 → 压缩提前触发,安全方向)。\n * 阈值按字符数而 BPE 按字节跑——box-drawing('─' 3 字节/char)64 字符 = 192 字节 run,\n * 已在病理区,故取 64 而非 128。\n */\nconst TIKTOKEN_MAX_RUN = 64\nconst TIKTOKEN_MAX_CHARS = 100_000\n\nfunction hasLongCharRun(text: string, maxRun: number): boolean {\n let run = 1\n for (let i = 1; i < text.length; i++) {\n if (text.charCodeAt(i) === text.charCodeAt(i - 1)) {\n run++\n if (run >= maxRun) return true\n } else {\n run = 1\n }\n }\n return false\n}\n\n/**\n * 估算文本的 token 数。\n * 优先使用 js-tiktoken(cl100k_base),未加载时回落启发式。\n * 病理输入(同字符长 run / 超长文本)直接走启发式,避免 O(n²) BPE 阻塞主线程。\n */\nexport function estimateTokens(text: string): number {\n if (_tiktoken) {\n if (text.length > TIKTOKEN_MAX_CHARS || hasLongCharRun(text, TIKTOKEN_MAX_RUN)) {\n return estimateTokensHeuristic(text)\n }\n try {\n return _tiktoken.encode(text).length\n } catch {\n return estimateTokensHeuristic(text)\n }\n }\n\n if (!_tiktokenLoading) {\n ensureTokenizer().catch(() => {})\n }\n\n return estimateTokensHeuristic(text)\n}\n\n/**\n * RFC-343:toolCallId 配对锚的短表示。取末 6 位——在一次压缩窗口内足以唯一区分\n * call↔result,避免整段冗长 id 稀释摘要。空 id 回退 '?'(不产错误锚)。\n */\nexport function shortToolId(id: string | undefined): string {\n if (!id) return '?'\n return id.length <= 6 ? id : id.slice(-6)\n}\n\nexport function messageToText(message: Message): string {\n if (typeof message.content === 'string') {\n return message.content\n }\n if (Array.isArray(message.content)) {\n return message.content\n .map((c) => {\n switch (c.type) {\n case 'text':\n return c.text\n case 'thinking':\n return c.text\n case 'tool_call':\n // RFC-343:加 toolCallId 配对锚(行内纯文本,非 JSON 元数据键——RFC-142 R1)。\n // 短 id 取末 6 位即可在一次压缩窗口内唯一区分 call↔result(full id 冗长)。\n return `[call#${shortToolId(c.id)} ${c.name}](${JSON.stringify(c.arguments)})`\n case 'image':\n return '[image]'\n default:\n return ''\n }\n })\n .filter(Boolean)\n .join('\\n')\n }\n\n return JSON.stringify(message)\n}\n\n/**\n * per-message token 估算缓存(TUI 卡顿钉问,2026-07-03):\n * needsPrune/needsCompaction 每个工具回合各做一次全量消息扫描,长会话(150 条/300KB)\n * 单次扫描实测 ~32ms、每回合 2-3 次 = 每工具回合 ~100ms 主线程阻塞。消息对象 append 后\n * 不可变(prune/compaction 均以 spread 新建对象),WeakMap 按对象引用缓存天然失效正确。\n * 键含 estimator 维度:默认 estimator 才缓存(自定义 estimator 直接旁路,避免混淆)。\n */\nconst messageTokenCache = new WeakMap<object, number>()\n\nexport function estimateMessageTokens(message: Message, estimator = estimateTokens): number {\n // 仅当 tokenizer 已就绪才缓存:懒加载完成前 estimateTokens 走启发式,加载后行为切换,\n // 提前缓存会把启发式值冻结成永久值。\n const cacheable =\n estimator === estimateTokens &&\n _tiktoken !== null &&\n typeof message === 'object' &&\n message !== null\n if (cacheable) {\n const hit = messageTokenCache.get(message)\n if (hit !== undefined) return hit\n }\n const roleOverhead = 4\n let total: number\n if (Array.isArray(message.content)) {\n total = roleOverhead\n for (const c of message.content) {\n switch (c.type) {\n case 'text':\n case 'thinking':\n total += estimator(c.text)\n break\n case 'tool_call':\n total += estimator(`${c.name}(${JSON.stringify(c.arguments)})`)\n break\n case 'image':\n total += IMAGE_BLOCK_TOKEN_ESTIMATE\n break\n default:\n break\n }\n }\n } else {\n total = roleOverhead + estimator(messageToText(message))\n }\n if (cacheable) messageTokenCache.set(message, total)\n return total\n}\n\nexport function estimateMessagesTokens(\n messages: readonly Message[],\n estimator = estimateTokens,\n): number {\n let total = 0\n for (const msg of messages) {\n total += estimateMessageTokens(msg, estimator)\n }\n return total\n}\n\nfunction isAssistantMessage(msg: Message): msg is AssistantMessage {\n return msg.role === 'assistant'\n}\n\nexport function estimateContextTokens(\n messages: readonly Message[],\n estimator = estimateTokens,\n): ContextTokenEstimate {\n let lastUsageIndex = -1\n let fromUsage = 0\n\n for (let i = messages.length - 1; i >= 0; i--) {\n const msg = messages[i]!\n if (isAssistantMessage(msg) && msg.usage) {\n lastUsageIndex = i\n\n fromUsage =\n (msg.usage.cacheReadTokens ?? 0) +\n (msg.usage.cacheWriteTokens ?? 0) +\n msg.usage.inputTokens +\n msg.usage.outputTokens\n break\n }\n }\n\n let fromEstimate = 0\n let headEstimate = 0\n if (lastUsageIndex >= 0) {\n for (let i = 0; i <= lastUsageIndex; i++) {\n headEstimate += estimateMessageTokens(messages[i]!, estimator)\n }\n for (let i = lastUsageIndex + 1; i < messages.length; i++) {\n fromEstimate += estimateMessageTokens(messages[i]!, estimator)\n }\n } else {\n fromEstimate = estimateMessagesTokens(messages, estimator)\n }\n\n const total = fromUsage + fromEstimate\n\n // RFC-066 M1-03(BodyAfterPrefix scope):usage 计入了系统前缀(system prompt + tool schemas +\n // memory 注入)——它们不在 messages 数组里(provider 层单独注入)。稳定前缀 = usage 总量减去\n // usage 覆盖区间内消息的纯估算(= 那段会话 body)。差额即\"usage 见过、消息体里没有\"的固定前缀。\n // 触发只数前缀之后的 body 增长,使大的稳定缓存前缀不把压缩提前顶过阈值(减压缩频次 = provider\n // 无关的缓存收益)。无 usage 时前缀 = 0(messages 本就不含前缀,纯估算即 body)。\n const derivedPrefix = lastUsageIndex >= 0 ? Math.max(0, fromUsage - headEstimate) : 0\n const bodyAfterPrefix = Math.max(0, total - derivedPrefix)\n\n return {\n total,\n fromUsage,\n fromEstimate,\n lastUsageIndex,\n derivedPrefix,\n bodyAfterPrefix,\n }\n}\n","/**\n * 记忆/上下文域常量(RFC-074 M8-06b / R-ENVCONST)。\n *\n * 从 @x-otto/env 的 god-constants 桶迁回 memory——压缩/裁剪调优阈值 + 工具名分类常量的唯一\n * 消费方就是 memory 包自身(defaults.ts / compaction.ts)。env 只保留跨切面环境/路径配置。\n * 多数为字面量;少数运维需现场调优的阈值(如压缩硬顶比例,RFC-066 增量修复)经 @x-otto/env\n * 的 normalizeEnv/normalizeEnvFraction 支持 env 覆盖,默认值仍在此处(单一真源)。\n * 注:MEMORY_ARCHIVE_SNAPSHOT_VERSION 全仓零消费,随迁移按 §7.4 删除。\n */\nimport { normalizeEnvFraction } from '@x-otto/env'\n\n// —— 压缩(compaction)调优 ——\nexport const MEMORY_COMPACTION_KEEP_RECENT_FRACTION = 0.1\nexport const MEMORY_COMPACTION_RESERVE_TOKENS = 16384\n\n/**\n * 压缩硬顶占上下文窗口的比例(RFC-066 M1-03 增量修复,事故 f9bab4c0 定位)。\n *\n * needsCompaction 的 total 口径硬顶兜底 = window × 本比例。此前硬顶为 window − reserveTokens\n * (16384 ≈ 98.4%),距窗口上限仅 ~16K 余量——实测系统前缀 ≥40K 时 bodyAfterPrefix 软触发失效\n * (要求 total 超过硬顶),硬顶成为唯一有效防线却贴着窗口,一个大工具结果即可从\"未触发\"直冲\n * 真实溢出(413)。会话 f9bab4c0 的 954K/1M 正落在 954K〜983K 死区徘徊数十轮不压缩。\n * 下调到 0.90 留 10% 溢出余量(1M→100K,足够吸收单个大工具结果)。真实场景软触发本已失效,\n * 只是让唯一有效的硬顶从 98.4% 前移到 90%,缓存经济损失可忽略。\n *\n * 三层可配置(终局设计):env `OTTO_MEMORY_COMPACTION_HARD_CEILING_FRACTION` 覆盖本默认;\n * CompactionConfig.hardCeilingFraction 提供 config/api option 层覆盖(不设则用本默认)。\n */\nexport const MEMORY_COMPACTION_HARD_CEILING_FRACTION = normalizeEnvFraction(\n process.env['OTTO_MEMORY_COMPACTION_HARD_CEILING_FRACTION'],\n 0.9,\n)\n\n/** 有损压缩触发的「回合留白」。越过触发到下次压缩之间还能再过一个 model-turn,\n * 留白须 ≥ 一个最胖回合(胖文件 read/测试日志/大 grep ≈ 常量,与窗口无关)。与 RESERVE_TOKENS\n * (输出预留)合成绝对 headroom = window − reserve − turnHeadroom,自适应窗口大小。 */\nexport const MEMORY_COMPACTION_TURN_HEADROOM = 24_000\n/** headroom 触发的安全下界(占窗口比例)。窗口过小(< reserve+headroom)致\n * window−headroom≤0 时兜底,保证阈值不塌到 0/负。正常窗口(≥128K)不绑定。 */\nexport const MEMORY_COMPACTION_MIN_TRIGGER_FRACTION = 0.5\n\n/**\n * needsCompaction 的**字节触发因子**安全系数(RFC-178 触发器 B 软段)。\n *\n * 估算字节 >= maxContentBytes * 本系数时提前强制压缩,确保存储层的字节收紧\n * (`capStoredHistoryByBytes`,由 doSave/在线评估触发)运行时压缩边界必然已存在——\n * 裁掉的是边界前死历史而非模型仍需要的活跃上下文。\n *\n * **RFC-321 D2/G1 更名说明**:本常量原名 `MEMORY_HISTORY_CAP_TRIGGER_FRACTION`,曾同时\n * 服务条数与字节两个触发因子。条数触发因子已于 RFC-321 M2 删除(死区空转 + 口径失配 +\n * 维度越界,详见 memory-manager.ts needsCompaction 内注释),故更名为 RESIDENCY 以准确\n * 反映其唯一职责:驻留(字节)维度的提前压缩安全系数。**禁止再把它用于任何条数判定**\n * (RFC-321 R9)。\n *\n * 时序说明(沿用原推导):压缩在 needsCompaction 判定的同一回合内同步完成(见\n * context-pipeline.ts 的 needsCompaction→process 调用序,中间无让出点);真正消耗这 10%\n * 余量的窗口是\"上一次 doSave 完成\"到\"下一回合判定并跑完压缩\"之间,覆盖同一回合内工具\n * 循环产生的新增消息。压缩失败时 needsCompaction 下一回合会再次返回 true(不消耗触发\n * 信号,见 memory-manager.ts process() 的 catch 分支)。\n */\nexport const MEMORY_RESIDENCY_TRIGGER_FRACTION = 0.9\n\n// —— 裁剪(prune)调优 ——\n/** prune 触发阈值(上下文窗口占比)。0.6 = 在 60% 窗口时开始无损裁剪,比 0.7 更早介入,\n * 给 compact 前的两次 prune 更宽裕的窗口(60%→85% 有 25% 缓冲 vs 旧 15%),减少 LLM 摘要调用。 */\nexport const MEMORY_PRUNE_TRIGGER_FRACTION = 0.6\nexport const MEMORY_PRUNE_PROTECT_FRACTION = 0.15\nexport const MEMORY_PRUNE_MINIMUM_TOKENS = 20000\nexport const MEMORY_PRUNE_TRUNCATE_MAX_LENGTH = 2000\n\n// —— 工具名分类(写类 / 读类 / legacy 别名)——\nexport const MEMORY_TOOL_WRITE = 'write'\nexport const MEMORY_TOOL_EDIT = 'edit'\n\nexport const MEMORY_TOOL_READ = 'read'\nexport const MEMORY_TOOL_GREP = 'grep'\nexport const MEMORY_TOOL_FIND = 'find'\nexport const MEMORY_LEGACY_TOOL_READ_FILE = 'read_file'\nexport const MEMORY_LEGACY_TOOL_GREP_SEARCH = 'grep_search'\nexport const MEMORY_LEGACY_TOOL_FILE_SEARCH = 'file_search'\n\nexport const MEMORY_LEGACY_TOOL_WRITE_FILE = 'write_file'\nexport const MEMORY_LEGACY_TOOL_EDIT_FILE = 'edit_file'\nexport const MEMORY_LEGACY_TOOL_REPLACE_STRING_IN_FILE = 'replace_string_in_file'\n\n/** 可再生工具名(prune fail-closed 允许列表用)。这些工具输出可廉价重生\n * (重读/重搜/重取),裁旧输出近无损;白名单**外**(MCP/agent 产物/一次性快照等)默认不裁。 */\nexport const MEMORY_TOOL_BASH = 'bash'\nexport const MEMORY_TOOL_BASH_OUTPUT = 'bash_output'\nexport const MEMORY_TOOL_WEB_SEARCH = 'web_search'\nexport const MEMORY_TOOL_WEB_FETCH = 'web_fetch'\n\n/**\n * 压缩输入的窗口占用上限比例(RFC-321 M6 / 底线2 机制保证)。\n *\n * `compact()` 把待摘要消息全量拼进一个 prompt 发给 LLM,此前**无任何长度保护**——\n * 实测 40 条 × 1.5MB 文本会话的 force 压缩会发出 **14625K token** 的 prompt(200K\n * 窗口的 73 倍),provider 直接 413,摘要保不住 → 用户底线2(上下文不能丢精度)被击穿。\n *\n * 本比例限定单次 summarize 调用的输入不超过 `窗口 × 本值`;超出则分批归约\n * (map-reduce:分段各自摘要 → 摘要再合并)。0.6 的余量用于容纳 prompt 模板、\n * previousSummary、以及 token 估算本身的误差(估算是下界,真实 tokenizer 可能更多)。\n */\nexport const MEMORY_COMPACTION_INPUT_WINDOW_FRACTION = 0.6\n\n/**\n * 分批摘要的最大批次数(RFC-321 M6 护栏)。\n *\n * 极端形态(单条消息本身就超窗口)下分批也无法收敛——批次数达上限即停止细分,\n * 对超长单条做**截断**(保留头尾各半,中间以省略标记替代)。截断虽有损,但\n * 它发生在\"摘要输入\"这一层,摘要本身仍会产出、边界仍会建立——相比压缩整体失败\n * (摘要完全没有、随后走 lossy 裁剪丢掉整段上下文)是严格更优的降级。\n */\nexport const MEMORY_COMPACTION_MAX_BATCHES = 8\n\n/**\n * RFC-324 D3:摘要覆盖率 warning 阈值——coverage 低于此值时下次压缩调大批次上限\n * (+2),使每批源内容更小、覆盖率回升。\n *\n * 优先级 env > `.otto/config.json`(`residency.coverage_warn_threshold`)> 本默认值:\n * 本常量已是 `normalizeEnvFraction(env, 0.7)` 的结果,故 env 显式设置时即为 env 值;\n * 宿主仅在 env 未设时才注入 config 值(与 residency 预算同一约定,见 RFC-323 D6b)。\n */\nexport const MEMORY_COVERAGE_WARN_THRESHOLD = normalizeEnvFraction(\n process.env['OTTO_MEMORY_COVERAGE_WARN_THRESHOLD'],\n 0.7,\n)\n\n/**\n * RFC-324 D3:摘要覆盖率 error 阈值——coverage 低于此值时记 warn 日志并**双重**调大\n * 批次上限(×2,下限 16),是比 warn 更激进的质量保护档。\n *\n * 不变式:error ≤ warn。若配置反了(error > warn),error 分支将永不可达——\n * 接线层(`memory-manager`)负责钳制,此处只定默认值。\n */\nexport const MEMORY_COVERAGE_ERROR_THRESHOLD = normalizeEnvFraction(\n process.env['OTTO_MEMORY_COVERAGE_ERROR_THRESHOLD'],\n 0.5,\n)\n\n/**\n * RFC-340 D1:摘要覆盖率**硬下限**——低于此值时摘要正文前置\"低保真\"标注\n * (`buildLowCoverageNotice`),如实告知模型这段摘要并不完整。\n *\n * 与 warn/error 的区别:那两档只影响\"下次压缩怎么切批次\"(对模型不可见的内部调节),\n * 本档影响**发给模型的摘要文本本身**——它是可见性档,不是执法档。\n *\n * 为什么不做成\"低于此值就不建边界\"(一版设计,已被 RFC-321 M6/C1 回归测试证伪):\n * 不建边界 → `compacted:false` → `escapeValveFailures` 累加 → 达阈值后放行 lossy\n * 裸裁剪(活跃上下文被无摘要直接删)。用 0% 覆盖换 6% 覆盖,严格更差。真正的缺陷\n * 不是\"边界建立了\",而是\"精度损失对模型不可见\"——故收敛为如实标注。\n *\n * 默认 0.3 远低于 error(0.5),只在极端截断场景触发标注,不影响正常压缩路径。\n * 优先级与 warn/error 同约定:env > `.otto/config.json` > 本默认值。\n */\nexport const MEMORY_COVERAGE_FLOOR_THRESHOLD = normalizeEnvFraction(\n process.env['OTTO_MEMORY_COVERAGE_FLOOR_THRESHOLD'],\n 0.3,\n)\n","import type { MemoryDefaults, CompactionConfig, PruneConfig } from './types'\nimport {\n MEMORY_COMPACTION_KEEP_RECENT_FRACTION,\n MEMORY_COMPACTION_RESERVE_TOKENS,\n MEMORY_COMPACTION_TURN_HEADROOM,\n MEMORY_COMPACTION_MIN_TRIGGER_FRACTION,\n MEMORY_PRUNE_TRIGGER_FRACTION,\n MEMORY_PRUNE_PROTECT_FRACTION,\n MEMORY_PRUNE_MINIMUM_TOKENS,\n MEMORY_PRUNE_TRUNCATE_MAX_LENGTH,\n MEMORY_TOOL_WRITE,\n MEMORY_TOOL_EDIT,\n MEMORY_TOOL_READ,\n MEMORY_TOOL_GREP,\n MEMORY_TOOL_FIND,\n MEMORY_LEGACY_TOOL_READ_FILE,\n MEMORY_LEGACY_TOOL_GREP_SEARCH,\n MEMORY_LEGACY_TOOL_FILE_SEARCH,\n MEMORY_TOOL_BASH,\n MEMORY_TOOL_BASH_OUTPUT,\n MEMORY_TOOL_WEB_SEARCH,\n MEMORY_TOOL_WEB_FETCH,\n} from './constants'\n\n// RFC-142 T142-01:apply_patch/create_file/edit_notebook_file 三个幽灵工具名已清理——\n// 它们从未在本仓注册过(tool-nodes.ts:181 仅注册 read/write/edit),truncateToolSet.has()\n// 对它们恒 false,是死代码。只保留两个真实存在的写类工具。\nconst defaultTruncateTools = [MEMORY_TOOL_WRITE, MEMORY_TOOL_EDIT] as const\n\n/** 默认可再生工具(fail-closed 允许列表)——只读/可重跑/可重取的输出,\n * 裁旧近无损。**保守**:bash 在内(输出大且重跑不自动触发);未列入的(write/edit 等写工具、\n * task_delegate 产物、capture_file_state 快照、任意 MCP 工具)默认不裁。\n * RFC-339 M3(T339-21):导出供 compaction.ts 的内容感知截断复用(单一真源,不新造分类)。 */\nexport const defaultRegenerableTools = [\n MEMORY_TOOL_READ,\n MEMORY_TOOL_GREP,\n MEMORY_TOOL_FIND,\n MEMORY_LEGACY_TOOL_READ_FILE,\n MEMORY_LEGACY_TOOL_GREP_SEARCH,\n MEMORY_LEGACY_TOOL_FILE_SEARCH,\n MEMORY_TOOL_BASH,\n MEMORY_TOOL_BASH_OUTPUT,\n MEMORY_TOOL_WEB_SEARCH,\n MEMORY_TOOL_WEB_FETCH,\n] as const\n\nexport function computeMemoryDefaults(model: {\n contextWindow: number\n maxOutput: number\n}): MemoryDefaults {\n return {\n compaction: {\n enabled: true,\n // 绝对 headroom = 输出预留(min(reserve,maxOutput)) + 一个最胖回合的余量。\n trigger: [\n 'headroom',\n Math.min(MEMORY_COMPACTION_RESERVE_TOKENS, model.maxOutput) + MEMORY_COMPACTION_TURN_HEADROOM,\n ],\n keepRecent: ['fraction', MEMORY_COMPACTION_KEEP_RECENT_FRACTION],\n reserveTokens: Math.min(MEMORY_COMPACTION_RESERVE_TOKENS, model.maxOutput),\n },\n prune: {\n trigger: ['fraction', MEMORY_PRUNE_TRIGGER_FRACTION],\n protect: ['fraction', MEMORY_PRUNE_PROTECT_FRACTION],\n minimum: MEMORY_PRUNE_MINIMUM_TOKENS,\n regenerableTools: [...defaultRegenerableTools],\n protectedTools: [],\n truncateTools: [...defaultTruncateTools],\n truncateMaxLength: MEMORY_PRUNE_TRUNCATE_MAX_LENGTH,\n },\n }\n}\n\nexport const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {\n enabled: true,\n // 无 model.maxOutput 时用 RESERVE_TOKENS 作输出预留分量。\n trigger: ['headroom', MEMORY_COMPACTION_RESERVE_TOKENS + MEMORY_COMPACTION_TURN_HEADROOM],\n keepRecent: ['fraction', MEMORY_COMPACTION_KEEP_RECENT_FRACTION],\n reserveTokens: MEMORY_COMPACTION_RESERVE_TOKENS,\n}\n\nexport const DEFAULT_PRUNE_CONFIG: PruneConfig = {\n trigger: ['fraction', MEMORY_PRUNE_TRIGGER_FRACTION],\n protect: ['fraction', MEMORY_PRUNE_PROTECT_FRACTION],\n minimum: MEMORY_PRUNE_MINIMUM_TOKENS,\n regenerableTools: [...defaultRegenerableTools],\n protectedTools: [],\n truncateTools: [...defaultTruncateTools],\n truncateMaxLength: MEMORY_PRUNE_TRUNCATE_MAX_LENGTH,\n}\n\nexport function resolveContextSize(\n size: import('./types').ContextSize,\n contextWindow: number,\n): number {\n const [unit, value] = size\n switch (unit) {\n case 'tokens':\n return value\n case 'fraction':\n return Math.floor(contextWindow * value)\n case 'headroom': {\n // 阈值 = window − 留白,带安全下界防小窗塌到 0/负。\n const floor = Math.floor(contextWindow * MEMORY_COMPACTION_MIN_TRIGGER_FRACTION)\n return Math.max(floor, contextWindow - value)\n }\n }\n}\n","import { createLogger } from '@x-otto/shared'\nimport {\n estimateTokens as defaultEstimateTokens,\n estimateMessagesTokens,\n estimateMessageTokens,\n} from './token-estimator'\nimport { resolveContextSize, DEFAULT_PRUNE_CONFIG } from './defaults'\nimport { MEMORY_TOOL_WRITE, MEMORY_TOOL_EDIT } from './constants'\n\nimport type { Message, ToolResultMessage } from '@x-otto/ai'\nimport type { PruneConfig, PruneResult } from './types'\n\nconst log = createLogger('@x-otto/memory:prune')\n\nexport function prune(\n messages: readonly Message[],\n contextWindow: number,\n config: Partial<PruneConfig> = {},\n estimator = defaultEstimateTokens,\n opts: { currentTokens?: number; force?: boolean } = {},\n): PruneResult {\n const cfg: PruneConfig = { ...DEFAULT_PRUNE_CONFIG, ...config }\n\n const triggerTokens = resolveContextSize(cfg.trigger, contextWindow)\n const messageTokens = estimateMessagesTokens(messages, estimator)\n\n const gateTokens = opts.currentTokens ?? messageTokens\n\n if (!opts.force && gateTokens < triggerTokens) {\n return { messages: [...messages], prunedCount: 0, tokensSaved: 0, changed: false }\n }\n\n const protectTokens = resolveContextSize(cfg.protect, contextWindow)\n const truncateToolSet = new Set(cfg.truncateTools)\n const regenerableToolSet = new Set(cfg.regenerableTools)\n const protectedToolSet = new Set(cfg.protectedTools)\n\n let accumulatedTokens = 0\n let protectBoundary = messages.length\n\n for (let i = messages.length - 1; i >= 0; i--) {\n const msg = messages[i]!\n const msgTokens = estimateMessageTokens(msg, estimator)\n accumulatedTokens += msgTokens\n\n if (accumulatedTokens >= protectTokens) {\n protectBoundary = i + 1\n break\n }\n }\n\n const result = messages.map((msg, i) => {\n if (i >= protectBoundary) {\n return msg\n }\n\n if (msg.role === 'tool_result') {\n const toolMsg = msg as ToolResultMessage\n\n // RFC-066 M3-01 fail-closed:仅裁已知可再生工具;未知/不可再生(MCP/agent 产物/快照)默认保留。\n if (!regenerableToolSet.has(toolMsg.toolName)) {\n return msg\n }\n\n // 二次保护:即便可再生,列入 protectedTools 仍不裁。\n if (protectedToolSet.has(toolMsg.toolName)) {\n return msg\n }\n\n return pruneToolResult(toolMsg, estimator)\n }\n\n if (msg.role === 'assistant' && i < protectBoundary) {\n return pruneAssistantToolCalls(msg, truncateToolSet, cfg.truncateMaxLength)\n }\n\n return msg\n })\n\n const newTokens = estimateMessagesTokens(result, estimator)\n\n const tokensSaved = messageTokens - newTokens\n\n if (tokensSaved < cfg.minimum) {\n log.debug(`Prune skipped: savings ${tokensSaved} < minimum ${cfg.minimum}`)\n return { messages: [...messages], prunedCount: 0, tokensSaved: 0, changed: false }\n }\n\n const prunedCount = result.filter((msg, i) => msg !== messages[i]).length\n\n log.info(`Pruned ${prunedCount} messages, saved ~${tokensSaved} tokens`)\n\n return { messages: result, prunedCount, tokensSaved, changed: true }\n}\n\n/** RFC-093:spill 占位符标记(work-loop clampWithSpill 生成)。含此标记的行指向磁盘全文。 */\nconst SPILL_MARKER = '[tool result stored to '\n\nfunction pruneToolResult(\n msg: ToolResultMessage,\n estimator: (text: string) => number,\n): ToolResultMessage {\n const originalText = msg.content.map((c) => (c.type === 'text' ? c.text : '')).join('')\n const originalTokens = estimator(originalText)\n\n if (originalTokens < 100) {\n return msg\n }\n\n // RFC-093:已 spill 的结果保留占位符首行(含磁盘路径,模型可 read 恢复),只裁 preview 部分。\n // 零依赖零注入——纯文本识别,不查询 store。\n const spillLine = originalText\n .split('\\n')\n .find((l) => l.startsWith(SPILL_MARKER))\n const pathRef = spillLine ? ` ${spillLine}` : ''\n\n return {\n ...msg,\n content: [\n {\n type: 'text' as const,\n text: `[output pruned — ${originalTokens} tokens]${pathRef}`,\n },\n ],\n }\n}\n\n/**\n * 超长写类工具调用参数的 schema 合法占位替换(RFC-142 D1)。\n *\n * **设计红线(RFC-142 D5,事故 trace 9a4a8cd5 教训)**:绝不把 `arguments` 替换成\n * 非工具 schema 的结构化 JSON(如旧实现的 `{_truncated: \"...\"}`)——模型会把上下文里\n * 反复出现的该结构误学为工具的合法参数格式,在生成新调用时模仿,导致 zod 校验失败\n * (上下文自我污染)。业界成熟做法:历史处理时显式排除工具调用项\n * (调用参数原样保留,只裁工具结果),且不改写工具调用的入参结构。\n *\n * 本实现的折中(沿用该原则 × otto 长会话 token 压力):占位后的参数**仍是该工具\n * 真实 zod schema 的合法值**(大字段换自然语言占位句,`path` 保留原值),即使被模型\n * 模仿也生成合法结构,不会引发校验失败。未知工具名 fail-closed 不裁。\n */\nfunction buildPlaceholderArguments(\n toolName: string,\n args: Record<string, unknown>,\n): Record<string, unknown> | null {\n const path = typeof args['path'] === 'string' ? args['path'] : undefined\n if (path === undefined) return null\n\n if (toolName === MEMORY_TOOL_WRITE) {\n const content = typeof args['content'] === 'string' ? args['content'] : ''\n return {\n path,\n content: `[content omitted by context pruning — ${content.length} chars. The file was already written to disk; read it if needed.]`,\n }\n }\n if (toolName === MEMORY_TOOL_EDIT) {\n const oldContent = typeof args['oldContent'] === 'string' ? args['oldContent'] : ''\n const newContent = typeof args['newContent'] === 'string' ? args['newContent'] : ''\n return {\n path,\n oldContent: `[omitted by context pruning — ${oldContent.length} chars]`,\n newContent: `[omitted by context pruning — ${newContent.length} chars. The edit was already applied to the file on disk; read it if needed.]`,\n }\n }\n // 未知工具名 fail-closed:宁可不省这点 token,也不冒产出非法/可误学结构的风险。\n return null\n}\n\nfunction pruneAssistantToolCalls(\n msg: Message & { role: 'assistant' },\n truncateToolSet: Set<string>,\n maxLength: number,\n): Message {\n if (!Array.isArray(msg.content)) {\n return msg\n }\n\n let changed = false\n const newContent = msg.content.map((part) => {\n if (part.type !== 'tool_call') {\n return part\n }\n if (!truncateToolSet.has(part.name)) {\n return part\n }\n\n const argsStr = JSON.stringify(part.arguments)\n if (argsStr.length <= maxLength) {\n return part\n }\n\n const placeholder = buildPlaceholderArguments(\n part.name,\n part.arguments as Record<string, unknown>,\n )\n if (placeholder === null) {\n return part\n }\n\n changed = true\n return { ...part, arguments: placeholder }\n })\n\n if (!changed) {\n return msg\n }\n\n return { ...msg, content: newContent }\n}\n","import { createLogger } from '@x-otto/shared'\nimport {\n MEMORY_TOOL_READ,\n MEMORY_TOOL_GREP,\n MEMORY_TOOL_FIND,\n MEMORY_TOOL_WRITE,\n MEMORY_TOOL_EDIT,\n MEMORY_LEGACY_TOOL_READ_FILE,\n MEMORY_LEGACY_TOOL_GREP_SEARCH,\n MEMORY_LEGACY_TOOL_FILE_SEARCH,\n MEMORY_LEGACY_TOOL_WRITE_FILE,\n MEMORY_LEGACY_TOOL_EDIT_FILE,\n MEMORY_LEGACY_TOOL_REPLACE_STRING_IN_FILE,\n MEMORY_COMPACTION_INPUT_WINDOW_FRACTION,\n MEMORY_COMPACTION_MAX_BATCHES as DEFAULT_MAX_BATCHES,\n MEMORY_COVERAGE_WARN_THRESHOLD,\n MEMORY_COVERAGE_ERROR_THRESHOLD,\n} from './constants'\n\n/** RFC-324 D3 M3:coverage 闭环状态——上次压缩 coverage < 0.7 时调大批次上限。\n * 上次 coverage 低说明批次过大导致大量截断,下次调上限允许更多批次、每批更小源。\n * 仅递推一次(消费后清 0),避免持续放大导致批次数无界。\n *\n * 终局修复(2026-08-06 review P1-5):此状态从模块级 `let` 变量改为 per-session Map,\n * 消除多会话并发压缩时的交叉污染——A 会话的低 coverage 不应影响 B 会话的批次上限。 */\nconst coverageBoostedBatchesBySession = new Map<string, number>()\n\n/** 获取指定 session 的 coverage 调大批次数(正常值 0=不调整)。 */\nfunction getCoverageBoostedBatches(sessionId: string): number {\n return coverageBoostedBatchesBySession.get(sessionId) ?? 0\n}\n\n/** 设置指定 session 的 coverage 调大批次数(仅递推一次)。 */\nfunction setCoverageBoostedBatches(sessionId: string, value: number): void {\n coverageBoostedBatchesBySession.set(sessionId, value)\n}\n\n/**\n * 仅供测试读取 per-session boost 状态(RFC-324 D3 阈值可配置性回归门用)。\n * 生产代码不得调用——boost 的读取入口是 `getCoverageBoostedBatches`。\n */\nexport function __getCoverageBoostedBatchesForTest(sessionId: string): number {\n return coverageBoostedBatchesBySession.get(sessionId) ?? 0\n}\n\n/** 消费后清零(仅递推一次)。 */\nfunction consumeCoverageBoostedBatches(sessionId: string): number {\n const v = coverageBoostedBatchesBySession.get(sessionId) ?? 0\n if (v > 0) coverageBoostedBatchesBySession.set(sessionId, 0)\n return v\n}\nimport {\n messageToText,\n estimateMessagesTokens,\n estimateMessageTokens,\n shortToolId,\n estimateTokens as defaultEstimateTokens,\n} from './token-estimator'\nimport { CONVERSATION_SUMMARY_PREFIX } from '@x-otto/ai'\nimport { resolveContextSize, DEFAULT_COMPACTION_CONFIG, defaultRegenerableTools } from './defaults'\nimport { buildSummarizationPrompt, buildSegmentMergePrompt, buildTurnPrefixPrompt } from './prompts'\n\nimport type { Message } from '@x-otto/ai'\nimport type { CompactionConfig, CompactionPreparation, CompactionResult, CutPoint } from './types'\n\nconst logger = createLogger('@x-otto/memory:compaction')\n\nexport { CONVERSATION_SUMMARY_PREFIX }\n\n/** 判定一条消息是否为历史压缩摘要(user 角色 + 文本以摘要前缀开头)。 */\nexport function isSummaryMessage(msg: Message): boolean {\n return msg.role === 'user' && messageToText(msg).startsWith(CONVERSATION_SUMMARY_PREFIX)\n}\n\nfunction stripSummaryPrefix(text: string): string {\n return text.startsWith(CONVERSATION_SUMMARY_PREFIX)\n ? text.slice(CONVERSATION_SUMMARY_PREFIX.length).trimStart()\n : text\n}\n\nconst fileReadToolSet = new Set<string>([\n MEMORY_TOOL_READ,\n MEMORY_TOOL_GREP,\n MEMORY_TOOL_FIND,\n MEMORY_LEGACY_TOOL_READ_FILE,\n MEMORY_LEGACY_TOOL_GREP_SEARCH,\n MEMORY_LEGACY_TOOL_FILE_SEARCH,\n])\n\n// RFC-142 T142-01:apply_patch/create_file/edit_notebook_file 三个幽灵工具名已清理——\n// 它们从未在本仓注册过(tool-nodes.ts 仅注册 read/write/edit),对应集合成员是死代码。\nconst fileModifyToolSet = new Set<string>([\n MEMORY_TOOL_WRITE,\n MEMORY_TOOL_EDIT,\n MEMORY_LEGACY_TOOL_WRITE_FILE,\n MEMORY_LEGACY_TOOL_EDIT_FILE,\n MEMORY_LEGACY_TOOL_REPLACE_STRING_IN_FILE,\n])\n\nexport function findCutPoint(\n messages: readonly Message[],\n keepRecentTokens: number,\n estimator = defaultEstimateTokens,\n): CutPoint {\n let accumulatedTokens = 0\n let rawCutIndex = 0\n\n for (let i = messages.length - 1; i >= 0; i--) {\n const tokens = estimateMessageTokens(messages[i]!, estimator)\n accumulatedTokens += tokens\n\n if (accumulatedTokens >= keepRecentTokens) {\n rawCutIndex = i + 1\n break\n }\n }\n\n rawCutIndex = Math.max(0, Math.min(rawCutIndex, messages.length - 1))\n\n let adjustedCutIndex = rawCutIndex\n while (adjustedCutIndex < messages.length) {\n const msg = messages[adjustedCutIndex]!\n if (msg.role === 'user' || msg.role === 'assistant') {\n break\n }\n adjustedCutIndex++\n }\n\n let isSplitTurn = false\n let turnStartIndex = adjustedCutIndex\n\n if (adjustedCutIndex > 0 && adjustedCutIndex < messages.length) {\n const msgAtCut = messages[adjustedCutIndex]!\n if (msgAtCut.role === 'assistant') {\n let lookback = adjustedCutIndex - 1\n while (lookback >= 0 && messages[lookback]!.role === 'tool_result') {\n lookback--\n }\n if (lookback >= 0 && messages[lookback]!.role === 'assistant') {\n isSplitTurn = true\n turnStartIndex = lookback\n }\n }\n }\n\n return {\n firstKeptIndex: adjustedCutIndex,\n turnStartIndex,\n isSplitTurn,\n }\n}\n\nexport function isEligibleForManualCompact(\n messages: readonly Message[],\n contextWindow: number,\n config: Partial<CompactionConfig> = {},\n estimator = defaultEstimateTokens,\n): boolean {\n const cfg = { ...DEFAULT_COMPACTION_CONFIG, ...config }\n const triggerTokens = resolveContextSize(cfg.trigger, contextWindow)\n const currentTokens = estimateMessagesTokens(messages, estimator)\n\n return currentTokens >= triggerTokens * 0.5\n}\n\nexport function prepareCompaction(\n messages: readonly Message[],\n contextWindow: number,\n config: Partial<CompactionConfig> = {},\n previousSummary?: string,\n estimator = defaultEstimateTokens,\n options?: { force?: boolean },\n): CompactionPreparation | null {\n const cfg = { ...DEFAULT_COMPACTION_CONFIG, ...config }\n const configuredKeepRecent = resolveContextSize(cfg.keepRecent, contextWindow)\n const currentTokens = estimateMessagesTokens(messages, estimator)\n\n if (messages.length <= 2) {\n logger.debug('Too few messages to compact')\n return null\n }\n\n /**\n * RFC-321 D1-a′/R10:**force 语义下把 keepRecent 钳制到当前总量的一半**。\n *\n * 根因:`force` 此前只绕过 `needsCompaction` 判定,**不绕过切点约束**——当全会话总\n * token < keepRecent(窗口×0.1)时,`findCutPoint` 的累加循环走完也命中不了阈值,\n * `rawCutIndex` 保持 0 → 下方 `firstKeptIndex <= 0` 短路返回 null,压缩静默不发生。\n * 实证(1M 窗口、6000 条 × 40 字符超短消息):`process({force:true})` → `compacted=false`、\n * LLM 调用 0。这使「驻留层 activeOversized 信号 → 请求强制压缩」的逃生阀在它唯一存在\n * 理由的形态下循环失效(RFC-321 §5 D1-a′);`/compact` 手动命令对超短会话同样静默无效。\n *\n * 钳制后同形态实测可压缩(6000 → 1715 条,LLM 调用 1)。切点仍由 token 累加得出,\n * **不引入条数塑形**(RFC-053 规则 1 与 G1 终局约束不破)——只是把\"保留多少 token\"\n * 从\"按窗口的绝对值\"改为\"按现有总量的相对值\",因为 force 的语义本就是\"现在必须压\"。\n * 自动触发路径(force=false)行为完全不变。\n */\n const keepRecentTokens =\n options?.force === true\n ? Math.max(1, Math.min(configuredKeepRecent, Math.floor(currentTokens / 2)))\n : configuredKeepRecent\n\n const cutPoint = findCutPoint(messages, keepRecentTokens, estimator)\n\n if (cutPoint.firstKeptIndex <= 0) {\n logger.debug('Cut point at beginning, nothing to compact')\n return null\n }\n\n const rawToSummarize = messages.slice(0, cutPoint.firstKeptIndex)\n const preservedMessages = messages.slice(cutPoint.firstKeptIndex)\n\n const messagesToSummarize = rawToSummarize.filter((m) => !isSummaryMessage(m))\n\n if (messagesToSummarize.length === 0) {\n logger.debug('Compaction window only contains prior summaries, nothing new to summarize')\n return null\n }\n\n let effectivePreviousSummary = previousSummary\n if (!effectivePreviousSummary) {\n for (let i = rawToSummarize.length - 1; i >= 0; i--) {\n if (isSummaryMessage(rawToSummarize[i]!)) {\n effectivePreviousSummary = stripSummaryPrefix(messageToText(rawToSummarize[i]!))\n break\n }\n }\n }\n\n let turnPrefixMessages: Message[] = []\n if (cutPoint.isSplitTurn) {\n turnPrefixMessages = messages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptIndex)\n }\n\n const fileOps = extractFileOps(messagesToSummarize)\n\n return {\n messagesToSummarize: [...messagesToSummarize],\n turnPrefixMessages,\n preservedMessages: [...preservedMessages],\n isSplitTurn: cutPoint.isSplitTurn,\n tokensBefore: currentTokens,\n previousSummary: effectivePreviousSummary,\n fileOps,\n }\n}\n\n/**\n * 执行 compaction — 调用 LLM 生成摘要。\n */\n/**\n * RFC-321 M6(底线2 机制保证):把待摘要文本按 token 预算切成若干批。\n *\n * 背景:`compact()` 原将全部待摘要消息拼成**单个** prompt 发给 LLM,无任何长度保护——\n * 实测 40 条 × 1.5MB 的文本会话 force 压缩会发出 14625K token 的 prompt(200K 窗口的\n * 73 倍),provider 直接 413 → 摘要产不出 → 随后走 lossy 裁剪丢掉整段上下文。\n * 用户底线2「上下文不能丢失精度,最低必须压缩保留摘要」因此被击穿。\n *\n * 切分以**消息为最小单位**(不切碎单条消息,保持语义完整);单条自身超预算时由\n * 调用方做截断降级(见 truncateForSummary)。\n */\nfunction batchMessagesByTokenBudget(\n messages: readonly Message[],\n budgetTokens: number,\n estimator: (text: string) => number,\n): Message[][] {\n const batches: Message[][] = []\n let current: Message[] = []\n let currentTokens = 0\n for (const m of messages) {\n const t = estimateMessageTokens(m, estimator)\n if (current.length > 0 && currentTokens + t > budgetTokens) {\n batches.push(current)\n current = []\n currentTokens = 0\n }\n current.push(m)\n currentTokens += t\n }\n if (current.length > 0) batches.push(current)\n return batches\n}\n\n/**\n * RFC-321 M6:单条文本超预算时的**头尾截断原语**(保留头尾、中间省略)。\n * RFC-339 M3(T339-22):截断标记去掉内部 RFC 编号,改语义化说明——内部编号不该\n * 泄进模型上下文。\n *\n * 有损,但损失被局限在\"喂给摘要器的那一份文本\"上——摘要仍会产出、压缩边界仍会建立。\n */\nfunction truncateText(text: string, maxChars: number): string {\n if (text.length <= maxChars) return text\n // 标记本身计入预算:产物长度 ≤ maxChars(严格上限,供 M6 硬预算依赖)。\n const marker = (n: number) => `\\n\\n…[${n} chars omitted to fit the summarization window]…\\n\\n`\n const budget = Math.max(0, maxChars - marker(text.length).length)\n const half = Math.floor(budget / 2)\n if (half <= 0) return marker(text.length).slice(0, maxChars)\n const omitted = text.length - half * 2\n return `${text.slice(0, half)}${marker(omitted)}${text.slice(-half)}`\n}\n\n/**\n * 将已按内容优先级处理过的文本再钳到真实 estimator 的 token 预算内。\n *\n * `chars = tokens * 4` 只能作为第一轮的分配尺度,不能充当安全边界:CJK 在 fallback\n * estimator 中约为一字一 token。这里二分头尾截断的字符预算,直到实际估算不超过上限,\n * 保证 map/reduce 不会把 CJK-heavy 摘要请求重新撑破 context window。\n */\nfunction truncateTextToTokenBudget(\n text: string,\n maxTokens: number,\n estimator: (text: string) => number,\n): string {\n if (estimator(text) <= maxTokens) return text\n\n let low = 0\n let high = text.length\n let best = ''\n while (low <= high) {\n const mid = Math.floor((low + high) / 2)\n const candidate = truncateText(text, mid)\n if (estimator(candidate) <= maxTokens) {\n best = candidate\n low = mid + 1\n } else {\n high = mid - 1\n }\n }\n return best\n}\n\n/**\n * RFC-339 M3(T339-20):**内容感知的批次截断**——取代改造前的「blob 挖心」。\n *\n * 缺陷背景(RFC-339 §1.3 缺陷 C):改造前把整批消息拼成一个 blob 再对其头尾截断、\n * 挖中间——内容盲。被挖掉的中段可能整条是用户指令,而两端可能都是 bash 日志。\n * RFC-321 C1 记录的\"57MB 会话 coverage 6.4%\",根因很可能在此:不是喂不下,是喂错了东西。\n *\n * 新策略:按消息优先级分层裁剪,预算耗尽时**从最低优先级开始牺牲**,用户指令永不裁:\n * P0 user 消息(指令/纠正) —— **永不裁剪**(超预算也整条保留)\n * P1 assistant 消息(决策文本 + tool_call)—— 仅在 P2/P3 裁尽后按头尾裁;头尾截断天然\n * 保留 tool_call 的 `name(` 前缀,只挖 payload 中段\n * P2 tool_result(不可再生工具) —— 次先裁(写类/未知工具输出,重跑不廉价)\n * P3 tool_result(可再生工具:read/grep/find/bash/web…)—— **最先裁**,头尾保留\n *\n * 返回逐消息格式化后的文本数组(供上层 join),使 coverage 能按消息粒度归因(T339-23)。\n * 每条产物均为纯文本(RFC-142 红线②:不产出模型会模仿的 JSON 结构/元数据键)。\n */\nexport function priorityOf(msg: Message): 0 | 1 | 2 | 3 {\n if (msg.role === 'user') return 0\n if (msg.role === 'assistant') return 1\n // tool_result:可再生工具优先裁(P3),不可再生的 tool_result 归 P2(更该保留)\n if (msg.role === 'tool_result') {\n return regenerableToolSet.has(msg.toolName) ? 3 : 2\n }\n // system_notification 等:按 assistant 同档保守对待\n return 1\n}\n\nconst regenerableToolSet = new Set<string>(defaultRegenerableTools)\n\n/**\n * RFC-339 M3:按优先级把一批消息裁到总预算内,返回 { texts, sourceChars, fedChars }。\n * 逐层降级:先整体尝试,超预算则把 P3 各消息头尾裁到小额度,仍超则裁 P2,再 P1;P0 永不动。\n */\nexport function truncateBatchByPriority(\n batch: readonly Message[],\n budgetChars: number,\n): { texts: string[]; sourceChars: number; fedChars: number; truncated: boolean } {\n const formatted = batch.map((m) => ({ msg: m, prio: priorityOf(m), text: formatMessageForSummary(m) }))\n const sourceChars = formatted.reduce((s, f) => s + f.text.length, 0)\n\n if (sourceChars <= budgetChars) {\n return { texts: formatted.map((f) => f.text), sourceChars, fedChars: sourceChars, truncated: false }\n }\n\n // RFC-339 M3 × RFC-321 M6 张力:D4「P0 永不裁」是**相对优先级**,M6「输入不超预算」是\n // **硬上限**。两者冲突时(P0 自身就超预算,如单条巨型用户消息 = 3× 窗口)以 M6 为准——\n // 否则会击穿 provider 413,反而丢掉整段。故按优先级**分配预算份额**,P0 拿最大份额、\n // 最后才动、给最多,但总额恒受 budgetChars 约束。\n let truncated = false\n const byPrio = (p: number) => formatted.filter((f) => f.prio === p)\n const p0 = byPrio(0)\n const p1 = byPrio(1)\n const p2 = byPrio(2)\n const p3 = byPrio(3)\n\n // 逐层从低优先级到高优先级分配:P3 最少、P0 最多。每层实际占用不超其\"当前文本量\",\n // 未用满的额度顺延给更高优先级层(使 P0 尽量拿满、只在总量真超时才被迫裁)。\n const layerChars = (msgs: typeof formatted) => msgs.reduce((s, f) => s + f.text.length, 0)\n // 目标份额(低→高)。P0 兜底拿\"剩余全部\",保证它被裁得最狠也最晚。\n const shares: Array<{ msgs: typeof formatted; share: number }> = [\n { msgs: p3, share: 0.1 },\n { msgs: p2, share: 0.2 },\n { msgs: p1, share: 0.35 },\n ]\n\n let budgetLeft = budgetChars\n // 先给 P3/P2/P1 各自 min(目标份额, 实际所需),回收未用额度。\n for (const { msgs, share } of shares) {\n if (msgs.length === 0) continue\n const want = layerChars(msgs)\n const cap = Math.floor(budgetChars * share)\n const grant = Math.min(want, cap)\n truncateLayer(msgs, grant)\n if (grant < want) truncated = true\n budgetLeft -= layerChars(msgs) // 用截断后的真实占用扣减\n }\n // P0 拿所有剩余预算(含上面各层回收的未用额度)。仅当 P0 总量仍超剩余时才裁(最后手段)。\n if (p0.length > 0) {\n const want = layerChars(p0)\n if (want > budgetLeft) {\n truncateLayer(p0, Math.max(0, budgetLeft))\n truncated = true\n }\n }\n\n const fedChars = formatted.reduce((s, f) => s + f.text.length, 0)\n return { texts: formatted.map((f) => f.text), sourceChars, fedChars, truncated }\n}\n\n/** 把一层消息裁到总额度 layerBudget 内(均分到每条,按头尾裁)。layerBudget<=0 → 各裁到最小残留。 */\nfunction truncateLayer(\n msgs: Array<{ text: string }>,\n layerBudget: number,\n): void {\n if (msgs.length === 0) return\n const each = Math.max(0, Math.floor(layerBudget / msgs.length))\n for (const f of msgs) {\n if (f.text.length > each) {\n f.text = each <= 0 ? '' : truncateText(f.text, each)\n }\n }\n}\n\nexport async function compact(\n preparation: CompactionPreparation,\n summarize: (\n prompt: string,\n options?: { maxTokens?: number; signal?: AbortSignal },\n ) => Promise<string>,\n config: Partial<CompactionConfig> = {},\n signal?: AbortSignal,\n estimator: (text: string) => number = defaultEstimateTokens,\n sessionId?: string,\n): Promise<CompactionResult> {\n const cfg = { ...DEFAULT_COMPACTION_CONFIG, ...config }\n\n logger.info(`Compacting ${preparation.messagesToSummarize.length} messages`)\n\n /**\n * RFC-321 M6(底线2 机制保证):**压缩输入窗口预检 + 分批归约**。\n *\n * 修复前:全部待摘要消息拼成单个 prompt 直发 LLM,无长度保护——实测 40 条 × 1.5MB\n * 文本会话 force 压缩发出 14625K token(200K 窗口的 73 倍)→ provider 413 →\n * 摘要产不出 → 走 lossy 裁剪丢整段上下文,用户底线2 被击穿。\n *\n * 修复后:单次 summarize 输入不超过 `窗口 × MEMORY_COMPACTION_INPUT_WINDOW_FRACTION`;\n * 超出则 map-reduce——分段各自摘要,再把段摘要合并成最终摘要。窗口未知(调用方未传\n * contextWindow)时退回原单批行为,保持向后兼容。\n *\n * 注:图片形态天然安全——`formatMessageForSummary` 只取文本,base64 不进 prompt\n * (实测 45 张 1.4MB 图的 prompt 长度为 0)。故本机制只对文本体量生效。\n */\n // 预算须扣除 prompt 模板 + previousSummary 的开销——否则批次贴着预算切,拼上模板后\n // 就会略微越界(实测超出约 270 token)。模板开销按其真实 token 估算,不用魔数。\n // RFC-339 D3:分批路径 map 阶段实际用 segment 变体,故按 segment 模板估算开销\n // (它比 UPDATE 略长,扣除偏保守,安全方向)。\n const templateOverhead =\n cfg.contextWindow != null\n ? estimator(\n buildSummarizationPrompt('', undefined, cfg.customInstructions, { variant: 'segment' }),\n )\n : 0\n const inputBudget =\n cfg.contextWindow != null\n ? Math.max(\n 1,\n Math.floor(cfg.contextWindow * MEMORY_COMPACTION_INPUT_WINDOW_FRACTION) -\n templateOverhead,\n )\n : undefined\n\n let summary: string\n /**\n * RFC-340 D1:本次压缩的摘要覆盖率,随 `CompactionResult` 上报给 `MemoryManager`。\n * 单批路径全量喂入恒为 1;map-reduce 路径由下方实算覆盖。**本层只上报不决策**。\n */\n let resultCoverage = 1\n const batches =\n inputBudget != null\n ? batchMessagesByTokenBudget(preparation.messagesToSummarize, inputBudget, estimator)\n : [preparation.messagesToSummarize as Message[]]\n\n if (batches.length <= 1) {\n const messagesText = preparation.messagesToSummarize.map(formatMessageForSummary).join('\\n\\n')\n // RFC-339 D3:主路径——有保留区则用 preserved-tail 变体,告知模型切点之后 N 条\n // 原样保留、不要重复概括,并要求产出「保留区依赖的前置信息」段。preservedCount 亦\n // 供 UPDATE 路径的尾注使用(buildSummarizationPrompt 内按 previousSummary 分派)。\n const preservedCount = preparation.preservedMessages?.length ?? 0\n const prompt = buildSummarizationPrompt(\n messagesText,\n preparation.previousSummary,\n cfg.customInstructions,\n { variant: preservedCount > 0 ? 'preserved-tail' : 'full', preservedCount },\n )\n summary = await summarize(prompt, { maxTokens: cfg.reserveTokens, signal })\n // RFC-321 C1(摘要质量可观测):单批路径全量喂入,覆盖率 100%。\n logger.info(\n {\n batches: 1,\n sourceChars: messagesText.length,\n fedChars: messagesText.length,\n coverage: 1,\n truncated: false,\n },\n '[compaction] summarization coverage',\n )\n } else {\n // 批次数护栏:超上限时合并尾部批次并对超长文本截断(宁可局部有损,也要产出摘要)。\n // RFC-324 D3 M3:coverage 闭环——上次 coverage < 0.7 时用 boosted 上限(允许更多批次)。\n const sid = sessionId ?? '__default__'\n const boosted = getCoverageBoostedBatches(sid)\n const maxBatches = boosted > 0 ? boosted : DEFAULT_MAX_BATCHES\n if (boosted > 0) {\n consumeCoverageBoostedBatches(sid) // 消费后置 0(仅递推一次)\n }\n const effective =\n batches.length <= maxBatches\n ? batches\n : [...batches.slice(0, maxBatches - 1), batches.slice(maxBatches - 1).flat()]\n logger.info(\n `RFC-321 M6: summarization input exceeds window budget; map-reduce over ${effective.length} batches`,\n )\n\n // map:逐批摘要(RFC-339 M3:批文本超预算时按消息优先级裁,不再 blob 挖心)。\n const maxChars = inputBudget! * 4 // token→char 粗放上界(估算 ~0.25 token/char;inputBudget 已扣模板开销)\n const partials: string[] = []\n // RFC-321 C1:统计真实喂入摘要器的字符量 vs 源字符量——这是\"上下文精度保留\"\n // 承诺唯一可检查的指标。M6 只保证\"摘要必然产出\",不保证保真度。\n // RFC-339 D5:coverage 口径保持字符比不变(fedChars/sourceChars),另加**分维度**\n // 归因(各优先级各裁掉多少),使\"coverage 低\"能定位到具体优先级层。\n let sourceChars = 0\n let fedChars = 0\n let truncated = false\n // 分维度累计:[P0,P1,P2,P3] 各层的 source/fed 字符量。\n const bySrc = [0, 0, 0, 0]\n const byFed = [0, 0, 0, 0]\n for (const batch of effective) {\n if (signal?.aborted) break\n const cut = truncateBatchByPriority(batch, maxChars)\n // 安全钳制:逐消息分配 + join 分隔符 + 截断标记的累加可能微超 maxChars(实测越界\n // 约 200 token/批)。M6 硬上限不可破,故对 join 后的整体再做一次头尾钳制兜底——\n // 仅在优先级分配后仍越界时触发,不改变\"低优先级先被裁\"的语义。\n const prioritizedText = cut.texts.join('\\n\\n')\n let text = prioritizedText\n if (text.length > maxChars) {\n text = truncateText(text, maxChars)\n }\n // 字符分配只保证 ASCII 近似;最终须按同一个 token estimator 收口,避免 CJK/符号\n // 密集批次穿透 inputBudget 触发 provider 413。\n text = truncateTextToTokenBudget(text, inputBudget!, estimator)\n sourceChars += cut.sourceChars\n fedChars += Math.min(cut.fedChars, text.length)\n if (cut.truncated || text.length < prioritizedText.length) truncated = true\n // 分维度归因(与 cut.texts 同序遍历 batch)。\n batch.forEach((m, i) => {\n const p = priorityOf(m)\n bySrc[p] = (bySrc[p] ?? 0) + formatMessageForSummary(m).length\n byFed[p] = (byFed[p] ?? 0) + (cut.texts[i]?.length ?? 0)\n })\n // RFC-339 D3:map 阶段用 segment 变体——显式告知模型这是「一段、非全部对话」,\n // 禁止输出面向全局的 next-step 判断(那属于下方 reduce 合并阶段)。\n const p = buildSummarizationPrompt(text, undefined, cfg.customInstructions, {\n variant: 'segment',\n })\n partials.push(await summarize(p, { maxTokens: cfg.reserveTokens, signal }))\n }\n\n // reduce:把段摘要合并成最终摘要(合并输入用头尾截断原语保护——段摘要无消息结构)。\n const mergedText = truncateText(\n partials.map((p, i) => `### Segment ${i + 1}\\n\\n${p}`).join('\\n\\n'),\n maxChars,\n )\n // RFC-339 D3:reduce 用专用合并提示词,输入包在 <segment_summaries> 而非\n // <conversation>——修正改造前 reduce 误用 <conversation> 标签(内容类型与标签语义不符)。\n summary = await summarize(\n buildSegmentMergePrompt(mergedText, preparation.previousSummary, cfg.customInstructions),\n { maxTokens: cfg.reserveTokens, signal },\n )\n // RFC-321 C1:coverage = 真实喂入 ÷ 源字符量。<1 表示有内容被截断未进摘要,\n // 该部分上下文既不在保留区、也不在摘要里——是真实的精度损失,须可观测。\n const coverage = sourceChars > 0 ? fedChars / sourceChars : 1\n // RFC-340 D1:上报给 MemoryManager,供其判定是否给摘要加低保真标注(本层不决策)。\n resultCoverage = coverage\n logger.info(\n {\n batches: effective.length,\n sourceChars,\n fedChars,\n coverage: Number(coverage.toFixed(4)),\n truncated,\n // RFC-339 D5:分维度归因——各优先级 fed/src,定位\"coverage 低\"落在哪层。\n // P0=user P1=assistant P2=tool_result(non-regenerable) P3=tool_result(regenerable)\n byPriority: {\n p0: { src: bySrc[0], fed: byFed[0] },\n p1: { src: bySrc[1], fed: byFed[1] },\n p2: { src: bySrc[2], fed: byFed[2] },\n p3: { src: bySrc[3], fed: byFed[3] },\n },\n },\n '[compaction] summarization coverage',\n )\n // RFC-324 D3 M3:摘要质量三级响应。coverage 低时不仅影响本次摘要质量,\n // 还递推影响下次压缩——本模块维护 coverage 历史记录,下次压缩时根据历史\n // 自动调大 MAX_BATCHES 上限(允许更多批次,每批更小源内容,提高覆盖率)。\n // RFC-324 D3:阈值可经 .otto 配置文件的 residency.coverage_* 注入(env 优先)。\n // 钳制 error ≤ warn——配置写反时 error 分支永不可达,会静默失去激进保护档。\n const warnThreshold = cfg.coverageWarnThreshold ?? MEMORY_COVERAGE_WARN_THRESHOLD\n const errorThreshold = Math.min(\n cfg.coverageErrorThreshold ?? MEMORY_COVERAGE_ERROR_THRESHOLD,\n warnThreshold,\n )\n if (coverage < errorThreshold) {\n logger.warn(\n {\n coverage: Number(coverage.toFixed(4)),\n sourceChars,\n fedChars,\n batches: effective.length,\n errorThreshold,\n },\n '[compaction] low summarization coverage — large portion of history not represented in summary (RFC-321 C1)',\n )\n }\n // 记录本次 coverage 供下次压缩参考(per-session 闭环)。\n recordCoverage(\n coverage,\n effective.length,\n sessionId ?? '__default__',\n errorThreshold,\n warnThreshold,\n )\n }\n\n if (preparation.fileOps.read.length > 0 || preparation.fileOps.modified.length > 0) {\n const fileOpsText = formatFileOps(preparation.fileOps)\n if (!summary.includes('## File Operations')) {\n summary += `\\n\\n${fileOpsText}`\n }\n }\n\n if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {\n const prefixText = preparation.turnPrefixMessages.map(formatMessageForSummary).join('\\n\\n')\n const prefixPrompt = buildTurnPrefixPrompt(prefixText)\n const prefixSummary = await summarize(prefixPrompt, { signal })\n summary = `${summary}\\n\\n---\\n\\n${prefixSummary}`\n }\n\n logger.info(`Compaction complete: summary ${summary.length} chars`)\n\n return {\n summary,\n firstKeptIndex: preparation.messagesToSummarize.length,\n tokensBefore: preparation.tokensBefore,\n coverage: resultCoverage,\n }\n}\n\nfunction formatMessageForSummary(msg: Message): string {\n const roleLabel =\n msg.role === 'user'\n ? 'Human'\n : msg.role === 'assistant'\n ? 'Assistant'\n : msg.role === 'tool_result'\n ? // RFC-343:tool_result 加 toolCallId 配对锚 + ok/error 状态(行内纯文本,RFC-142 R1)。\n // 与 messageToText 的 `[call#<id> name]` 对应,使摘要器能关联 call↔result。\n `Tool[result#${shortToolId(msg.toolCallId)} ${msg.toolName}]${msg.isError ? '{error}' : ''}`\n : 'System'\n\n const content = messageToText(msg)\n return `${roleLabel}: ${content}`\n}\n\nfunction extractFileOps(messages: readonly Message[]): { read: string[]; modified: string[] } {\n const read = new Set<string>()\n const modified = new Set<string>()\n\n for (const msg of messages) {\n if (msg.role !== 'tool_result') {\n continue\n }\n\n const toolMsg = msg\n const toolName = toolMsg.toolName\n const content = messageToText(msg)\n const pathMatch = content.match(/(?:^|\\s)(\\/[^\\s]+\\.[a-z]+)/i)\n\n if (!pathMatch?.[1]) {\n continue\n }\n\n if (fileReadToolSet.has(toolName)) {\n read.add(pathMatch[1])\n } else if (fileModifyToolSet.has(toolName)) {\n modified.add(pathMatch[1])\n }\n }\n\n return {\n read: [...read],\n modified: [...modified],\n }\n}\n\nfunction formatFileOps(fileOps: { read: string[]; modified: string[] }): string {\n const parts: string[] = ['## File Operations']\n\n if (fileOps.read.length > 0) {\n parts.push('### Read')\n for (const path of fileOps.read) {\n parts.push(`- ${path}`)\n }\n }\n\n if (fileOps.modified.length > 0) {\n parts.push('### Modified')\n for (const path of fileOps.modified) {\n parts.push(`- ${path}`)\n }\n }\n\n return parts.join('\\n')\n}\n\n/** RFC-324 D3 M3:coverage 闭环——记录本次压缩 coverage,供下次压缩调批次上限。\n * coverage < 0.5 → 双重调大(x2);0.5-0.7 → 调大(+2);>= 0.7 → 正常不干预。\n * 终局修复(2026-08-06 review P1-5):从模块级全局变量改为 per-session Map,消除交叉污染。 */\nfunction recordCoverage(\n coverage: number,\n currentBatches: number,\n sessionId: string,\n errorThreshold: number,\n warnThreshold: number,\n): void {\n if (coverage < errorThreshold) {\n setCoverageBoostedBatches(sessionId, Math.max(currentBatches * 2, 16))\n } else if (coverage < warnThreshold) {\n setCoverageBoostedBatches(sessionId, currentBatches + 2)\n }\n}\n","import { createLogger } from '@x-otto/shared'\nimport { messageToText } from './token-estimator'\n\nimport type { Message } from '@x-otto/ai'\nimport type { ArchiveStorage, ArchiveEntry, StorageType } from './types'\n\nconst logger = createLogger('@x-otto/memory:archive')\n\nexport class InMemoryArchiveStorage implements ArchiveStorage {\n readonly type: StorageType = 'memory'\n\n private archives = new Map<string, { content: string; entry: ArchiveEntry }>()\n private sessionIndex = new Map<string, string[]>()\n\n async archive(sessionId: string, messages: Message[], summary: string): Promise<string> {\n const timestamp = Date.now()\n const path = `memory://archives/${sessionId}/compaction-${timestamp}.md`\n const content = formatArchive(messages, summary, timestamp)\n\n this.archives.set(path, {\n content,\n entry: {\n path,\n timestamp,\n summary: summary.slice(0, 200),\n messageCount: messages.length,\n },\n })\n\n const index = this.sessionIndex.get(sessionId) ?? []\n index.push(path)\n this.sessionIndex.set(sessionId, index)\n\n logger.info(`Archived ${messages.length} messages for session ${sessionId}`)\n return path\n }\n\n async read(archivePath: string): Promise<string> {\n const record = this.archives.get(archivePath)\n if (!record) {\n throw new Error(`Archive not found: ${archivePath}`)\n }\n return record.content\n }\n\n async list(sessionId: string): Promise<ArchiveEntry[]> {\n const paths = this.sessionIndex.get(sessionId) ?? []\n return paths\n .map((p) => this.archives.get(p)?.entry)\n .filter((e): e is ArchiveEntry => e !== undefined)\n }\n\n /** 会话销毁时驱逐其全部归档条目——防长跑进程中 archives/sessionIndex 无界增长。 */\n forgetSession(sessionId: string): void {\n const paths = this.sessionIndex.get(sessionId)\n if (!paths) return\n for (const p of paths) {\n this.archives.delete(p)\n }\n this.sessionIndex.delete(sessionId)\n }\n}\n\n/**\n * file/http archive persistence 整栈已删。\n * archive = session 派生视图——仅 `InMemoryArchiveStorage` 为 V 形态/无持久化 session tree 回退。\n * createArchiveStorage() 工厂亦退场。\n */\n\nexport function formatArchive(messages: Message[], summary: string, timestamp: number): string {\n const date = new Date(timestamp).toISOString()\n const parts: string[] = [\n `## Compacted at ${date}`,\n '',\n '### Summary',\n summary,\n '',\n `### Original Messages (${messages.length} messages)`,\n ]\n\n for (const msg of messages) {\n const role =\n msg.role === 'user'\n ? 'Human'\n : msg.role === 'assistant'\n ? 'Assistant'\n : msg.role === 'tool_result'\n ? `Tool[${msg.toolName}]`\n : 'System'\n\n const content = messageToText(msg)\n\n const truncated =\n content.length > 2000\n ? `${content.slice(0, 2000)}\\n...(truncated, ${content.length} chars total)`\n : content\n\n parts.push(`\\n**${role}**: ${truncated}`)\n }\n\n return parts.join('\\n')\n}\n\n","import { createLogger } from '@x-otto/shared'\n\nimport { PersistentMemory, FileSystemMemoryStore } from './persistent-memory'\nimport { prune } from './prune'\nimport {\n isEligibleForManualCompact,\n prepareCompaction,\n compact,\n CONVERSATION_SUMMARY_PREFIX,\n} from './compaction'\nimport { InMemoryArchiveStorage, formatArchive } from './archive'\nimport { estimateTokens as defaultEstimateTokens, estimateContextTokens } from './token-estimator'\nimport {\n computeMemoryDefaults,\n resolveContextSize,\n DEFAULT_COMPACTION_CONFIG,\n DEFAULT_PRUNE_CONFIG,\n} from './defaults'\nimport {\n MEMORY_COMPACTION_HARD_CEILING_FRACTION,\n MEMORY_COVERAGE_ERROR_THRESHOLD,\n MEMORY_COVERAGE_FLOOR_THRESHOLD,\n MEMORY_RESIDENCY_TRIGGER_FRACTION,\n} from './constants'\nimport { buildArchiveReference, buildLowCoverageNotice } from './prompts'\n\nimport type {\n MemoryManagerConfig,\n CompactionConfig,\n ContextTokenEstimate,\n PruneConfig,\n PruneResult,\n CompactionPreparation,\n CompactionResult,\n ArchiveEntry,\n ArchiveStorage,\n MinimalEntry,\n MessageFromEntry,\n} from './types'\nimport type { Message, AssistantMessage } from '@x-otto/ai'\n\nfunction clearStaleUsage(m: Message): Message {\n if (m.role === 'assistant' && (m as AssistantMessage).usage) {\n const { usage: _drop, ...rest } = m as AssistantMessage\n return rest as Message\n }\n return m\n}\n\nconst logger = createLogger('@x-otto/memory')\n\nconst DEFAULT_SESSION_KEY = '__default__'\n\nfunction sessionKey(sessionId?: string): string {\n return sessionId?.trim() || DEFAULT_SESSION_KEY\n}\n\nexport class MemoryManager {\n private readonly persistent: PersistentMemory\n /**\n * archive = session 派生视图——loadSessionEntries 就位时走 session 条目的\n * compaction 边界间重建;未就位(无持久化/V 形态)时回退到 archiveStorage(InMemory)。\n */\n private readonly archiveStorage: ArchiveStorage\n private readonly loadSessionEntries?: (sessionId: string) => Promise<MinimalEntry[]>\n private readonly messageFromEntry?: MessageFromEntry\n private readonly summarize: MemoryManagerConfig['summarize']\n private readonly estimateTokens: (text: string) => number\n /** 构造时保留原始配置副本,供 reconfigure 用新窗口重算阈值。 */\n private readonly rawConfig: MemoryManagerConfig\n private contextWindow: number\n /** 惰性窗口解析器(跟随生效模型),优先于 this.contextWindow。 */\n private readonly getContextWindow?: () => number | undefined\n private compactionConfig: CompactionConfig\n private pruneConfig: PruneConfig\n /** 会话消息字节估算预算(RFC-178 字节触发因子;RFC-321 M2 后是唯一的非 token 触发因子)。 */\n private readonly maxContentBytes?: number\n /** RFC-321 M3:生效字节预算惰性读数(跟随 ResidencyGovernor 收紧档位)。 */\n private readonly getMaxContentBytes?: () => number | undefined\n private readonly getEstimatedBytes?: () => number | undefined\n\n private readonly previousSummaries = new Map<string, string>()\n /** archive Path 单调发号——每会话内独立(archive:{sessionId}:{1..N})。 */\n private readonly archiveIdCounter = new Map<string, number>()\n /**\n * 终局架构 review 建议优化项:并发 process() 门闩(per-session)。\n *\n * 单进程事件循环下 JS 无 TOCTOU(check-then-act 之间不会被抢占),但若同一 sessionId\n * 的两次 process() 调用在 await LLM 压缩(compact())期间交错(如同一会话被两个不同\n * 入口几乎同时触发 process,例如 work-loop 的常规压缩检查与用户手动 /compact 命令\n * 撞车),后调用者会基于前调用者压缩前的旧快照重复生成摘要,产生冗余/冲突的\n * previousSummaries 写入。此 flag 在 compact() 的 await 期间挡住同 session 的重入,\n * 后来者直接跳过本轮压缩(下一次 needsCompaction 判定仍会触发,不丢失压缩机会,只是\n * 延后一轮),finally 保证异常路径也会复位。\n */\n private readonly compactionInFlight = new Set<string>()\n\n constructor(config: MemoryManagerConfig) {\n const defaults = config.model\n ? computeMemoryDefaults(config.model)\n : { compaction: DEFAULT_COMPACTION_CONFIG, prune: DEFAULT_PRUNE_CONFIG }\n\n this.compactionConfig = { ...defaults.compaction, ...config.compaction }\n this.pruneConfig = { ...defaults.prune, ...config.prune }\n this.contextWindow = config.model?.contextWindow ?? 200_000\n this.getContextWindow = config.getContextWindow\n this.maxContentBytes = config.maxContentBytes\n this.getMaxContentBytes = config.getMaxContentBytes\n this.getEstimatedBytes = config.getEstimatedBytes\n this.rawConfig = config\n\n this.archiveStorage = config.archiveStorage ?? new InMemoryArchiveStorage()\n this.loadSessionEntries = config.loadSessionEntries\n this.messageFromEntry = config.messageFromEntry\n this.summarize = config.summarize\n this.estimateTokens = config.estimateTokens ?? defaultEstimateTokens\n\n const store = config.memoryStore ?? new FileSystemMemoryStore()\n this.persistent = new PersistentMemory(store, config.sources)\n }\n\n /**\n * `/clear`——驱逐本会话的滚动压缩摘要桶(L2 工作记忆)。L1 持久知识(persistent)\n * 与 L3 归档(archiveStorage)不动。无 sessionId 走 __default__ 桶(单会话场景)。\n */\n clearSession(sessionId?: string): void {\n this.previousSummaries.delete(sessionKey(sessionId))\n }\n\n /**\n * 审计修复:会话销毁/驱逐时遗忘其全部 per-session 状态——防三个 Map 在长跑进程中无界增长。\n * 与 clearSession 不同:此处清 archiveIdCounter(会话终态,无再压缩可能)。\n */\n forgetSession(sessionId: string): void {\n this.previousSummaries.delete(sessionKey(sessionId))\n this.archiveIdCounter.delete(sessionId)\n // V 形态(无 loadSessionEntries 重建器)时 archiveStorage 是真实写入路径\n // (见 recordCompactionArchive),InMemoryArchiveStorage 实现下需要级联驱逐;\n // 落盘/远程实现或未实现该可选方法时 no-op。\n this.archiveStorage.forgetSession?.(sessionId)\n }\n\n /**\n * 模型切换时更新上下文窗口 + 重算 compaction/prune 触发阈值。\n * 调用后 needsCompaction/needsPrune 基于新窗口判定。\n */\n reconfigure(model: { contextWindow: number; maxOutput: number }): void {\n const defaults = computeMemoryDefaults(model)\n this.contextWindow = model.contextWindow\n\n this.compactionConfig = { ...defaults.compaction, ...this.rawConfig.compaction }\n this.pruneConfig = { ...defaults.prune, ...this.rawConfig.prune }\n }\n\n /**\n * 生效上下文窗口——优先惰性解析器(跟随当前模型),回退构造期/ reconfigure 值。\n * prune/compaction 一切触发线均按此计算,避免构造期 model 未知导致的 200K 默认锁死。\n */\n private effectiveContextWindow(): number {\n return this.getContextWindow?.() ?? this.contextWindow\n }\n\n /**\n * 主动检查当前消息量是否超过压缩触发线(基于 reconfigure 后的新窗口);\n * 超过则 force-compact。用于模型切换时预防下一轮 413。\n */\n async ensureFitsWindow(\n messages: readonly Message[],\n sessionId?: string,\n signal?: AbortSignal,\n ): Promise<{\n messages: Message[]\n compacted: boolean\n summary?: string\n replacement?: Message[]\n }> {\n if (!this.needsCompaction(messages)) {\n return { messages: [...messages], compacted: false }\n }\n const result = await this.process(messages, sessionId, signal, { force: true })\n return {\n messages: result.messages,\n compacted: result.compacted,\n summary: result.summary,\n replacement: result.replacement,\n }\n }\n\n async loadMemory(): Promise<void> {\n await this.persistent.load()\n }\n\n getMemoryPrompt(): string {\n return this.persistent.getInjection()\n }\n\n async reloadMemory(): Promise<void> {\n await this.persistent.reload()\n }\n\n getMemories(): ReadonlyMap<string, string> {\n return this.persistent.getMemories()\n }\n\n needsPrune(messages: readonly Message[]): boolean {\n return this.needsPruneWith(estimateContextTokens(messages, this.estimateTokens))\n }\n\n /** 估算复用变体(终局审查 2026-07-18:process() 一次估算供判定+执行两处消费,\n * 消除同批消息的重复全量遍历——WeakMap 缓存对 spread 新建的消息数组会穿透)。 */\n private needsPruneWith(estimate: ContextTokenEstimate): boolean {\n // 以 BodyAfterPrefix 为触发口径——稳定系统前缀不计入,只数会话 body 增长。\n const triggerTokens = resolveContextSize(\n this.pruneConfig.trigger,\n this.effectiveContextWindow(),\n )\n return estimate.bodyAfterPrefix >= triggerTokens\n }\n\n prune(messages: readonly Message[], force = false): PruneResult {\n return this.pruneWith(messages, estimateContextTokens(messages, this.estimateTokens), force)\n }\n\n private pruneWith(\n messages: readonly Message[],\n estimate: ContextTokenEstimate,\n force: boolean,\n ): PruneResult {\n return prune(messages, this.effectiveContextWindow(), this.pruneConfig, this.estimateTokens, {\n currentTokens: estimate.total,\n force,\n })\n }\n\n needsCompaction(messages: readonly Message[]): boolean {\n if (this.compactionConfig.enabled === false) return false\n const window = this.effectiveContextWindow()\n const estimate = estimateContextTokens(messages, this.estimateTokens)\n const triggerTokens = resolveContextSize(this.compactionConfig.trigger, window)\n // 软触发以 BodyAfterPrefix 为口径(减压缩频次,稳定前缀不提前顶过阈值);\n // 再以 total 为口径设硬上限作溢出兜底——前缀异常大时 body 口径可能迟迟不触发\n // (实测系统前缀 ≥40K 时 bodyAfterPrefix 软触发失效,硬顶成唯一有效防线),硬上限确保\n // 在真实溢出(413 reactiveCompact)前强制压缩。RFC-066 增量修复(事故 f9bab4c0):硬顶\n // 从 window−reserveTokens(≈98.4%,距溢出仅 16K) 改为 window × hardCeilingFraction\n // (默认 0.90,留 10% 溢出余量),消除 954K〜983K 死区。config 未设则用 env/常量默认。\n // Math.max 保证硬上限永不低于软触发(即便 trigger 被覆盖为高值)。\n const hardCeilingFraction =\n this.compactionConfig.hardCeilingFraction ?? MEMORY_COMPACTION_HARD_CEILING_FRACTION\n const hardCeiling = Math.max(triggerTokens, Math.floor(window * hardCeilingFraction))\n if (estimate.bodyAfterPrefix >= triggerTokens || estimate.total >= hardCeiling) return true\n // RFC-321 D2/G1(2026-08-07 定稿,方向 A′):**条数触发因子已删除**。\n //\n // 它曾在此处判定 `messages.length >= maxHistoryMessages * 0.9` 即强制压缩,用于覆盖\n // \"会话全程超短消息、token 从未触发、条数先摸到存储上限\"的场景。删除的三条理由:\n // ① **死区空转**:压缩能否发生取决于 findCutPoint 能否切出切点(需总 token ≥\n // keepRecent = 窗口×0.1)。1M 窗口下需 ≥22.2 token/条,而最短消息实测 6 token/条\n // —— 因子让 needsCompaction 恒返回 true 但 prepareCompaction 恒返回 null。实证:\n // 8000 条会话仿真触发 351 次、实际压缩 0 次,每次白跑一遍 O(n) 全量 token 估算。\n // ② **口径失配**:cap 判据是全树 messageCount(含边界前死历史),本因子判据是\n // buildContext 产出的活跃条数,压缩后前者不降而后者骤降,两者可差数千条——\n // \"确保 cap 触发时边界已存在\"的原不变式因此并不成立。\n // ③ **维度越界**:让条数决定\"压不压缩\"即条数塑造上下文,与 RFC-053 的维度分层相悖。\n //\n // 它原本覆盖的\"活跃区自身超限\"盲区,改由**驻留层逃生阀**承担(RFC-321 D1-a′):\n // 裁剪遇 compaction 边界即止后若仍超限,`capStoredHistory` 返回 `activeOversized`,\n // 由 persistence-sync 请求一次强制压缩(force 语义带 keepRecent 钳制,见 R10)。\n // 区别在于触发依据是\"裁剪已尽力仍超内存防御上限\"这一驻留层事实,而非条数阈值本身;\n // 压缩切点始终纯由 token 决定。**禁止以任何形式恢复条数→压缩的直接判定**(R9)。\n //\n // 字节触发因子(RFC-178 触发器 B 软段)保留:字节维度存在 token 覆盖不到的真实盲区\n // (实证 170 张 1MB 图 → 字节占预算 89%、token 仅占窗口 34%),且字节大 ⇒ token 通常\n // 也够 findCutPoint 切得动,不存在条数因子那种死区。\n // 覆盖\"条数少但单条巨大、token 维度也未触发\"场景(如大附件/超大工具输出),确保\n // capStoredHistoryByBytes(存储层字节收紧)触发时压缩边界必然已存在。\n // maxContentBytes/getEstimatedBytes 未注入时不启用(向后兼容,仅 token 维度)。\n if (this.residencyPressure()) return true\n return false\n }\n\n /**\n * 驻留(字节)维度是否已逼近预算——`needsCompaction` 的非 token 触发因子。\n *\n * 单独抽出的原因(RFC-321 R10,二轮实证):字节因子与已删除的条数因子**同型死区**——\n * 判定为真不代表切得动。`findCutPoint` 要求总 token ≥ keepRecent(窗口×0.1),大窗口下\n * 「字节超预算但总 token 偏小」的形态(如少量大附件)会让 `prepareCompaction` 返回 null,\n * 压缩静默不发生(实证:1M 窗口、200 条 × 1000 字符、字节因子触发 → compacted=false、\n * LLM 调用 0)。故 `process()` 在**由驻留压力驱动**时同样启用 keepRecent 钳制,与\n * 逃生阀 force 路径共用同一修法。\n */\n private residencyPressure(): boolean {\n if (this.getEstimatedBytes == null) return false\n // RFC-321 M3:优先读惰性有效值(跟随 ResidencyGovernor 的 warning/critical 收紧),\n // 回退构造期静态值(向后兼容)。两侧同源后,压力档下\"cap 触发时边界必然已存在\"\n // 的不变式才真正成立——此前 memory 侧恒按全值判定,收紧在压缩层完全不可见。\n const budget = this.getMaxContentBytes?.() ?? this.maxContentBytes\n if (budget == null) return false\n const bytes = this.getEstimatedBytes()\n return bytes != null && bytes >= budget * MEMORY_RESIDENCY_TRIGGER_FRACTION\n }\n\n isEligibleForManualCompact(messages: readonly Message[]): boolean {\n return isEligibleForManualCompact(\n messages,\n this.effectiveContextWindow(),\n this.compactionConfig,\n this.estimateTokens,\n )\n }\n\n /**\n * @param options.force RFC-321 R10:强制压缩语义下 keepRecent 钳制到当前总量一半,\n * 使\"总 token < keepRecent\"的超短会话也能切出边界(否则 force 仍被切点约束挡住、\n * 压缩静默不发生)。详见 `compaction.ts` 的 prepareCompaction 内注释。\n */\n prepareCompaction(\n messages: readonly Message[],\n sessionId?: string,\n options?: { force?: boolean },\n ): CompactionPreparation | null {\n return prepareCompaction(\n messages,\n this.effectiveContextWindow(),\n this.compactionConfig,\n this.previousSummaries.get(sessionKey(sessionId)),\n this.estimateTokens,\n options,\n )\n }\n\n async compact(\n preparation: CompactionPreparation,\n sessionId?: string,\n signal?: AbortSignal,\n ): Promise<CompactionResult> {\n /**\n * RFC-339 D2-a:**B1「复用旧摘要」判据已废止**(曾在此处,commit 前占约 45 行)。\n *\n * 废止原因是坐标系错配,非阈值不当——`currentFirstKept = messagesToSummarize.length`\n * 是**本轮输入数组**里的下标,而每次压缩后 `process()` 把历史重建为\n * `[summaryMessage, ...replacement]`(下方 `current` 重建),索引坐标系归零。于是\n * 上一轮记录的 `lastFirstKept` 与本轮 `currentFirstKept` 不在同一坐标系,\n * `growth = current - last` 无意义:稳态会话首次压缩后 `growth` 恒为负 →\n * `growthRatio < 0.25` 恒真 → **永久锁死在复用分支**,摘要冻结在第一版,而每轮仍\n * 真实切走一批消息(既不进冻结摘要、也不在保留区)——实证 4 轮轨迹见 RFC-339 §1.3。\n * 叠加 RFC-340 的 `coverage: 1` 硬编码,该锁死还使 340 的 floor 执法在此路径失效。\n *\n * 省调用的**正确形态由下方完整路径天然提供**:`prepareCompaction` 已把历史摘要消息\n * 过滤出 `messagesToSummarize`(`compaction.ts` 的 `!isSummaryMessage` 过滤),\n * `previousSummary` 作 `UPDATE_SUMMARIZATION_PROMPT` 基底 → 每轮只对**增量**消息做\n * 摘要并与旧摘要合并。这既不丢 delta,也不需要一个基于数组下标的复用捷径。\n */\n\n // RFC-321 M6:注入生效窗口 + estimator,使 compact() 能做压缩输入窗口预检\n // (超预算改走 map-reduce 分批归约,保证摘要必产出——底线2 机制保证)。\n const result = await compact(\n preparation,\n this.summarize,\n {\n ...this.compactionConfig,\n contextWindow: this.effectiveContextWindow(),\n // RFC-324 D3:coverage 阈值惰性读(settings 在 App 构造后才 load,\n // 静态快照会永远是 undefined)。未注入或字段缺省时回退 memory 侧常量。\n ...(() => {\n const t = this.rawConfig.getCoverageThresholds?.()\n return {\n ...(t?.warn !== undefined ? { coverageWarnThreshold: t.warn } : {}),\n ...(t?.error !== undefined ? { coverageErrorThreshold: t.error } : {}),\n ...(t?.floor !== undefined ? { coverageFloorThreshold: t.floor } : {}),\n }\n })(),\n },\n signal,\n this.estimateTokens,\n sessionId,\n )\n\n /**\n * RFC-340 D1(**二版,一版被回归测试证伪**):低覆盖摘要**如实标注**,而非拒绝建边界。\n *\n * 一版设计是\"coverage < floor 就不建立边界\",理由是\"覆盖率 6% 的摘要不该授权删除\n * 94% 的历史\"。RFC-321 M6/C1 的既有回归测试把它证伪了——真实级联是:\n *\n * 不建边界 → `compactNow()` 返回 compacted:false\n * → `persistence-sync.ts:466` 累加 `escapeValveFailures`\n * → 达 `LOSSY_TRIM_FAILURE_THRESHOLD`(3) 后 `residency-executor.ts:119` 放行 lossy\n * → 活跃上下文被**无摘要**直接裁掉\n *\n * 即:拒绝一份 6% 覆盖的摘要,换来的是 0% 覆盖的裸裁剪——**严格更差**。这正是\n * RFC-321 M6「宁可局部有损,也要产出摘要」那条决策的由来,一版设计是在跟它对打。\n *\n * 二版据此收敛到真正的缺陷面:问题从来不是\"边界建立了\",而是**边界背后的精度损失\n * 对模型和运维都不可见**(摘要不说自己不完整,`buildArchiveReference` 还谎称可以\n * read 回来——见 D2)。所以正确的动作是**如实标注 + 可观测**,不是阻断。\n *\n * 位置在记归档/写 previousSummaries 之前:标注要成为摘要正文的一部分,随边界一起\n * 落地,并在后续复用该摘要(下一轮压缩以其为 UPDATE 基底)时继续携带(一份\"标注了\n * 不完整\"的摘要被沿用仍然诚实)。\n *\n * 注(RFC-339 M1 后修正):原注释此处写\"被下一轮 B1 复用时携带\"——B1「复用旧摘要」\n * 分支已因坐标系错配被 RFC-339 D2-a 删除,不再存在。摘要的沿用现改经正常\n * UPDATE 路径(previousSummary 作基底),标注随之继续携带,语义不变。\n */\n const floorThreshold = Math.min(\n this.compactionConfig.coverageFloorThreshold ??\n this.rawConfig.getCoverageThresholds?.()?.floor ??\n MEMORY_COVERAGE_FLOOR_THRESHOLD,\n // 不变式 floor ≤ error ≤ warn:配置写反时钳制,防档位静默失效\n // (对齐 compaction.ts 内既有的 error≤warn 钳制模式)。\n this.compactionConfig.coverageErrorThreshold ?? MEMORY_COVERAGE_ERROR_THRESHOLD,\n )\n\n if (result.coverage < floorThreshold) {\n logger.warn(\n {\n sessionId,\n coverage: Number(result.coverage.toFixed(4)),\n floorThreshold,\n },\n '[compaction] coverage below floor — boundary established WITH an explicit ' +\n 'low-fidelity notice; a large portion of history is not represented in this summary ' +\n '(RFC-340 D1)',\n )\n result.summary = `${buildLowCoverageNotice(result.coverage)}\\n\\n${result.summary}`\n }\n\n if (sessionId) {\n try {\n await this.recordCompactionArchive(sessionId, result, preparation.messagesToSummarize)\n logger.info(`Compaction recorded at ${result.archivePath}`)\n } catch (err) {\n logger.warn({ error: err }, 'Failed to record compaction archive')\n }\n }\n\n this.previousSummaries.set(sessionKey(sessionId), result.summary)\n\n return result\n }\n\n /**\n * 压缩归档单源(reused B1 缓存命中 / fresh LLM 压缩两路共用,memory-1 去重):递增 per-session\n * 归档序号 → 赋逻辑 `archive:<sessionId>:<idx>` → 仅当无 loadSessionEntries 重建器时落盘\n * (有重建器则归档按需从会话条目还原,archivePath 只作逻辑引用)。\n */\n private async recordCompactionArchive(\n sessionId: string,\n target: CompactionResult,\n messagesToSummarize: Message[],\n ): Promise<void> {\n const idx = (this.archiveIdCounter.get(sessionId) ?? 0) + 1\n this.archiveIdCounter.set(sessionId, idx)\n target.archivePath = `archive:${sessionId}:${idx}`\n if (!this.loadSessionEntries) {\n await this.archiveStorage.archive(sessionId, messagesToSummarize, target.summary)\n }\n }\n\n async process(\n messages: readonly Message[],\n sessionId?: string,\n signal?: AbortSignal,\n options?: { force?: boolean; skipPrune?: boolean },\n ): Promise<{\n messages: Message[]\n summary?: string\n archivePath?: string\n pruned: boolean\n compacted: boolean\n\n replacement?: Message[]\n\n pruneMetrics?: {\n prunedCount: number\n tokensSaved: number\n messagesBefore: number\n messagesAfter: number\n }\n }> {\n let current = [...messages]\n let pruned = false\n let compacted = false\n let summary: string | undefined\n let replacement: Message[] | undefined\n let archivePath: string | undefined\n let pruneMetrics:\n | { prunedCount: number; tokensSaved: number; messagesBefore: number; messagesAfter: number }\n | undefined\n\n const force = options?.force === true\n\n const skipPrune = options?.skipPrune === true\n\n // 一次估算供 needsPrune 判定 + prune 执行共用(此前各自全量遍历同一批消息估算一次;\n // prune 后 current 变为新数组,needsCompaction 的重新估算是必要的不在此列)。\n const pruneEstimate = estimateContextTokens(current, this.estimateTokens)\n if (!skipPrune && (force || this.needsPruneWith(pruneEstimate))) {\n const messagesBefore = current.length\n const pruneResult = this.pruneWith(current, pruneEstimate, force)\n if (pruneResult.changed) {\n current = pruneResult.messages\n pruned = true\n pruneMetrics = {\n prunedCount: pruneResult.prunedCount,\n tokensSaved: pruneResult.tokensSaved,\n messagesBefore,\n messagesAfter: current.length,\n }\n logger.info(\n `Prune: removed ${pruneResult.prunedCount} tool outputs, saved ~${pruneResult.tokensSaved} tokens`,\n )\n }\n }\n\n const compactionAllowed = this.compactionConfig.enabled !== false\n if (force ? compactionAllowed : this.needsCompaction(current)) {\n const inFlightKey = sessionKey(sessionId)\n // Concurrency gate: skip duplicate compact() calls when one already in-flight for this session.\n if (this.compactionInFlight.has(inFlightKey)) {\n logger.info(\n { sessionId },\n 'Compaction already in flight for this session, skipping this round (will retry next round)',\n )\n } else {\n this.compactionInFlight.add(inFlightKey)\n try {\n // RFC-321 R10:keepRecent 钳制用于**非 token 驱动**的压缩——force(逃生阀/\n // /compact/413 反应式)与驻留压力(字节因子)两条路径都可能面对\"总 token <\n // keepRecent 因而切不动\"的死区。纯 token 阈值触发的自动压缩不钳制(此时总量\n // 本就远超 keepRecent,钳制无意义且会过度压缩)。\n const preparation = this.prepareCompaction(current, sessionId, {\n force: force || this.residencyPressure(),\n })\n\n if (preparation) {\n try {\n const result = await this.compact(preparation, sessionId, signal)\n compacted = true\n summary = result.summary\n archivePath = result.archivePath\n\n const summaryText = archivePath\n ? buildArchiveReference(archivePath, result.summary)\n : result.summary\n\n const summaryMessage: Message = {\n role: 'user',\n content: [\n { type: 'text', text: `${CONVERSATION_SUMMARY_PREFIX}\\n\\n${summaryText}` },\n ],\n timestamp: Date.now(),\n }\n\n replacement = preparation.preservedMessages.map(clearStaleUsage)\n current = [summaryMessage, ...replacement]\n\n logger.info(\n `Compaction: ${preparation.messagesToSummarize.length} messages → summary, kept ${preparation.preservedMessages.length}`,\n )\n } catch (err) {\n logger.warn(\n { error: err },\n 'Compaction failed (LLM/summarizer error?), skipping compaction for this turn',\n )\n }\n }\n } finally {\n this.compactionInFlight.delete(inFlightKey)\n }\n }\n }\n\n return { messages: current, summary, replacement, archivePath, pruned, compacted, pruneMetrics }\n }\n\n async listArchives(sessionId: string): Promise<ArchiveEntry[]> {\n if (this.loadSessionEntries && this.messageFromEntry) {\n try {\n return await this.listArchivesFromEntries(sessionId)\n } catch (err) {\n logger.warn({ error: err }, 'listArchives from session entries failed, falling back')\n }\n }\n\n return this.archiveStorage.list(sessionId)\n }\n\n /** 从 session entries 的 compaction 节点枚举 ArchiveEntry 列表。 */\n private async listArchivesFromEntries(sessionId: string): Promise<ArchiveEntry[]> {\n const entries = await this.loadSessionEntries!(sessionId)\n const msgFromEntry = this.messageFromEntry!\n const result: ArchiveEntry[] = []\n let counter = 0\n\n for (let entryIdx = 0; entryIdx < entries.length; entryIdx++) {\n const entry = entries[entryIdx]!\n if (entry.type !== 'compaction') continue\n const data = entry.data as { summary: string; compactedCount: number }\n counter++\n\n let msgCount = 0\n let lastTs = 0\n for (let i = entryIdx - 1; i >= 0; i--) {\n const prev = entries[i]!\n if (prev!.type === 'compaction') break\n const msg = msgFromEntry(prev!)\n if (msg) {\n msgCount++\n lastTs = (msg as { timestamp?: number }).timestamp ?? lastTs\n }\n }\n result.push({\n path: `archive:${sessionId}:${counter}`,\n timestamp: lastTs || entry.seq || 0,\n messageCount: data.compactedCount,\n summary: data.summary.slice(0, 200),\n })\n }\n return result\n }\n\n async readArchive(archivePath: string): Promise<string> {\n if (this.loadSessionEntries && this.messageFromEntry) {\n try {\n const reconstructed = await this.readArchiveFromEntries(archivePath)\n if (reconstructed) return reconstructed\n } catch (err) {\n logger.warn({ error: err }, 'readArchive from session entries failed, falling back')\n }\n }\n\n return this.archiveStorage.read(archivePath)\n }\n\n /** 从 session entries 的 compaction 边界间重建归档全文。 */\n private async readArchiveFromEntries(archivePath: string): Promise<string | null> {\n const m = archivePath.match(/^archive:(.+):(\\d+)$/)\n if (!m) return null\n const sessionId = m[1]!\n const targetCounter = parseInt(m[2]!, 10)\n\n const entries = await this.loadSessionEntries!(sessionId)\n const msgFromEntry = this.messageFromEntry!\n\n let counter = 0\n let compactionIdx = -1\n for (let i = 0; i < entries.length; i++) {\n if (entries[i]!.type === 'compaction') {\n counter++\n if (counter === targetCounter) {\n compactionIdx = i\n break\n }\n }\n }\n if (compactionIdx < 0) return null\n const compactionEntry = entries[compactionIdx]!\n const cdata = compactionEntry.data as { summary: string }\n\n let prevCompactionIdx = -1\n for (let i = compactionIdx - 1; i >= 0; i--) {\n if (entries[i]!.type === 'compaction') {\n prevCompactionIdx = i\n break\n }\n }\n const startIdx = prevCompactionIdx + 1\n const messagesInRange: ReturnType<typeof msgFromEntry>[] = []\n for (let i = startIdx; i < compactionIdx; i++) {\n const msg = msgFromEntry(entries[i]!)\n if (msg) messagesInRange.push(msg)\n }\n\n const ts = Date.now()\n return formatArchive(\n messagesInRange.filter((m): m is NonNullable<typeof m> => m !== null),\n cdata.summary,\n ts,\n )\n }\n\n dispose(): void {\n this.persistent.dispose()\n }\n}\n\nexport function createMemoryManager(config: MemoryManagerConfig): MemoryManager {\n return new MemoryManager(config)\n}\n","import { FileSystemMemoryStore } from './persistent-memory'\nimport type { MemorySource, MemoryStore } from './types'\n\n/** 冲突旁路文件的环形缓冲槽位数(RFC-164 M164-2 D6,实现简化:避免新增 DELETE 端点)。 */\nconst CONFLICT_RING_SIZE = 5\n\n/**\n * M71c + RFC-164 M164-2: 远端 user 级 / workspace 级 memory 的 HTTP MemoryStore。\n *\n * 对 user 源(~/.otto/AGENTS.md 等)走远端 HTTP 读/写,project 源走本地文件系统;\n * R 形态 opt-in 后 workspace 源(./.otto/MEMORY.md 等)同样走远端(M164-2 新增)。\n * - 读:HTTP GET → 缓存本地 → 返回;远端不可达时回退本地缓存(R5 降级)\n * - 写:本地缓存先写,再 fire-and-forget 推远端(R5 降级:远端不可达不阻塞)\n *\n * 远端 key:\n * - user-scope = `user:<userId>:<相对 ~/.otto/ 的文件路径>`(如 AGENTS.md/MEMORY.md/memory/foo.md)。\n * - workspace-scope(M164-2)= `workspace:<userId>:<workspaceKey>:<相对 workspace 根的文件路径>`\n * (`userId` 仍是安全边界,`workspaceKey` 是同用户下的项目分区,见 `entity-route.ts` 设计记录)。\n *\n * **惰性 getter 而非构造期快照**(RFC-164 M164-2 摸底阶段发现的时序断层修正):\n * `workspaceKey`/`isWorkspaceSyncEnabled` 均为函数,每次 `load()`/`write()` 才实时求值——\n * `HttpMemoryStore` 从始至终只构造一次(App 构造期),但是否真的走远端由每次 I/O 时的\n * 当前状态决定,不在构造期\"决定死\"。`workspaceRef`(携带这两个字段的来源)要到 `start()`\n * 才异步解析完成,构造期读不到;惰性 getter 与本仓 `getWorkspaceKey: () => this.workspaceRef?.key`\n * (`app.ts:718`)同一模式。\n */\nexport class HttpMemoryStore implements MemoryStore {\n private readonly baseUrl: string\n /** user-scope 路径的用户身份——M164-2 改为可选:R 形态可能没有 userIdentity(未登录),\n * 此时 user-scope 路径优雅降级为本地(不抛错),workspace-scope 路径不受影响。 */\n private readonly userId?: string\n private readonly workspaceKeyGetter?: () => string | undefined\n private readonly isWorkspaceSyncEnabled: () => boolean\n private readonly getAuth: () => Promise<{ token: string }>\n private readonly fetchImpl: typeof globalThis.fetch\n private readonly timeoutMs: number\n private readonly local: FileSystemMemoryStore\n /** 冲突发生时的可选通知回调(RFC-164 M164-2 子agent评审F3)——app 层接线 TUI toast。 */\n private readonly onConflict?: (path: string, conflictPath: string) => void\n /** 每个 path 的冲突环形缓冲计数器(进程内存,不持久化——仅用于槽位轮转,不影响正确性)。 */\n private readonly conflictCounters = new Map<string, number>()\n /** 每个远端 path 的已知版本号(简单单调计数器,进程内存)——PUT 时携带,供服务端判定冲突。 */\n private readonly knownVersions = new Map<string, number>()\n\n constructor(options: {\n /** 远端服务器 base URL(不尾斜线)。 */\n baseUrl: string\n /** 获取 Bearer token 的工厂。 */\n getAuth: () => Promise<{ token: string }>\n /** fetch 实现。 */\n fetch: typeof globalThis.fetch\n /** 请求超时 (ms)。 */\n timeoutMs: number\n /** 当前用户 identity id(可选——R 形态可能未登录)。 */\n userId?: string\n /**\n * workspace key 的惰性 getter(R 形态 opt-in 时使用)。缺省时 workspace-scope 路径\n * 恒判定为不可用(降级本地),与 `isWorkspaceSyncEnabled` 缺省行为一致。\n */\n workspaceKey?: () => string | undefined\n /**\n * workspace 记忆远端同步是否已 opt-in 的惰性 getter(RFC-164 D2)。每次 I/O 时才求值——\n * 不在构造期决定,而是随 `workspaceRef.memorySyncEnabled` 的当前值实时判定。\n * 缺省 = 恒 `false`(向后兼容,user-only 场景不受影响)。\n */\n isWorkspaceSyncEnabled?: () => boolean\n /** 本地缓存根目录(传给 FileSystemMemoryStore 的 cwd);缺省 process.cwd()。 */\n localDir?: string\n /**\n * `~/` 前缀路径展开的用户主目录 override(透传给内部 `FileSystemMemoryStore`,仅测试\n * 使用——见 `FileSystemMemoryStore.homeDirOverride` 注释:修复持续写污染真实\n * `~/.otto/` 的测试隔离缺陷)。生产环境不传,行为不变。\n */\n homeDirOverride?: string\n /** 冲突检测到时的通知回调(可选)。 */\n onConflict?: (path: string, conflictPath: string) => void\n }) {\n this.baseUrl = options.baseUrl.replace(/\\/+$/, '')\n this.userId = options.userId\n this.workspaceKeyGetter = options.workspaceKey\n this.isWorkspaceSyncEnabled = options.isWorkspaceSyncEnabled ?? (() => false)\n this.getAuth = options.getAuth\n this.fetchImpl = options.fetch\n this.timeoutMs = options.timeoutMs\n this.local = new FileSystemMemoryStore(options.localDir, options.homeDirOverride)\n this.onConflict = options.onConflict\n }\n\n async load(sources: MemorySource[]): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n const localFallback: MemorySource[] = []\n\n for (const source of sources) {\n if (this.userId && isUserSource(source.path)) {\n const remoteContent = await this.loadRemoteUser(source.path)\n if (remoteContent !== null) {\n result.set(source.path, remoteContent)\n this.local.write(source.path, remoteContent).catch(() => {})\n } else {\n localFallback.push(source)\n }\n } else if (this.isWorkspaceSourceEnabled(source.path)) {\n const remoteContent = await this.loadRemoteWorkspace(source.path)\n if (remoteContent !== null) {\n result.set(source.path, remoteContent)\n this.local.write(source.path, remoteContent).catch(() => {})\n } else {\n localFallback.push(source)\n }\n } else {\n localFallback.push(source)\n }\n }\n\n if (localFallback.length > 0) {\n const localResult = await this.local.load(localFallback)\n for (const [path, content] of localResult) {\n result.set(path, content)\n }\n }\n\n return result\n }\n\n async write(path: string, content: string): Promise<void> {\n await this.local.write(path, content)\n\n if (this.userId && isUserSource(path)) {\n // user-scope:fire-and-forget(R5,既有行为不变)——偶发手动编辑场景,不阻塞。\n this.writeRemoteUser(path, content).catch(() => {})\n } else if (this.isWorkspaceSourceEnabled(path)) {\n // workspace-scope:await(M164-2 实现阶段决策)——D6 冲突检测的 onConflict 回调\n // 需要与写入同步可见(用户能在 write() resolve 后立即看到冲突提示),网络异常\n // 仍静默降级(writeRemoteWorkspace 内部 try/catch 吞错误,不向上抛)。\n await this.writeRemoteWorkspace(path, content)\n }\n }\n\n watch?(_sources: MemorySource[], _onChange: (path: string) => void): () => void {\n return () => {}\n }\n\n /** workspace-scope 判定:路径形如 workspace 根下的 `.otto/` 文件(非 `~/.otto/`)且 opt-in 已启用。 */\n private isWorkspaceSourceEnabled(path: string): boolean {\n return isWorkspaceSource(path) && this.isWorkspaceSyncEnabled() && !!this.workspaceKeyGetter?.()\n }\n\n private remoteKey(path: string): string {\n const suffix = path.startsWith('~/.otto/') ? path.slice('~/.otto/'.length) : path\n return `user:${this.userId}:${suffix}`\n }\n\n /**\n * 新路由 `/memory/user/:resourceId` 的 resourceId——文件相对路径(如 `AGENTS.md`/\n * `MEMORY.md`/`memory/foo.md`)本身。多个不同 path 各自映射独立 resourceId,\n * 避免固定单一 key 导致的跨文件覆盖(RFC-108 D1 修正)。\n */\n private remoteResourceId(path: string): string {\n const suffix = path.startsWith('~/.otto/') ? path.slice('~/.otto/'.length) : path\n return encodeURIComponent(suffix)\n }\n\n /** workspace-scope resourceId——相对 workspace 根的路径(如 `.otto/MEMORY.md` → `MEMORY.md`)。 */\n private workspaceResourceId(path: string): string {\n const suffix = path.startsWith('./.otto/') ? path.slice('./.otto/'.length) : path\n return encodeURIComponent(suffix)\n }\n\n /** 远端 GET(user-scope) → { content: string } | null (网络/404均返回 null) */\n private async loadRemoteUser(path: string): Promise<string | null> {\n try {\n const auth = await this.getAuth()\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.token}`,\n Accept: 'application/json',\n 'X-Otto-User-Id': this.userId!,\n }\n\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), this.timeoutMs)\n\n try {\n // RFC-108 D1:优先尝试新收口路由 /memory/user/:resourceId(每个 path 独立 resourceId)。\n const newResponse = await this.fetchImpl(\n `${this.baseUrl}/memory/user/${this.remoteResourceId(path)}`,\n { method: 'GET', headers, signal: controller.signal },\n )\n\n if (newResponse.ok) {\n const data = (await newResponse.json()) as { content?: string }\n if (typeof data.content === 'string') {\n return data.content\n }\n }\n\n // 新路由 404 → fallback 旧路由(一次性兼容期读取;旧路由也 404 则走 R5 降级)。\n if (newResponse.status === 404) {\n const oldResponse = await this.fetchImpl(\n `${this.baseUrl}/sessions/${encodeURIComponent(this.remoteKey(path))}`,\n { method: 'GET', headers, signal: controller.signal },\n )\n\n if (oldResponse.ok) {\n const data = (await oldResponse.json()) as { content?: string }\n if (typeof data.content === 'string') {\n return data.content\n }\n }\n }\n } finally {\n clearTimeout(timeout)\n }\n } catch {}\n return null\n }\n\n /** 远端 PUT(user-scope)(best-effort, 失败静默——R5) */\n private async writeRemoteUser(path: string, content: string): Promise<void> {\n try {\n const auth = await this.getAuth()\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.token}`,\n 'Content-Type': 'application/json',\n 'X-Otto-User-Id': this.userId!,\n }\n\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), this.timeoutMs)\n\n try {\n await this.fetchImpl(`${this.baseUrl}/memory/user/${this.remoteResourceId(path)}`, {\n method: 'PUT',\n headers,\n body: JSON.stringify({ content }),\n signal: controller.signal,\n })\n } finally {\n clearTimeout(timeout)\n }\n } catch {}\n }\n\n /** 远端 GET(workspace-scope) → { content: string } | null (网络/404均返回 null) */\n private async loadRemoteWorkspace(path: string): Promise<string | null> {\n const workspaceKey = this.workspaceKeyGetter?.()\n if (!workspaceKey) return null\n\n try {\n const auth = await this.getAuth()\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.token}`,\n Accept: 'application/json',\n }\n\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), this.timeoutMs)\n\n try {\n const response = await this.fetchImpl(\n `${this.baseUrl}/memory/workspace/${encodeURIComponent(workspaceKey)}/${this.workspaceResourceId(path)}`,\n { method: 'GET', headers, signal: controller.signal },\n )\n if (response.ok) {\n const data = (await response.json()) as { content?: string; version?: number }\n if (typeof data.content === 'string') {\n if (typeof data.version === 'number') {\n this.knownVersions.set(path, data.version)\n }\n return data.content\n }\n }\n } finally {\n clearTimeout(timeout)\n }\n } catch {}\n return null\n }\n\n /**\n * 远端 PUT(workspace-scope)(best-effort, 失败静默——R5)。\n * D6 冲突检测:携带客户端已知版本号;服务端返回 409(版本不匹配)时,不覆盖,\n * 转存为环形缓冲冲突旁路文件。\n */\n private async writeRemoteWorkspace(path: string, content: string): Promise<void> {\n const workspaceKey = this.workspaceKeyGetter?.()\n if (!workspaceKey) return\n\n try {\n const auth = await this.getAuth()\n const headers: Record<string, string> = {\n Authorization: `Bearer ${auth.token}`,\n 'Content-Type': 'application/json',\n }\n\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), this.timeoutMs)\n\n const knownVersion = this.knownVersions.get(path) ?? 0\n const nextVersion = knownVersion + 1\n\n try {\n const response = await this.fetchImpl(\n `${this.baseUrl}/memory/workspace/${encodeURIComponent(workspaceKey)}/${this.workspaceResourceId(path)}`,\n {\n method: 'PUT',\n headers,\n body: JSON.stringify({ content, version: nextVersion, expectedVersion: knownVersion }),\n signal: controller.signal,\n },\n )\n\n if (response.status === 409) {\n // 冲突:远端版本已被其他设备推进。不覆盖——写环形缓冲旁路文件(D6)。\n await this.writeConflictSidecar(path, content)\n return\n }\n\n if (response.ok) {\n this.knownVersions.set(path, nextVersion)\n }\n } finally {\n clearTimeout(timeout)\n }\n } catch {}\n }\n\n /** 环形缓冲冲突旁路文件——`<path>.conflict-<slot>`,slot 循环覆盖(M164-2 D6 实现简化)。 */\n private async writeConflictSidecar(path: string, content: string): Promise<void> {\n const counter = this.conflictCounters.get(path) ?? 0\n const slot = counter % CONFLICT_RING_SIZE\n this.conflictCounters.set(path, counter + 1)\n\n const conflictPath = `${path}.conflict-${slot}`\n try {\n await this.local.write(conflictPath, content)\n this.onConflict?.(path, conflictPath)\n } catch {}\n }\n}\n\nfunction isUserSource(path: string): boolean {\n return (\n path === '~/.otto/AGENTS.md' ||\n path === '~/.otto/MEMORY.md' ||\n path.startsWith('~/.otto/memory/')\n )\n}\n\n/**\n * workspace-scope 判定(RFC-164 M164-2 D5):workspace 根下的 auto-memory/lessons 路径\n * (`./.otto/MEMORY.md`/`./.otto/memory/*`/`./.otto/lessons.json`)。`AGENTS.md` 不在此列——\n * 继续走 git 单一权威(D4 分离处理),不受 opt-in 影响。`local/` 子目录同样不在此列——\n * M164-1 已确保其物理隔离,本判定天然不匹配 `local/` 前缀路径。\n */\nexport function isWorkspaceSource(path: string): boolean {\n return (\n path === './.otto/MEMORY.md' ||\n (path.startsWith('./.otto/memory/') && !path.startsWith('./.otto/local/')) ||\n path === './.otto/lessons.json'\n )\n}\n","import type { MemoryStore, MemorySource } from './types'\n\n/**\n * RFC-345 影子模式:MemoryStore 的 overlay 包装(读穿透 base、写只进内存)。\n *\n * 语义(对齐 Chromium OverlayUserPrefStore + MCP token store 的 overlay):\n * - write:只写内存 Map,绝不落盘/不触发远端同步;\n * - load:内存 overlay 命中优先(保证\"本会话内 record 后 memory_read 读得到\",\n * 即写后读正确性),未命中回退 base(继承磁盘/远端既有记忆,可注入 prompt)。\n *\n * 为何是 overlay 而非\"write no-op\":AutoMemory 的索引(this.index)确实是内存真源,\n * 但 memory_read → readEntry → store.load(entryPath) 读的是**条目文件内容**。若 write\n * 纯 no-op,条目文件从未进任何存储,同会话内 record 后 read 该条目会落空。overlay 让\n * 写入的条目留在内存 Map 里,load 优先命中,写后读因此成立(RFC-345 §D3)。\n *\n * 由 §D1 白名单装配点(storage-wiring.buildMemoryStore)在影子态包装 base store。\n * watch 不转发——影子会话不监听外部记忆变更(与 AuthStore overlay 关 watch 同理)。\n */\nexport function createOverlayMemoryStore(base: MemoryStore): MemoryStore {\n const overlay = new Map<string, string>()\n return {\n async load(sources: MemorySource[]): Promise<Map<string, string>> {\n // 先取 base(未命中的路径由 base 决定),再用 overlay 覆盖命中项。\n const missing = sources.filter((s) => !overlay.has(s.path))\n const result = missing.length > 0 ? await base.load(missing) : new Map<string, string>()\n for (const s of sources) {\n const ov = overlay.get(s.path)\n if (ov !== undefined) result.set(s.path, ov)\n }\n return result\n },\n async write(path: string, content: string): Promise<void> {\n overlay.set(path, content)\n },\n // watch 省略:影子态不监听外部变更。\n }\n}\n","import { existsSync, statSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\n\nimport type { MemorySource } from './types'\n\nexport interface AgentsDiscoveryOptions {\n /** 文件名,默认 'AGENTS.md'。 */\n fileName?: string\n /** 项目根标记,遇到即停(含该层),默认 ['.git']。 */\n rootMarkers?: string[]\n /** 向上层数硬上限(防符号链接环 / 异常深路径),默认 25。 */\n maxDepth?: number\n /** 收集文件数上限,超出保留最靠近 cwd 的(更相关),默认 10。 */\n maxFiles?: number\n /** 单文件字节上限,超出跳过(防巨型文件撑爆上下文),默认 64KiB。 */\n maxFileBytes?: number\n}\n\n/**\n * 从 startDir 向上发现 AGENTS.md,返回 root→cwd 顺序的只读 MemorySource(绝对路径)。\n */\nexport function discoverAgentsSources(\n startDir: string,\n opts: AgentsDiscoveryOptions = {},\n): MemorySource[] {\n const fileName = opts.fileName ?? 'AGENTS.md'\n const rootMarkers = opts.rootMarkers ?? ['.git']\n const maxDepth = opts.maxDepth ?? 25\n const maxFiles = opts.maxFiles ?? 10\n const maxFileBytes = opts.maxFileBytes ?? 64 * 1024\n\n const collected: string[] = []\n let dir = startDir\n for (let depth = 0; depth < maxDepth; depth++) {\n const candidate = join(dir, fileName)\n try {\n if (existsSync(candidate) && statSync(candidate).size <= maxFileBytes) {\n collected.push(candidate)\n }\n } catch {}\n\n const atRoot = rootMarkers.some((m) => existsSync(join(dir, m)))\n const parent = dirname(dir)\n if (atRoot || parent === dir) {\n break\n }\n dir = parent\n }\n\n const ordered = collected.reverse().slice(-maxFiles)\n return ordered.map((path) => ({ path, writable: false }))\n}\n","import { createLogger } from '@x-otto/shared'\n\nimport type { MemorySource, MemoryStore } from './types'\n\nconst log = createLogger('@x-otto/memory:auto')\n\n/**\n * AutoMemory(M13-03):可写的跨会话记忆。\n *\n * 约定(progressive disclosure):\n * - `<baseDir>/MEMORY.md` —— 索引,一行一条目指针(`- [name](memory/<slug>.md) — hook`)。\n * 每会话仅注入索引,控制 token 占用。\n * - `<baseDir>/memory/<slug>.md` —— 单事实条目全文,按需经 `read(name)` 读取。\n *\n * 存储经 `MemoryStore` 抽象(FileSystemMemoryStore / HttpMemoryStore / InMemory 同构),\n * 即本地与 remote 双后端天然同源(M13 目标)。\n *\n * 并发边界(已知设计):索引为单文件 last-write-wins,单写者假设\n * (app 级单实例持有);多实例并发 record 同名索引可能丢行。\n */\nexport interface AutoMemoryOptions {\n store: MemoryStore\n /** 记忆根目录(store 语义下的路径前缀),缺省 `./.otto`。 */\n baseDir?: string\n /**\n * 机器特定记忆根目录(RFC-164 D3/M164-1),缺省 `${baseDir}/local`。\n * `scope:'local'` 的条目落此目录——物理隔离于 `baseDir` 之外,天然不参与\n * 未来 workspace 远端同步判定(M164-2 的 `HttpMemoryStore.isWorkspaceSource()`\n * 只识别 `baseDir` 下路径,不识别 `localBaseDir`)。\n */\n localBaseDir?: string\n}\n\nexport interface AutoMemoryEntry {\n name: string\n description: string\n content: string\n}\n\n/**\n * 记忆归属域:`workspace`(RFC-164 D3)= 可移植项目知识(默认,向后兼容,结构化\n * index+entry 存储);`local`(RFC-164 D3)= 机器特定事实,永不跨设备同步;\n * `project-shared`(RFC-228 D2)= 团队可见的项目级约定,直接写 `./.otto/AGENTS.md`\n * (marker 块格式,不接入 remote workspace sync——见 `recordProjectShared` 注释)。\n */\nexport type MemoryScope = 'workspace' | 'local' | 'project-shared'\n\nconst INDEX_HEADER = '# MEMORY'\nconst POINTER_PATTERN = /^- \\[(.+?)\\]\\(memory\\/(.+?)\\.md\\)/\n/** D6 冲突旁路文件环形缓冲槽位数——与 http-memory-store.ts 的 CONFLICT_RING_SIZE 同源常量。 */\nconst CONFLICT_RING_SIZE = 5\n\n/** RFC-228 D2:project-shared 写入目标(AGENTS.md 项目级可写源,与 DEFAULT_MEMORY_SOURCES 同路径)。 */\nconst PROJECT_SHARED_PATH = './.otto/AGENTS.md'\n/** 新建 AGENTS.md 时的顶层模板——顶部为人工可编辑说明,下方追加 marker 块。 */\nconst PROJECT_SHARED_TEMPLATE_HEADER =\n '# 项目记忆(模型经 memory_record(scope: \"project-shared\") 写入,人工可编辑本文件任意内容)\\n'\n\nfunction ottoMemoryMarkerBlock(slug: string, name: string, content: string): string {\n // heading 只作展示,marker 边界定位靠 slug——name 仍需去换行防止破坏单行 heading 结构。\n const safeName = name.replace(/[\\r\\n]+/g, ' ').trim()\n return `<!-- otto-memory:start slug=\"${slug}\" -->\\n## ${safeName}\\n\\n${content.trim()}\\n<!-- otto-memory:end slug=\"${slug}\" -->`\n}\n\nfunction ottoMemoryMarkerPattern(slug: string): RegExp {\n // slug 经 slugify() 产出,只含 [a-z0-9一-鿿-],正则内无需转义特殊字符。\n return new RegExp(\n `<!-- otto-memory:start slug=\"${slug}\" -->[\\\\s\\\\S]*?<!-- otto-memory:end slug=\"${slug}\" -->`,\n )\n}\n\nexport class AutoMemory {\n private readonly store: MemoryStore\n private readonly baseDir: string\n private readonly localBaseDir: string\n private index = ''\n /** local scope 的独立索引(不与 workspace 索引合并,读取侧透明查找)。 */\n private localIndex = ''\n private loaded = false\n private localLoaded = false\n\n constructor(options: AutoMemoryOptions) {\n this.store = options.store\n this.baseDir = (options.baseDir ?? './.otto').replace(/\\/+$/, '')\n this.localBaseDir = (options.localBaseDir ?? `${this.baseDir}/local`).replace(/\\/+$/, '')\n }\n\n get indexPath(): string {\n return `${this.baseDir}/MEMORY.md`\n }\n\n entryPathOf(slug: string): string {\n return `${this.baseDir}/memory/${slug}.md`\n }\n\n get localIndexPath(): string {\n return `${this.localBaseDir}/MEMORY.md`\n }\n\n localEntryPathOf(slug: string): string {\n return `${this.localBaseDir}/memory/${slug}.md`\n }\n\n /** PersistentMemory sources 接入点:索引作为一个可写 L1 源注入。 */\n asSource(): MemorySource {\n return { path: this.indexPath, writable: true }\n }\n\n /** local 索引同样作为一个可写 L1 源注入(RFC-164 grill 已确认:常驻注入,不做按需裁剪)。 */\n asLocalSource(): MemorySource {\n return { path: this.localIndexPath, writable: true }\n }\n\n async load(): Promise<void> {\n const loadedSources = await this.store.load([this.asSource()])\n this.index = loadedSources.get(this.indexPath) ?? ''\n this.loaded = true\n }\n\n async loadLocal(): Promise<void> {\n const loadedSources = await this.store.load([this.asLocalSource()])\n this.localIndex = loadedSources.get(this.localIndexPath) ?? ''\n this.localLoaded = true\n }\n\n /**\n * 注入文本 = 索引原文 + 读取导语(空索引 → 空串,不占 token)。\n * 导语说明条目的真实存放位置(可能 ≠ workspace 相对路径,review F4)。\n */\n getInjection(): string {\n const index = this.index.trim()\n if (!index) {\n return ''\n }\n return `${index}\\n\\n> Auto-memory entries live in the memory store (\\`${this.baseDir}/memory/\\`), not workspace files — read them with the memory_read tool; record new durable facts with memory_record.`\n }\n\n /** local 索引的注入文本(同结构,指向 localBaseDir,供组合注入到独立来源段落)。 */\n getLocalInjection(): string {\n const index = this.localIndex.trim()\n if (!index) {\n return ''\n }\n return `${index}\\n\\n> Machine-specific memory entries live in \\`${this.localBaseDir}/memory/\\` — never synced to other devices. Read with memory_read; record with memory_record (scope: 'local').`\n }\n\n async record(entry: AutoMemoryEntry, scope: MemoryScope = 'workspace'): Promise<void> {\n if (scope === 'local') {\n await this.recordLocal(entry)\n return\n }\n if (scope === 'project-shared') {\n await this.recordProjectShared(entry)\n return\n }\n await this.ensureLoaded()\n\n const slug = this.resolveSlug(entry.name, this.indexLines())\n await this.store.write(this.entryPathOf(slug), `# ${entry.name}\\n\\n${entry.content}\\n`)\n\n const pointer = `- [${entry.name}](memory/${slug}.md) — ${entry.description}`\n const lines = this.indexLines().filter((line) => !line.includes(`(memory/${slug}.md)`))\n if (!lines.some((line) => line.startsWith(INDEX_HEADER))) {\n lines.unshift(INDEX_HEADER)\n }\n lines.push(pointer)\n\n this.index = `${lines.join('\\n')}\\n`\n await this.store.write(this.indexPath, this.index)\n log.info(`Auto-memory recorded: ${slug}`)\n }\n\n private async recordLocal(entry: AutoMemoryEntry): Promise<void> {\n await this.ensureLocalLoaded()\n\n const slug = this.resolveSlug(entry.name, this.localIndexLines())\n await this.store.write(this.localEntryPathOf(slug), `# ${entry.name}\\n\\n${entry.content}\\n`)\n\n const pointer = `- [${entry.name}](memory/${slug}.md) — ${entry.description}`\n const lines = this.localIndexLines().filter((line) => !line.includes(`(memory/${slug}.md)`))\n if (!lines.some((line) => line.startsWith(INDEX_HEADER))) {\n lines.unshift(INDEX_HEADER)\n }\n lines.push(pointer)\n\n this.localIndex = `${lines.join('\\n')}\\n`\n await this.store.write(this.localIndexPath, this.localIndex)\n log.info(`Auto-memory recorded (local scope): ${slug}`)\n }\n\n /**\n * RFC-228 D2:`project-shared` scope——直接写 `./.otto/AGENTS.md`(marker 块格式),\n * 收编此前\"模型直接 edit/write AGENTS.md\"的旁路(见 `prompts.ts` 的 `<memory_guidelines>`\n * 文案变更,D3)。**不接入 remote workspace sync**——`HttpMemoryStore.isWorkspaceSource()`\n * 不识别 `./.otto/AGENTS.md`,AGENTS.md 继续走 git 单一权威协作模型,与 lessons.json/\n * MEMORY.md 的\"多机自动同步\"模型不同(RFC-228 D2 决策)。\n *\n * **写前 load-latest**(不依赖内存缓存的旧快照):每次调用都重新 `store.load()` 取当前磁盘\n * 最新内容,避免同会话内先前的注入快照覆盖并发写入(人工编辑或另一进程)的内容。\n *\n * slug 复用 `resolveSlug`(同名复用、异名消歧),marker 块按 slug 精确定位替换,不依赖\n * heading 文本本身(避免 `entry.content` 内嵌 `##`/换行破坏边界)。\n */\n private async recordProjectShared(entry: AutoMemoryEntry): Promise<void> {\n const loaded = await this.store.load([{ path: PROJECT_SHARED_PATH, writable: true }])\n const current = loaded.get(PROJECT_SHARED_PATH) ?? ''\n\n // slug 复用现有 marker 块的既有 slug(按去换行后的 name 精确匹配已有块的 heading,\n // 与写入时的 safeName 处理保持一致),否则走 slugify 消歧。\n const safeName = entry.name.replace(/[\\r\\n]+/g, ' ').trim()\n const existingSlugMatch = new RegExp(\n `<!-- otto-memory:start slug=\"([^\"]+)\" -->\\\\n## ${escapeRegExp(safeName)}\\\\n`,\n ).exec(current)\n const slug = existingSlugMatch?.[1] ?? slugify(entry.name)\n\n const block = ottoMemoryMarkerBlock(slug, entry.name, entry.content)\n const pattern = ottoMemoryMarkerPattern(slug)\n\n let next: string\n if (pattern.test(current)) {\n next = current.replace(pattern, block)\n } else if (current.trim() === '') {\n next = `${PROJECT_SHARED_TEMPLATE_HEADER}\\n${block}\\n`\n } else {\n next = `${current.trimEnd()}\\n\\n${block}\\n`\n }\n\n await this.store.write(PROJECT_SHARED_PATH, next)\n log.info(`Auto-memory recorded (project-shared scope): ${slug}`)\n }\n\n /**\n * 读取侧透明合并:先查 workspace 索引,未命中再查 local 索引(RFC-164 §4.2)。\n * RFC-164 M164-2 D6:读到的条目若存在冲突旁路文件(`<path>.conflict-0..4`),\n * 前缀 `[CONFLICT]` 标签——避免 agent 把冲突副本当作等同权威记忆使用(子agent评审 F3)。\n */\n async read(name: string): Promise<string | null> {\n await this.ensureLoaded()\n\n const indexedSlug = this.slugByName(name, this.indexLines())\n const workspaceSlug = indexedSlug ?? slugify(name)\n const workspacePath = this.entryPathOf(workspaceSlug)\n const workspaceContent = await this.readEntry(workspacePath)\n if (workspaceContent !== null) {\n return await this.withConflictLabel(workspacePath, workspaceContent)\n }\n\n await this.ensureLocalLoaded()\n const localSlug = this.slugByName(name, this.localIndexLines()) ?? slugify(name)\n const localPath = this.localEntryPathOf(localSlug)\n const localContent = await this.readEntry(localPath)\n if (localContent === null) return null\n return await this.withConflictLabel(localPath, localContent)\n }\n\n /** 探测 `<path>.conflict-0..4` 旁路文件是否存在,存在则前缀 `[CONFLICT]` 标签。 */\n private async withConflictLabel(path: string, content: string): Promise<string> {\n for (let slot = 0; slot < CONFLICT_RING_SIZE; slot++) {\n const conflictPath = `${path}.conflict-${slot}`\n const found = await this.readEntry(conflictPath)\n if (found !== null) {\n return `[CONFLICT] Concurrent edit detected for \"${path}\" — review \"${conflictPath}\" and reconcile manually.\\n\\n${content}`\n }\n }\n return content\n }\n\n private async readEntry(path: string): Promise<string | null> {\n const loadedSources = await this.store.load([{ path, writable: false }])\n const content = loadedSources.get(path)\n return content && content.trim() !== '' ? content : null\n }\n\n async forget(name: string): Promise<void> {\n await this.ensureLoaded()\n await this.ensureLocalLoaded()\n\n // 向后兼容既有行为:未登记指针的 name 按 slugify 猜测路径处理(即便索引里没有该指针,\n // 仍尝试清空对应条目文件——与改造前的 workspace-only 行为一致)。\n const slug = this.slugByName(name, this.indexLines()) ?? slugify(name)\n this.index = `${this.indexLines()\n .filter((line) => !line.includes(`(memory/${slug}.md)`))\n .join('\\n')}\\n`\n await this.store.write(this.indexPath, this.index)\n await this.store.write(this.entryPathOf(slug), '')\n\n const localSlug = this.slugByName(name, this.localIndexLines())\n if (localSlug) {\n this.localIndex = `${this.localIndexLines()\n .filter((line) => !line.includes(`(memory/${localSlug}.md)`))\n .join('\\n')}\\n`\n await this.store.write(this.localIndexPath, this.localIndex)\n await this.store.write(this.localEntryPathOf(localSlug), '')\n }\n }\n\n /** record/forget 前确保索引已加载(review F1:防未 load 的 record 清空既有索引)。 */\n private async ensureLoaded(): Promise<void> {\n if (!this.loaded) {\n await this.load()\n }\n }\n\n private async ensureLocalLoaded(): Promise<void> {\n if (!this.localLoaded) {\n await this.loadLocal()\n }\n }\n\n private indexLines(): string[] {\n return this.index.split('\\n').filter((line) => line.trim() !== '')\n }\n\n private localIndexLines(): string[] {\n return this.localIndex.split('\\n').filter((line) => line.trim() !== '')\n }\n\n /** 索引中 name → slug 的既有映射(精确名匹配)。传入 lines 使 workspace/local 两套索引共用同一实现。 */\n private slugByName(name: string, lines: string[]): string | null {\n for (const line of lines) {\n const match = POINTER_PATTERN.exec(line)\n if (match && match[1] === name) {\n return match[2] ?? null\n }\n }\n return null\n }\n\n /** slug 解析:同名复用既有 slug;异名撞 slug 追加序号消歧(review F2)。传入 lines 使 workspace/local 两套索引共用同一实现。 */\n private resolveSlug(name: string, lines: string[]): string {\n const existing = this.slugByName(name, lines)\n if (existing) {\n return existing\n }\n\n const taken = new Map<string, string>()\n for (const line of lines) {\n const match = POINTER_PATTERN.exec(line)\n if (match && match[1] !== undefined && match[2] !== undefined) {\n taken.set(match[2], match[1])\n }\n }\n\n const base = slugify(name)\n let candidate = base\n let suffix = 2\n while (taken.has(candidate) && taken.get(candidate) !== name) {\n candidate = `${base}-${suffix}`\n suffix += 1\n }\n return candidate\n }\n}\n\nfunction slugify(name: string): string {\n return name\n .toLowerCase()\n .replace(/[^a-z0-9一-鿿]+/g, '-')\n .replace(/^-+|-+$/g, '')\n}\n\n/** 转义正则特殊字符——`entry.name` 可含任意用户文本,拼进动态正则前必须转义。 */\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n","import { createLogger } from '@x-otto/shared'\n\nimport { buildExtractionPrompt } from './prompts'\nimport { messageToText } from './token-estimator'\n\nimport type { Lesson, LessonInput } from './operational-learning'\nimport type { Message } from '@x-otto/ai'\n\nconst logger = createLogger('@x-otto/memory:extractor')\n\n/**\n * 抽取器调用 LLM 的接缝——与 compaction 的 `summarize` 同形。memory 包保持纯净:运行时\n * 再决定注入 forked-agent 还是复用主 summarize。\n */\nexport type ExtractLLM = (\n prompt: string,\n options?: { maxTokens?: number; signal?: AbortSignal },\n) => Promise<string>\n\nexport interface ExtractionOptions {\n /** 抽取调用的 token 上限。默认 DEFAULT_MAX_TOKENS=2048。 */\n maxTokens?: number\n signal?: AbortSignal\n /** 单次抽取最多产出多少条 lesson(防爆 / 防召回污染)。默认 5。 */\n maxLessons?: number\n}\n\ninterface RawLesson {\n trigger?: unknown\n insight?: unknown\n tags?: unknown\n}\n\nconst DEFAULT_MAX_LESSONS = 5\nconst DEFAULT_MAX_TOKENS = 2048\nconst MAX_TRIGGER_LEN = 500\nconst MAX_INSIGHT_LEN = 2000\nconst MAX_TAGS = 5\n\n/**\n * 从会话切片抽取跨会话经验(Lesson)。\n *\n * **fail-closed**:LLM 失败/超时/解析失败一律返回 `[]`,绝不抛——抽取永不阻塞、\n * 不污染主回合。本函数是纯库逻辑(经 `ExtractLLM` 注入模型调用),不依赖 agent/ForkRunner;\n * 触发节流、不与主 agent 抢、去重写入由上层负责。\n *\n * @param messages 会话切片(通常是上次抽取后的新增消息)\n * @param sourceSessionId 产出 lesson 的来源会话 id(写入 `LessonInput.sourceSessionId`)\n * @param llm 模型调用接缝\n * @returns 规整后的 `LessonInput[]`(可直接喂 `OperationalLearningStore.save`),失败为空数组\n */\nexport async function extractLessons(\n messages: readonly Message[],\n sourceSessionId: string,\n llm: ExtractLLM,\n options: ExtractionOptions = {},\n): Promise<LessonInput[]> {\n const conversationText = formatConversation(messages)\n if (!conversationText.trim()) {\n return []\n }\n\n let raw: string\n try {\n raw = await llm(buildExtractionPrompt(conversationText), {\n maxTokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,\n signal: options.signal,\n })\n } catch (err) {\n logger.debug(`extraction LLM call failed (fail-closed → []): ${String(err)}`)\n return []\n }\n\n const parsed = parseLessons(raw)\n if (!parsed) {\n return []\n }\n\n const max = options.maxLessons ?? DEFAULT_MAX_LESSONS\n const lessons: LessonInput[] = []\n for (const item of parsed) {\n const lesson = normalizeLesson(item, sourceSessionId)\n if (lesson) {\n lessons.push(lesson)\n }\n if (lessons.length >= max) {\n break\n }\n }\n return lessons\n}\n\n/** `saveExtractedLessons` 依赖的最小 store 接口——便于单测注入 in-memory store。 */\nexport interface LessonWriteStore {\n saveDeduped(input: LessonInput, threshold?: number): Promise<{ lesson: Lesson; deduped: boolean }>\n}\n\nexport interface SaveExtractedResult {\n /** 真正写入的新经验。 */\n saved: Lesson[]\n /** 因命中近似既有经验而跳过的数量(去重)。 */\n skippedSimilar: number\n}\n\nexport interface SaveExtractedOptions {\n /** 去重阈值,透传 `findSimilar`。省略用 store 默认值。 */\n similarityThreshold?: number\n}\n\n/**\n * 去重前置写入:把抽取出的候选 `LessonInput[]` 逐条写入 store——**写前必 `findSimilar`**,\n * 命中近似既有经验则跳过,否则 `save`。严禁无去重批量追加(否则 lesson 爆炸→召回退化+持久化膨胀)。\n *\n * 批内去重天然成立:顺序写入 + `findSimilar` 查实时 store,故后续候选会命中刚写入的同批经验而跳过。\n */\nexport async function saveExtractedLessons(\n store: LessonWriteStore,\n candidates: readonly LessonInput[],\n options: SaveExtractedOptions = {},\n): Promise<SaveExtractedResult> {\n const saved: Lesson[] = []\n let skippedSimilar = 0\n\n for (const candidate of candidates) {\n const { lesson, deduped } = await store.saveDeduped(candidate, options.similarityThreshold)\n if (deduped) {\n skippedSimilar++\n } else {\n saved.push(lesson)\n }\n }\n\n return { saved, skippedSimilar }\n}\n\nfunction formatConversation(messages: readonly Message[]): string {\n return messages\n .map((msg) => {\n const role =\n msg.role === 'user'\n ? 'Human'\n : msg.role === 'assistant'\n ? 'Assistant'\n : msg.role === 'tool_result'\n ? `Tool[${msg.toolName ?? 'unknown'}]`\n : 'System'\n return `${role}: ${messageToText(msg)}`\n })\n .join('\\n\\n')\n}\n\nfunction parseLessons(raw: string): RawLesson[] | null {\n const start = raw.indexOf('[')\n const end = raw.lastIndexOf(']')\n if (start === -1 || end === -1 || end < start) {\n return null\n }\n try {\n const parsed: unknown = JSON.parse(raw.slice(start, end + 1))\n return Array.isArray(parsed) ? (parsed as RawLesson[]) : null\n } catch {\n return null\n }\n}\n\nfunction normalizeLesson(item: RawLesson, sourceSessionId: string): LessonInput | null {\n if (!item || typeof item !== 'object') {\n return null\n }\n const trigger = typeof item.trigger === 'string' ? item.trigger.trim() : ''\n const insight = typeof item.insight === 'string' ? item.insight.trim() : ''\n if (!trigger || !insight) {\n return null\n }\n const tags = Array.isArray(item.tags)\n ? item.tags\n .filter((t): t is string => typeof t === 'string' && t.trim().length > 0)\n .map((t) => t.trim().toLowerCase())\n .slice(0, MAX_TAGS)\n : []\n return {\n trigger: trigger.slice(0, MAX_TRIGGER_LEN),\n insight: insight.slice(0, MAX_INSIGHT_LEN),\n tags,\n sourceSessionId,\n }\n}\n","import { createLogger } from '@x-otto/shared'\n\nimport { extractLessons, saveExtractedLessons } from './memory-extractor'\n\nimport type { ExtractLLM, LessonWriteStore } from './memory-extractor'\nimport type { Message } from '@x-otto/ai'\n\nconst logger = createLogger('@x-otto/memory:auto-extract')\n\n/** 编排器配置(camelCase 运行时形态;App 层从 snake_case settings 映射)。 */\nexport interface MemoryAutoExtractConfig {\n /** 总开关。默认关——灰度验证信噪比后再放开(R6)。 */\n enabled: boolean\n /** 节流:每 N 次 idle 才抽取一次(累积切片)。默认 1(每次 idle 都抽)。 */\n everyNTurns?: number\n /** 单次抽取上限,透传 extractLessons。 */\n maxLessons?: number\n /** 去重阈值,透传 saveExtractedLessons。 */\n similarityThreshold?: number\n /** 抽取调用 token 上限,透传 extractLessons。 */\n maxTokens?: number\n}\n\nexport interface MemoryAutoExtractorDeps {\n llm: ExtractLLM\n store: LessonWriteStore\n config: MemoryAutoExtractConfig\n /**\n * R5 不与主 agent 抢:判定某工具调用是否算\"记忆写入\"——若本批切片里主 agent 已写过记忆,\n * 跳过本次抽取。默认认 `learn` 工具与名字含 `memory` 的工具。\n */\n isMemoryWriteTool?: (toolName: string) => boolean\n}\n\nexport type AutoExtractReason =\n | 'disabled'\n | 'no-new-messages'\n | 'throttled'\n | 'main-agent-wrote'\n | 'error'\n\nexport interface AutoExtractOutcome {\n ran: boolean\n reason?: AutoExtractReason\n saved: number\n skippedSimilar: number\n}\n\ninterface SessionState {\n lastExtractedIndex: number\n\n idleCount: number\n}\n\nconst DEFAULT_EVERY_N_TURNS = 1\n\nfunction defaultIsMemoryWriteTool(toolName: string): boolean {\n return toolName === 'learn' || toolName === 'memory_record'\n}\n\nfunction hasMemoryWrite(\n messages: readonly Message[],\n isMemoryWriteTool: (toolName: string) => boolean,\n): boolean {\n for (const msg of messages) {\n if (msg.role !== 'assistant') {\n continue\n }\n for (const part of msg.content) {\n if (part.type === 'tool_call' && isMemoryWriteTool(part.name)) {\n return true\n }\n }\n }\n return false\n}\n\n/**\n * 会话经验自动抽取编排器(纯库 + 全注入)。\n *\n * 在每次会话 idle(由 runtime 订阅 `session.idle`/`turn.end` 触发)调 `onIdle`——按\n * gate(R6 默认关)→ 节流(R7)→ 不抢(R5)→ 抽取(fail-closed)→ 去重写入(R4)流程跑。\n * **永不抛**(R2):任何失败返回 `{ran:false, reason:'error'}`,绝不阻塞或污染主回合。\n * runtime 层只需提供 `llm`(复用 summarize 接缝)、`store`、`config`,并在每次 idle 把会话\n * 全量 messages 传进来——编排器自己按 `lastExtractedIndex` 切增量。\n */\nexport function createMemoryAutoExtractor(deps: MemoryAutoExtractorDeps): {\n onIdle: (\n sessionId: string,\n messages: readonly Message[],\n opts?: { signal?: AbortSignal },\n ) => Promise<AutoExtractOutcome>\n reset: (sessionId: string) => void\n} {\n const states = new Map<string, SessionState>()\n const isMemoryWriteTool = deps.isMemoryWriteTool ?? defaultIsMemoryWriteTool\n // `everyNTurns` 每次 onIdle 读取(而非工厂构造期定档一次)——App 层用 getter 形态的\n // config 从 settings 惰性映射(settings 在 App 构造期尚未 load,构造期定档会永远读到\n // 空值,见 coding/app.ts 的 settings 时序注释)。maxLessons/similarityThreshold 本就\n // 在 onIdle 内读,此处对齐 everyNTurns 的求值时机。\n const resolveEveryNTurns = (): number => Math.max(1, deps.config.everyNTurns ?? DEFAULT_EVERY_N_TURNS)\n\n function getState(sessionId: string): SessionState {\n let state = states.get(sessionId)\n if (!state) {\n state = { lastExtractedIndex: 0, idleCount: 0 }\n states.set(sessionId, state)\n }\n return state\n }\n\n const idle = (saved = 0, skippedSimilar = 0, reason?: AutoExtractReason): AutoExtractOutcome => ({\n ran: reason === undefined,\n reason,\n saved,\n skippedSimilar,\n })\n\n return {\n async onIdle(sessionId, messages, opts) {\n if (!deps.config.enabled) {\n return idle(0, 0, 'disabled')\n }\n\n const state = getState(sessionId)\n const slice = messages.slice(state.lastExtractedIndex)\n if (slice.length === 0) {\n return idle(0, 0, 'no-new-messages')\n }\n\n state.idleCount += 1\n if (state.idleCount < resolveEveryNTurns()) {\n return idle(0, 0, 'throttled')\n }\n\n const consume = (): void => {\n state.lastExtractedIndex = messages.length\n state.idleCount = 0\n }\n\n if (hasMemoryWrite(slice, isMemoryWriteTool)) {\n consume()\n return idle(0, 0, 'main-agent-wrote')\n }\n\n try {\n const candidates = await extractLessons(slice, sessionId, deps.llm, {\n maxTokens: deps.config.maxTokens,\n maxLessons: deps.config.maxLessons,\n signal: opts?.signal,\n })\n const result = await saveExtractedLessons(deps.store, candidates, {\n similarityThreshold: deps.config.similarityThreshold,\n })\n consume()\n\n if (candidates.length > 0) {\n logger.info(\n {\n sessionId,\n extracted: candidates.length,\n saved: result.saved.length,\n deduped: result.skippedSimilar,\n },\n 'memory-auto-extract',\n )\n }\n return idle(result.saved.length, result.skippedSimilar)\n } catch (err) {\n logger.warn(`auto-extract failed (fail-closed): ${String(err)}`)\n return idle(0, 0, 'error')\n }\n },\n\n reset(sessionId) {\n states.delete(sessionId)\n },\n }\n}\n","import { readdir, stat } from 'node:fs/promises'\nimport { join, relative } from 'node:path'\n\nexport interface FileStateSnapshot {\n timestamp: number\n directoryTree: string\n modifiedFiles: Array<{\n path: string\n action: 'created' | 'modified' | 'deleted'\n summary: string\n }>\n planStatus?: string\n findings: string[]\n}\n\nexport interface FileStateCapture {\n workspaceDir: string\n recentFiles?: string[]\n planStatus?: string\n}\n\nconst IGNORED_DIRS = new Set([\n 'node_modules',\n '.git',\n 'dist',\n '.next',\n '.turbo',\n 'coverage',\n '.cache',\n '__pycache__',\n '.vscode',\n])\n\nconst MAX_TREE_DEPTH = 4\nconst MAX_ENTRIES_PER_DIR = 50\n\nexport class FileStateManager {\n private lastSnapshot: FileStateSnapshot | null = null\n\n async capture(input: FileStateCapture): Promise<FileStateSnapshot> {\n const [directoryTree, modifiedFiles] = await Promise.all([\n this.buildDirectoryTree(input.workspaceDir, 0),\n this.checkRecentFiles(input.workspaceDir, input.recentFiles ?? []),\n ])\n\n const findings: string[] = []\n if (modifiedFiles.length > 0) {\n findings.push(`${modifiedFiles.length} recently modified file(s) detected`)\n }\n\n const snapshot: FileStateSnapshot = {\n timestamp: Date.now(),\n directoryTree,\n modifiedFiles,\n planStatus: input.planStatus,\n findings,\n }\n\n this.lastSnapshot = snapshot\n return snapshot\n }\n\n getLastSnapshot(): FileStateSnapshot | null {\n return this.lastSnapshot\n }\n\n formatSnapshot(snapshot: FileStateSnapshot): string {\n const parts: string[] = [\n `[File State Snapshot @ ${new Date(snapshot.timestamp).toISOString()}]`,\n ]\n\n if (snapshot.directoryTree) {\n parts.push(`Directory:\\n${snapshot.directoryTree}`)\n }\n\n if (snapshot.modifiedFiles.length > 0) {\n const fileLines = snapshot.modifiedFiles\n .map((f) => ` ${f.action}: ${f.path} — ${f.summary}`)\n .join('\\n')\n parts.push(`Modified Files:\\n${fileLines}`)\n }\n\n if (snapshot.planStatus) {\n parts.push(`Plan Status: ${snapshot.planStatus}`)\n }\n\n if (snapshot.findings.length > 0) {\n parts.push(`Findings:\\n${snapshot.findings.map((f) => ` - ${f}`).join('\\n')}`)\n }\n\n return parts.join('\\n\\n')\n }\n\n private async buildDirectoryTree(dir: string, depth: number, prefix = ''): Promise<string> {\n if (depth >= MAX_TREE_DEPTH) {\n return prefix ? `${prefix}...\\n` : ''\n }\n\n let entries: string[]\n try {\n const dirEntries = await readdir(dir, { withFileTypes: true })\n entries = dirEntries\n .filter((e) => !IGNORED_DIRS.has(e.name) && !e.name.startsWith('.'))\n .sort((a, b) => {\n if (a.isDirectory() !== b.isDirectory()) {\n return a.isDirectory() ? -1 : 1\n }\n return a.name.localeCompare(b.name)\n })\n .slice(0, MAX_ENTRIES_PER_DIR)\n .map((e) => e.name + (e.isDirectory() ? '/' : ''))\n } catch {\n return ''\n }\n\n const lines: string[] = []\n for (const entry of entries) {\n const isDir = entry.endsWith('/')\n lines.push(`${prefix}${entry}`)\n if (isDir) {\n const subTree = await this.buildDirectoryTree(\n join(dir, entry.slice(0, -1)),\n depth + 1,\n prefix + ' ',\n )\n if (subTree) {\n lines.push(subTree.trimEnd())\n }\n }\n }\n\n return lines.join('\\n')\n }\n\n private async checkRecentFiles(\n workspaceDir: string,\n recentFiles: string[],\n ): Promise<FileStateSnapshot['modifiedFiles']> {\n const results: FileStateSnapshot['modifiedFiles'] = []\n const now = Date.now()\n\n const CREATED_WINDOW_MS = 1000\n\n for (const filePath of recentFiles) {\n const fullPath = filePath.startsWith('/') ? filePath : join(workspaceDir, filePath)\n const relPath = relative(workspaceDir, fullPath)\n try {\n const fileStat = await stat(fullPath)\n const age = now - fileStat.mtimeMs\n const createdNotModified =\n fileStat.birthtimeMs > 0 && fileStat.mtimeMs - fileStat.birthtimeMs < CREATED_WINDOW_MS\n const action = createdNotModified ? 'created' : 'modified'\n const summary = `${(fileStat.size / 1024).toFixed(1)}KB, modified ${formatAge(age)} ago`\n results.push({ path: relPath, action, summary })\n } catch {\n results.push({ path: relPath, action: 'deleted', summary: 'file not found' })\n }\n }\n\n return results\n }\n}\n\nfunction formatAge(ms: number): string {\n const seconds = Math.floor(ms / 1000)\n if (seconds < 60) {\n return `${seconds}s`\n }\n const minutes = Math.floor(seconds / 60)\n if (minutes < 60) {\n return `${minutes}m`\n }\n const hours = Math.floor(minutes / 60)\n return `${hours}h`\n}\n","import { basename, dirname, extname } from 'node:path'\nimport { createPersistence, saveDocument } from '@x-otto/persistence'\n\nimport type { Persistence } from '@x-otto/persistence'\nimport type { MemoryStore } from './types'\n\n/**\n * RFC-284 D2:经验的语义类别,与 `state` 生命周期正交。\n *\n * 判别基准是「换一个技术栈后是否还成立」:\n * - `norm` 在任何项目都成立(如「禁用 git stash」)→ 常驻 Core Pack。\n * - `fact` 只在碰到该技术栈时才需要(如「undici 默认 keep-alive 会让第二个请求挂起」)→ 按需检索。\n * - `case` 是某次落地/排障的纪要 → 只供 explain/审计。\n * - `ephemeral` 绑定某台机器/某次基线 → TTL 到期归档。\n */\nexport type LessonClass = 'norm' | 'fact' | 'case' | 'ephemeral'\n\n/**\n * RFC-284 D2:经验的生命周期状态。\n *\n * `live` 表示当前会话的 stable snapshot 仍引用它——内存压力也不得淘汰,\n * 只能从下一个会话起生效(对齐 Flutter ImageCache 对 live image 的处理)。\n */\nexport type LessonState = 'active' | 'live' | 'archived' | 'tombstone'\n\nexport interface Lesson {\n id: string\n tags: string[]\n trigger: string\n insight: string\n sourceSessionId: string\n createdAt: number\n /**\n * @deprecated RFC-284 D8:改用 `relevantHitCount`。\n *\n * 该字段唯一自增点是 `search()`,而注入路径走 `list()` 且 `search()` 零生产调用方,\n * 故它在历史数据中结构性恒为 0(详见 RFC-036 §8.9 勘误)。保留仅为向后兼容:\n * 读取时与 `relevantHitCount` 双向同步,不再作为新的判据来源。\n */\n appliedCount: number\n\n // ── RFC-284 M1 新增(全部可选,保证 v1 数据零改动即可读入)────────────\n\n /** 语义类别。缺省视为未归类,由 M4 审计填充。 */\n class?: LessonClass\n /** 生命周期状态。缺省视为 `active`。 */\n state?: LessonState\n /** 作用域。由所属 store 类型决定,不从 v1 正文推断。 */\n scope?: 'workspace' | 'local'\n /** 注入文本的内容哈希——只由 content 字段决定,统计字段变化不得影响它。 */\n contentHash?: string\n /** 注入成本估算(CJK 加权)。用于预算核算,不参与 `contentHash`。 */\n estimatedTokens?: number\n /** 被 Relevant Hints 检索命中的次数(承接旧 `appliedCount` 语义)。 */\n relevantHitCount?: number\n /** 进入 stable Core Pack 的次数。 */\n coreInjectedCount?: number\n /** 任意 selector 命中总数。 */\n hitCountTotal?: number\n /** 最近一次被 selector 命中的时间。 */\n lastAccessedAt?: number\n /** 用户或系统固定,不得自动归档。 */\n pinned?: boolean\n /** 被哪条经验取代(同主题合并后指向合并条款)。 */\n supersededBy?: string\n /** 合并来源——规范条款保留原始 lesson id,维持 AGENTS.md 引用可追溯性。 */\n mergedFrom?: string[]\n}\n\nexport type LessonInput = Omit<Lesson, 'id' | 'createdAt' | 'appliedCount'>\n\nexport interface LessonFilter {\n tags?: string[]\n query?: string\n}\n\nexport interface SimilarLesson {\n lesson: Lesson\n /** Jaccard 词集相似度 [0,1]。 */\n similarity: number\n}\n\n/** 默认去重阈值——trigger+insight 词集 Jaccard ≥ 此值即视作近似重复。 */\nexport const DEFAULT_SIMILARITY_THRESHOLD = 0.6\n\nfunction toWordSet(text: string): Set<string> {\n return new Set(\n text\n .toLowerCase()\n .split(/[^\\p{L}\\p{N}]+/u)\n .filter((w) => w.length > 2),\n )\n}\n\nfunction jaccard(a: Set<string>, b: Set<string>): number {\n if (a.size === 0 || b.size === 0) {\n return 0\n }\n let intersection = 0\n for (const w of a) {\n if (b.has(w)) {\n intersection++\n }\n }\n return intersection / (a.size + b.size - intersection)\n}\n\nexport interface LearningStoreOptions {\n /** 本地文件持久化路径(与 `store` 互斥;两者都缺省时纯内存,不持久化)。 */\n path?: string\n /**\n * RFC-164 M164-2 D5:可选 `MemoryStore` 抽象接入——提供时,读写改走\n * `store.load()`/`store.write()`(序列化为 JSON 字符串),使 lessons.json 与\n * auto-memory 走同一远端同步管道(不再是碎片化的\"auto-memory 同步了但 lessons 没同步\")。\n * 与 `path` 二选一:`store` 优先;两者都提供时 `store` 生效、`path` 被忽略(`store` 场景下\n * 仍用 `path` 派生 `storeKey`,即 store 语义下的路径,通常与 `path` 同值)。\n */\n store?: MemoryStore\n /** `store` 模式下的存储路径(store 语义下的 key/path)。仅 `store` 提供时使用,缺省复用 `path`。 */\n storePath?: string\n /**\n * 可选 logger——提供时用于诊断加载/持久化错误(warn 级别)。\n * 默认不打印,避免影响现有调用方。\n */\n logger?: { warn: (...args: unknown[]) => void }\n /**\n * RFC-284 D11:本 store 承载的作用域。由装配方按 store 类型传入\n * (workspace store 传 'workspace',local store 传 'local'),\n * 不从 lesson 正文推断——scope 是存储边界属性,不是内容属性。\n */\n scope?: 'workspace' | 'local'\n /**\n * RFC-345 §D3:影子模式——persist() 整体短路(唯一写盘出口)。\n *\n * 教训子系统是内存优先架构(this.lessons Map 为真源,list/get/search 全读 Map),\n * persist() 只是把 Map 刷盘(§E2 已实证)。短路 persist 后写后读天然正确(写进 Map、\n * 读从 Map),同会话内行为与普通模式一致,仅退出后不留痕。覆盖 store 与 persistence\n * 两个写盘分支于单点。由 §D1 白名单点 agent-services-setup 从 isShadowModeEnabled 派生。\n */\n ephemeral?: boolean\n}\n\ninterface LessonCollection {\n lessons: Lesson[]\n nextId: number\n}\n\n/**\n * v1 = RFC-036 起的 `{version:1, data:{lessons,nextId}}`。\n * v2 = RFC-284 M1,同结构但 lesson 携带 class/state/contentHash 等治理字段。\n *\n * 结构未变,只是字段变丰富——故 v1 数据无需转换即可读入,写出时升 v2。\n */\nconst SNAPSHOT_VERSION = 2\nconst SUPPORTED_SNAPSHOT_VERSIONS = new Set([1, 2])\n\n/** 估算注入 token(CJK≈1/字,ASCII≈4 字符/token)。与 RFC-284 探针脚本同口径。 */\nexport function estimateLessonTokens(lesson: Pick<Lesson, 'tags' | 'trigger' | 'insight'>): number {\n const text = `[${lesson.tags.join(', ')}] ${lesson.trigger} → ${lesson.insight}`\n let cjk = 0\n for (const ch of text) {\n if (/[\\u3000-\\u9fff\\uff00-\\uffef]/.test(ch)) {\n cjk++\n }\n }\n return Math.ceil(cjk + (text.length - cjk) / 4)\n}\n\n/**\n * 内容哈希——**只覆盖进入注入文本的字段**(RFC-284 D6)。\n *\n * 统计字段(hitCount/lastAccessedAt/…)绝不参与,否则每次 selector 命中都会\n * 改变 packId、逐轮击穿 prompt cache 的 stable 前缀。\n */\nexport function computeLessonContentHash(\n lesson: Pick<Lesson, 'tags' | 'trigger' | 'insight'>,\n): string {\n const canonical = JSON.stringify({\n tags: [...lesson.tags].sort(),\n trigger: lesson.trigger,\n insight: lesson.insight,\n })\n // FNV-1a 32 位——够用于变更检测,避免为此引入 crypto 依赖。\n let hash = 0x811c9dc5\n for (let i = 0; i < canonical.length; i++) {\n hash ^= canonical.charCodeAt(i)\n hash = Math.imul(hash, 0x01000193) >>> 0\n }\n return hash.toString(16).padStart(8, '0')\n}\n\n/**\n * 把任意版本的 lesson 补齐为 v2 形态。\n *\n * 对 v1 数据:派生 contentHash/estimatedTokens,`appliedCount` 映射为\n * `relevantHitCount`(二者语义一致——都是\"被上下文相关召回\"),\n * class 留空交由 M4 审计填充,不做关键词猜测。\n */\nfunction hydrateLesson(raw: Lesson, scope?: 'workspace' | 'local'): Lesson {\n const relevantHitCount = raw.relevantHitCount ?? raw.appliedCount ?? 0\n return {\n ...raw,\n scope: raw.scope ?? scope,\n state: raw.state ?? 'active',\n contentHash: raw.contentHash ?? computeLessonContentHash(raw),\n estimatedTokens: raw.estimatedTokens ?? estimateLessonTokens(raw),\n relevantHitCount,\n coreInjectedCount: raw.coreInjectedCount ?? 0,\n hitCountTotal: raw.hitCountTotal ?? relevantHitCount,\n appliedCount: relevantHitCount,\n }\n}\n\nfunction readCollection(snapshot: unknown): LessonCollection | undefined {\n if (!snapshot || typeof snapshot !== 'object') {\n return undefined\n }\n const envelope = snapshot as { version?: number; data?: LessonCollection }\n if (envelope.version !== undefined && SUPPORTED_SNAPSHOT_VERSIONS.has(envelope.version) && envelope.data) {\n return envelope.data\n }\n const flat = snapshot as { version?: number; lessons?: unknown }\n if (flat.version === undefined && Array.isArray(flat.lessons)) {\n return snapshot as LessonCollection\n }\n return undefined\n}\n\nexport class OperationalLearningStore {\n private lessons = new Map<string, Lesson>()\n private nextId = 1\n private loaded = false\n private readonly snapshotId: string | undefined\n private readonly persistence: Persistence<LessonCollection> | undefined\n /** RFC-164 M164-2 D5:MemoryStore 接入(可选)——提供时优先于 `persistence` 路径。 */\n private readonly store: MemoryStore | undefined\n private readonly storePath: string | undefined\n private readonly logger: { warn: (...args: unknown[]) => void } | undefined\n /** RFC-284 D11:本 store 的作用域,装配期注入,写入时打到每条 lesson 上。 */\n private readonly scope: 'workspace' | 'local' | undefined\n /** RFC-345 §D3:影子态——persist() 短路(内存优先架构,写后读经 this.lessons Map 保持)。 */\n private readonly ephemeral: boolean\n\n constructor(options?: LearningStoreOptions) {\n this.logger = options?.logger\n this.scope = options?.scope\n this.ephemeral = options?.ephemeral ?? false\n if (options?.store) {\n this.store = options.store\n this.storePath = options.storePath ?? options.path\n } else if (options?.path) {\n const ext = extname(options.path) || '.json'\n this.snapshotId = basename(options.path, ext)\n this.persistence = createPersistence<LessonCollection>({\n type: 'file',\n baseDir: dirname(options.path),\n extension: ext,\n })\n }\n }\n\n async save(input: LessonInput): Promise<Lesson> {\n await this.ensureLoaded()\n\n const id = `lesson_${this.nextId++}`\n const lesson: Lesson = hydrateLesson(\n {\n ...input,\n id,\n createdAt: Date.now(),\n appliedCount: 0,\n },\n this.scope,\n )\n\n this.lessons.set(id, lesson)\n await this.persist()\n\n return lesson\n }\n\n async search(query: string, limit = 5): Promise<Lesson[]> {\n await this.ensureLoaded()\n const lowerQuery = query.toLowerCase()\n const scored: Array<{ lesson: Lesson; score: number }> = []\n\n for (const lesson of this.lessons.values()) {\n let score = 0\n\n if (lesson.trigger.toLowerCase().includes(lowerQuery)) {\n score += 3\n }\n if (lesson.insight.toLowerCase().includes(lowerQuery)) {\n score += 2\n }\n\n for (const tag of lesson.tags) {\n if (tag.toLowerCase().includes(lowerQuery)) {\n score += 1\n }\n }\n\n if (score > 0) {\n scored.push({ lesson, score })\n }\n }\n\n const result = scored\n .sort((a, b) => b.score - a.score)\n .slice(0, limit)\n .map((s) => {\n // RFC-284 D8:search 是 query 相关召回 → 记 relevantHitCount;\n // appliedCount 作为兼容别名同步,供既有观测脚本继续读取。\n s.lesson.relevantHitCount = (s.lesson.relevantHitCount ?? 0) + 1\n s.lesson.hitCountTotal = (s.lesson.hitCountTotal ?? 0) + 1\n s.lesson.lastAccessedAt = Date.now()\n s.lesson.appliedCount = s.lesson.relevantHitCount\n return s.lesson\n })\n\n await this.persist()\n return result\n }\n\n /**\n * RFC-036 M61(R4 去重前置):在写入前查找与 `(trigger, insight)` 近似重复的既有经验。\n * 返回相似度最高且 ≥ `threshold` 的一条,没有则 `undefined`。**只读,不自增 appliedCount**\n * (区别于 `search`——`search` 是召回侧、计数被引用;`findSimilar` 是写前去重、不应计数)。\n */\n async findSimilar(\n trigger: string,\n insight: string,\n threshold = DEFAULT_SIMILARITY_THRESHOLD,\n ): Promise<SimilarLesson | undefined> {\n await this.ensureLoaded()\n const target = toWordSet(`${trigger} ${insight}`)\n if (target.size === 0) {\n return undefined\n }\n\n let best: SimilarLesson | undefined\n for (const lesson of this.lessons.values()) {\n const similarity = jaccard(target, toWordSet(`${lesson.trigger} ${lesson.insight}`))\n if (similarity >= threshold && (!best || similarity > best.similarity)) {\n best = { lesson, similarity }\n }\n }\n return best\n }\n\n /**\n * RFC-036 M62-03(R4):去重写入——写前 `findSimilar`,命中近似既有经验则**复用**(不新增),\n * 否则 `save`。`learn` 工具与 `saveExtractedLessons` 共用此原语,保证所有写入口都过去重。\n */\n async saveDeduped(\n input: LessonInput,\n threshold = DEFAULT_SIMILARITY_THRESHOLD,\n ): Promise<{ lesson: Lesson; deduped: boolean }> {\n const similar = await this.findSimilar(input.trigger, input.insight, threshold)\n if (similar) {\n return { lesson: similar.lesson, deduped: true }\n }\n return { lesson: await this.save(input), deduped: false }\n }\n\n async list(filter?: LessonFilter): Promise<Lesson[]> {\n await this.ensureLoaded()\n let results = [...this.lessons.values()]\n\n if (filter?.tags && filter.tags.length > 0) {\n const tagSet = new Set(filter.tags)\n results = results.filter((l) => l.tags.some((t) => tagSet.has(t)))\n }\n\n if (filter?.query) {\n const q = filter.query.toLowerCase()\n results = results.filter(\n (l) => l.trigger.toLowerCase().includes(q) || l.insight.toLowerCase().includes(q),\n )\n }\n\n return results\n }\n\n async get(id: string): Promise<Lesson | undefined> {\n await this.ensureLoaded()\n return this.lessons.get(id)\n }\n\n /** RFC-284 T3b:记录 Relevant Hints 命中;只更新统计字段,不改变 contentHash/estimatedTokens。 */\n async recordRelevantHits(ids: readonly string[], now = Date.now()): Promise<void> {\n await this.ensureLoaded()\n let changed = false\n for (const id of ids) {\n const lesson = this.lessons.get(id)\n if (!lesson) continue\n lesson.relevantHitCount = (lesson.relevantHitCount ?? 0) + 1\n lesson.hitCountTotal = (lesson.hitCountTotal ?? 0) + 1\n lesson.lastAccessedAt = now\n lesson.appliedCount = lesson.relevantHitCount\n changed = true\n }\n if (changed) {\n await this.persist()\n }\n }\n\n async delete(id: string): Promise<boolean> {\n await this.ensureLoaded()\n const deleted = this.lessons.delete(id)\n\n if (deleted) {\n await this.persist()\n }\n\n return deleted\n }\n\n get size(): number {\n return this.lessons.size\n }\n\n private async ensureLoaded(): Promise<void> {\n if (this.loaded) {\n return\n }\n\n if (this.store && this.storePath) {\n await this.loadFromStore()\n this.loaded = true\n return\n }\n\n if (!this.persistence || !this.snapshotId) {\n this.loaded = true\n return\n }\n\n try {\n const snapshot = await this.persistence.load(this.snapshotId)\n const collection = readCollection(snapshot)\n if (collection) {\n for (const lesson of collection.lessons) {\n this.lessons.set(lesson.id, hydrateLesson(lesson, this.scope))\n }\n this.nextId = collection.nextId ?? this.lessons.size + 1\n }\n } catch (err) {\n this.logger?.warn(\n `Failed to load lessons from file persistence (snapshotId=${this.snapshotId}): ${String(err)}. ` +\n `Falling back to empty lessons — all previously learned lessons will be lost for this session.`,\n )\n }\n this.loaded = true\n }\n\n /**\n * RFC-164 M164-2 D4b:强制重新从 store 拉取一次(\"最终同步\"场景——R↔L 切换 disable 前\n * 拉取远端最新内容覆盖本地缓存,避免用户看到 stale 数据)。仅 store 模式生效;\n * 本地文件模式(无 store)为 no-op(无远端可拉取)。\n */\n async refresh(): Promise<void> {\n if (!this.store || !this.storePath) return\n await this.loadFromStore()\n this.loaded = true\n }\n\n /** RFC-164 M164-2 D5:MemoryStore 路径的加载——store.load 返回原始 JSON 字符串,本地解析。 */\n private async loadFromStore(): Promise<void> {\n try {\n const loaded = await this.store!.load([{ path: this.storePath!, writable: true }])\n const raw = loaded.get(this.storePath!)\n if (!raw) return\n const collection = readCollection(JSON.parse(raw) as unknown)\n if (collection) {\n for (const lesson of collection.lessons) {\n this.lessons.set(lesson.id, hydrateLesson(lesson, this.scope))\n }\n this.nextId = collection.nextId ?? this.lessons.size + 1\n }\n } catch (err) {\n this.logger?.warn(\n `Failed to load lessons from MemoryStore (storePath=${this.storePath}): ${String(err)}. ` +\n `Falling back to empty lessons — all previously learned lessons will be lost for this session.`,\n )\n }\n }\n\n private async persist(): Promise<void> {\n // RFC-345 §D3:影子态唯一写盘出口短路(覆盖 store 与 persistence 两分支)。\n // this.lessons Map 为真源,list/get/search 均读 Map,短路后写后读不受影响。\n if (this.ephemeral) {\n return\n }\n if (this.store && this.storePath) {\n const collection: LessonCollection = {\n lessons: [...this.lessons.values()],\n nextId: this.nextId,\n }\n await this.store.write(this.storePath, JSON.stringify({ version: SNAPSHOT_VERSION, data: collection }))\n return\n }\n\n if (!this.persistence || !this.snapshotId) {\n return\n }\n\n const now = Date.now()\n await saveDocument(\n this.persistence,\n this.snapshotId,\n {\n lessons: [...this.lessons.values()],\n nextId: this.nextId,\n },\n { version: SNAPSHOT_VERSION, tags: ['operational-learning'], now, preserveCreatedAt: false },\n )\n }\n}\n"],"mappings":"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"}