diff --git a/CLAUDE.md b/CLAUDE.md index 9ed603f06..cbd214dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,8 @@ generates one standard-quality image and must never run on load or save. [AgentLoop](src/engine/agent/agent_loop.hpp) is the public facade. [TurnRunner](src/engine/agent/turn/turn_runner.cpp) owns the multi-turn state machine; [TurnFinalizer](src/engine/agent/turn/turn_finalizer.cpp) owns ordered completion. Fixed dependencies enter through [AgentLoopServices](src/engine/agent/agent_loop_services.hpp); event-backed prompters are installed before explicit start(). A text-only assistant reply ends the loop. `task_complete` is an optional explicit terminator that renders a concise completion row. `AskUserQuestion` is not a terminator; its answer returns as a tool result. `config.agent_loop.max_iterations` is an optional hard cap; `0` or omitted means unlimited. +**输出损坏重发一次(``)。** 回复正文里(代码块与行内代码之外)出现工具参数模板标记 `` / `` 及闭合标签(`find_leaked_tool_argument_markup`),说明网关把模板残片吐进了输出流,这一步的正文与同一回复里的原生工具调用都不可信:TurnRunner 在执行工具之前整条丢弃(不入历史、不执行)、清掉 TUI / Web 的流式草稿、发 `response_corrupted_retry` 通知后原样重发;每步连续最多 1 次(`ResponseRecoveryState::corrupted_output_retries`,产出有效工具批次后清零),重发后仍损坏就按原流程继续。`` 刻意不算 —— 把推理写进正文的模型会合法输出它(用户拍板:think 残片不处理)。起因:反馈 huangyuan816,坏回复里的 bash 命令也夹着乱码照样执行,之后整个回合答非所问。 + `agent_loop.question_policy`(`ask` 默认 / `deny` / `timeout`,CLI `--question-policy` 覆盖只写运行时字段不落盘)控制 AskUserQuestion 应答:deny 不弹 UI 直接返回「自行决策并继续」自动应答;timeout 等 `question_timeout_seconds`(默认 60,[5,3600])秒后自动采纳每题第一选项(output/metadata 标注自动采纳)。YOLO 只跳过工具权限确认,不改变提问策略;active goal 将每次 AskUserQuestion 覆盖为 30 秒 timeout,正常弹 UI,超时采纳推荐项。daemon 通过 `AskUserQuestionPrompter::prompt` 的 per-call timeout override 实现 goal 动态覆盖。见 openspec/changes/add-ask-question-policy。 **提问期间的插话(question interjection)。** AskUserQuestion 挂起时用户没作答而是直接发了文本,三端统一走 `AgentLoop::interject_question(request_id, input)`(REST `POST /api/sessions/:id/questions/interject`,`SessionClient::interject_question`):在 `active_turn_mu_` 下先把该 request 经 prompter `notify_response({cancelled:true, interjected:true})` 收掉(first-wins,已不挂起 → `TurnSteerStatus::NoPendingQuestion`,输入**不**提交,调用方退回普通路径),再把文本压进 `pending_turn_inputs_`(同回合 soft steer,metadata `question_interjection` / `question_request_id`)。worker 要等工具批次收割完才 drain,所以模型看到的顺序恒为 tool_call → tool_result(`[User interjected] …看下一条 user 消息`,success=true,metadata `ask_user_question_result.interjected`)→ 插话 user 消息,回合不 abort、不开新回合、没有 `` 标记;`question_closed.reason=interjected`。**不要**改成走 `interrupt_turn`:那会把回合打断并给模型「工具可能部分执行」的错误前提。曾经的时序问题就是它要修的:Web 提问期间输入框被禁用,用户只能先点取消,工具立刻返回 declined 错误、模型带着「用户拒答」自作主张继续,那句话排在队列里要等本回合结束才作为新回合送达;IM 通道里非 `/aq` 文本直接 `send_input` 排在被阻塞的回合后面,问题却还在等,双方互相等到超时。接线:Web `ChatView::submit` 在 `questionForView` 存在时优先调 `api.interjectQuestion`(目标是问题所属会话,子代理的问题路由回子会话;`QuestionPicker` 挂载时若输入框正在输入不抢焦点);IM `session_channel_binder` 对非 `/aq` 文本先 `ChannelQuestionBridge::begin_interjection()` 再锁外调 `interject_question`,失败回退 `send_input`。TUI overlay 期间 composer 不可达,没有这条路径。`interjected` 与 `cancelled` 同时为 true,只认 cancelled 的旧调用方(image_generate 费用确认、theme_create)把它当普通拒绝。回归:`tests/agent/agent_loop_question_interjection_test.cpp`、`ask_user_question_prompter_test.cpp::InterjectedResponseClosesWithInterjectedReason`、`ask_user_question_tool_test.cpp::InterjectedResponseWinsOverCancelled`、`channel_question_bridge_test.cpp` 两条、`session_channel_binder_test.cpp::PlainTextDuringPendingQuestionInterjectsInsteadOfQueueing`、`web_server_smoke_test.cpp::QuestionInterjectResolvesPendingQuestionInSameTurn`,前端 `composerEditabilityArchitecture.test.js`(此前未注册进 runTests.js,现已登记)。 @@ -314,7 +316,16 @@ P2-06 module boundaries: generic YAML frontmatter lives in `src/base/utils/front Proactive skill discovery (`inject-skill-index-into-context`): a compact skill index (name + description + optional `whenToUse` frontmatter, grouped by category) is injected each request into the session-context system-reminder built by `build_session_context_prompt` — the same per-request, never-persisted block that carries project instructions and the memory index. Budget is 1% of the context window in chars (4 chars/token, 8000-char fallback), 250 chars per entry (UTF-8-safe truncation); over budget degrades to names-only, then tail-cut with a `(+N more — call skills_list)` marker. The static `# Skills` prompt section points at this index ("err on the side of loading"); `skills_list` remains the fallback enumerator only. Without this index the model has zero visibility into installed skills and never invokes them proactively. -[src/domain/memory/](src/domain/memory) stores Markdown memory entries under `~/.acecode/memory/` and rewrites an index on upsert/remove. `memory_write` is constrained to that directory even under broad permission modes. +**记忆(openspec unify-memory-system)。** TUI、daemon、headless 都经 `create_memory_runtime`([src/host/session_host/memory_runtime.cpp](src/host/session_host/memory_runtime.cpp))拿到同一个 `MemoryService`([src/domain/memory/](src/domain/memory)):全局作用域 `/memory/`,工作区作用域 `get_project_dir(会话 cwd)/memory/`(与会话存储同一 hash,worktree 会话归主工作区,子会话与父会话同目录)。作用域一律按**会话**项目目录解析(工具经 `ToolContext::session_manager`,请求经 `RequestContextFactory`),不用进程 cwd —— daemon 一个进程服务多个工作区。 + +- 存储:`MemoryRegistry` 按目录实例化,写入先重扫磁盘,再在状态库 `/memory/state.sqlite3` 的 `BEGIN IMMEDIATE`(`MemoryWriteLock`)里完成,锁序固定「跨进程写锁 → 进程内 mu_」。写入口统一 `redact_secrets`(手写扫描,别换 `std::regex`)。frontmatter 保留未知字段、补 `created_at` / `updated_at` / `source` / `source_sessions`;读回时去掉 frontmatter 之后那一个空行,否则每次系统改写正文前都多攒一行。 +- 注入是**按会话冻结的快照**:`ApiRequestBuilder::frozen_memory_snapshot` 以「会话 id|项目目录|开关」为 key 存进 `PromptContextCache`,只在压缩 / 线程修复(`invalidate_memory_snapshot`,挂在两处 `compact_generation_` 自增点)或 key 变化时按磁盘重建 —— 会话中途写入不打穿 prompt cache。记忆关闭(配置或 `/memory off`,meta `memory_mode: off`)时快照为空、模型侧工具表滤掉两个记忆工具、静态提示不出 `# Memory` 指引;工具本身始终注册(headless `--list-tools` / `--disable-tools` 能看到)。 +- `/memory` 只有一份文本实现 `dispatch_memory_command`:TUI 命令与 daemon 内置命令白名单(含网页 `builtin_command_handler` / `/api/commands`)都调它,网页的 `edit` 改为指向设置页。设置走 `set_memory_settings`(`/api/config/memory` 与 TUI 设置中心 `memory_settings_section` 共用);本进程经 `MemoryRuntime::update_config` 立即生效,其他进程由调度器每分钟按 `config.json` 修改时间重读。 +- 记忆摘要(默认关)`MemorySummaryScheduler` 只在 daemon / TUI 跑:闲置会话(排除子会话、headless(meta `surface`)、关了记忆的会话)→ 提炼观察进 `inbox/<会话>--.json`(写成功才推进位置,重放覆盖同名文件)→ 满 20 条或最早超 24 小时整合:JSON 计划 create / update / merge / delete 每项都要引用本批观察,`source: manual`(含没有 source 的旧条目)只读,墓碑 90 天内不得复活;计划先存为待应用、再在写锁内应用,失败整批回滚,成功后本批观察整体归档。永久删除会话经 `notify_session_purged`(`purge_session_files` 成功后)撤回只来源于它的摘要条目。测试经 `MemorySchedulerHost::complete` / `now_ms` 注入假模型与时钟。模型输出经 `parse_model_json` 容错取出 JSON 对象(跳过 `` 之前的内容、代码围栏和正文里夹带的推理文字),仍无效时日志记一段输出样本;实测会把推理写进正文的模型不加这层会让每次提炼都判无效。`/memory flush` 的完成报告在 daemon 用 `emit_transcript_system_message` 写进对话记录,刷新或重新打开会话后仍可见。 + +记忆在 TUI、daemon(桌面版 / Web)与 headless 都接线:daemon 曾经 `memory_registry = nullptr`,桌面会话既没有索引注入也没有 `memory_read` / `memory_write`,用户让模型「记住」时它只能把经验写进子目录 CLAUDE.md / `.acecode/MEMORY.md` 这类不会被自动加载的文件(反馈 LINDANDAN069)。每个工作区一个 daemon 加上 TUI 会同时写同一个目录,所以 `MemoryRegistry::upsert` / `remove` 写前按磁盘重扫、`memory_read` 读前重扫 —— 拿启动时的旧缓存重写 MEMORY.md 会把别的进程后来写的条目当成已删除丢掉。静态 system prompt 在记忆开启时带一段 `# Memory` 指引(「记住」一律走 `memory_write`,别另写笔记文件)。headless(`-p`)同样注册两个记忆工具,但不跑记忆摘要。 + +`file_read` 的「文件未变」去重(`MtimeTracker` 的已读观测)按会话 id 分区:进程级单例曾经不分会话,daemon 里别的会话或子代理读过同一文件,本会话第一次读就只拿到占位。摘要压缩与各种线程修复(`CompactionController::mark_history_repaired`)之后清空观测,因为之前的读取结果可能已被清成占位符。 [src/domain/project_instructions/](src/domain/project_instructions) loads configured project-instruction filenames from the global config directory and then from the project hierarchy, outer-first, subject to per-file and aggregate byte caps. The repository root intentionally keeps only canonical docs; do not add duplicate root instruction files for this repository. @@ -422,8 +433,10 @@ revision stale so a later send retries. 3. **随机拒收绝不终止回合**(`pa_overflow_rescue`)。服务端实际能收的规模随 负载浮动,同一规模的请求时过时不过。PA 特征的整体拒收不走通用三级恢复链, 改走 `AgentLoop::run_pa_overflow_rescue`:原样重发 2 次 → 每次被拒缩到 - 上一次的 85%(先丢老回合,再清本回合旧工具输出 / 大参数, - `ThreadRepairOptions::clear_tool_outputs`)无上限 → 紧急档 → 5s 起封顶 + 上一次的 85%(先清整段历史里最旧的工具输出 / 大参数,再把老回合精简成 + 「用户消息 + 摘要 + 纯文本结论」,最后才整组丢且保留最新摘要; + `ThreadRepairOptions::clear_tool_outputs` + `thin_old_turns_first`, + 反馈 LINDANDAN069:旧的「先丢老回合」把任务说明与用户纠正一起删掉)无上限 → 紧急档 → 5s 起封顶 60s 的退避等待最多 12 次,只有等待耗尽才报错。兜底后的那次重发跳过自动 压缩(`skip_auto_compact_once_`);请求被收下即清零 episode,同回合再被拒 重新开始。学习器只记每个 episode 最初确认拒收的规模,紧急档请求永远不记。 @@ -754,7 +767,11 @@ The existing region detector still probes DuckDuckGo once at startup through `Pr "context_window": 128000, "max_sessions": 50, "skills": { "disabled": [], "external_dirs": [] }, - "memory": { "enabled": true, "max_index_bytes": 32768 }, + "memory": { + "enabled": true, + "max_index_bytes": 8192, + "summary": { "enabled": false, "model_name": "", "idle_minutes": 30, "max_session_age_days": 7 } + }, "project_instructions": { "enabled": true, "max_depth": 8, diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 7475bd12e..6b8476a64 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -386,6 +386,13 @@ update their transcript presentation. | PUT | `/api/config/summary-generation` | save summary-model override for automatic session titles | | PUT | `/api/config/image-generation` | save image generation settings and refresh the tool | | POST | `/api/config/image-generation/test` | explicitly generate one standard-quality test image | +| GET | `/api/config/memory` | read memory settings (use memory, memory summarization, summary model) | +| PUT | `/api/config/memory` | patch memory settings; persists `config.json` and applies to this daemon's memory runtime | +| GET | `/api/memory?workspace=` | list global and workspace memory entries plus memory-summarization status | +| GET | `/api/memory/:scope/:name?workspace=` | read one entry with body | +| PUT | `/api/memory/:scope/:name?workspace=` | edit one entry (redacted, marked manual) | +| DELETE | `/api/memory/:scope/:name?workspace=` | delete one entry and record a tombstone | +| POST | `/api/memory/reset` | clear one scope (entries, index, inbox, archive) | | GET | `/api/config/tool-rewrites` | read tool rewrite settings plus the built-in tool catalog | | PUT | `/api/config/tool-rewrites` | replace tool rewrite settings, persist `tool-rewrites.json`, apply live | | GET | `/api/config/tool-preamble` | read the concrete progress text switch (work mode) | @@ -1847,9 +1854,19 @@ Runs daemon-owned builtin slash commands. Body: `command` can also be slash text like `"/compact"`. Supported commands are the daemon builtin commands accepted by `parse_builtin_command_request`: -`init`, `compact`, `goal`, and `plan`. Skill slash commands must use +`init`, `compact`, `goal`, `plan`, `lsp`, `sandbox`, `memory`, `rc` and +`remote-control`. Skill slash commands must use `POST /api/sessions/:id/messages`. +`memory` runs the same text implementation as the TUI `/memory` +(`dispatch_memory_command`): `list [--scope=global|workspace] [--type=]`, +`view `, `forget ` (records a tombstone), `flush`, `off`, `on` +and `reload`. The result is emitted as a session system message with notice code +`memory_status`; `edit` only points to Settings > Personalization > Memory. +`/memory flush` returns immediately and, when memory summarization is on, a +second system message with code `memory_flush_done` reports the processed +observations and changed entries. + Returns `202 {"queued":true,"command":"compact"}`. Errors: - `400 {"error":"unsupported command","command":"..."}` @@ -3742,6 +3759,48 @@ configuration returns 400 `IMAGE_NOT_CONFIGURED`; upstream failures return 502 `IMAGE_QUOTA_ERROR` or `IMAGE_TEST_FAILED` without echoing provider error bodies. All endpoints use normal API authentication/CORS and `Cache-Control: no-store`. +### Memory settings and entries (`openspec unify-memory-system`) + +Memory has two scopes: **global** (`/memory/`, the user's personal +preferences) and **workspace** (`/projects//memory/`, the same +hash as the workspace's session storage). The `workspace` query parameter must +be a registered workspace hash (`404 {"error":"UNKNOWN_WORKSPACE"}` otherwise); +without it the workspace scope is reported as unavailable. Every route returns +`503 {"error":"UNAVAILABLE"}` when the daemon has no memory runtime. + +`GET /api/config/memory` returns: + +```json +{"enabled":true,"max_index_bytes":8192, + "summary":{"enabled":false,"model_name":"","idle_minutes":30,"max_session_age_days":7}, + "summary_available":true} +``` + +`enabled` is "use memory" (inject the per-session memory snapshot and offer +`memory_read` / `memory_write`). `max_index_bytes` is the per-scope injection +budget. `summary.enabled` turns memory summarization on (default off); +`summary.model_name` is a saved model name, empty meaning "the model each +session last used". `PUT /api/config/memory` accepts a patch of the same shape +and goes through `set_memory_settings` (unknown model, `idle_minutes` outside +5–1440 or `max_session_age_days` outside 1–90 → `400 BAD_REQUEST`). Other +ACECode processes pick the change up from `config.json` within one minute. + +`GET /api/memory?workspace=` rescans disk and returns +`{enabled, scopes:{global:{available,dir,entries[]}, workspace:{...}}, status}`. +Each entry is `{scope,name,description,type,created_at,updated_at,source,source_sessions}` +(`source` is `manual` or `summary`; legacy entries without provenance report +`manual`). `status` is `{summary_enabled, global_inbox, workspace_inbox, +last_extraction_ms, last_consolidation_ms, last_error, last_error_ms}`. + +`GET /api/memory/:scope/:name` adds `body` and `path`. `PUT` takes +`{description, body, type?}`, writes through the store (secrets redacted to +`[REDACTED]`, `updated_at` refreshed, source becomes `manual` so summarization +leaves the entry alone) and returns the entry plus `redactions`. `DELETE` +removes the entry and its index line and records a tombstone (90 days) so +summarization cannot recreate the same name or title. `POST /api/memory/reset` +takes `{scope, workspace}` and clears that scope's entries, index, inbox and +archive; the other scope is untouched. + ### Tool rewrite settings Settings > Tools > 工具重写. Renames built-in tools as the model sees them: the diff --git a/docs/help-source/group4.py b/docs/help-source/group4.py index 62ee1293c..ea2b9a6f5 100644 --- a/docs/help-source/group4.py +++ b/docs/help-source/group4.py @@ -87,10 +87,12 @@ code("用简体中文解释结果。\n修改代码前先阅读相关实现。\n完成后说明改动与验证结果;没有执行的检查请明确写出。", "自定义指令示例"), figure("CF-05", "个性化指令与保存反馈", "展示自定义指令编辑区和已保存状态,使用通用示例,不展示个人或公司私密信息。")), section("memory", "保存和查看记忆", - '''

需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。

默认记忆目录是 ~/.acecode/memory/,其中 MEMORY.md 为索引,具体条目分别保存为 Markdown 文件。TUI 的 /memory 用于列出记忆,更多操作可查看 /memory help。

记忆与聊天记录不同:恢复任务读取该任务的上下文,持久记忆用于后续工作重用信息。不要把整个项目源码或完整对话复制为一条记忆。

'''), + '''

需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。

记忆分两层:全局记忆保存跨项目的个人偏好,位于 ~/.acecode/memory/;工作区记忆只属于当前工作区,位于 ~/.acecode/projects/<工作区标识>/memory/。每层都有 MEMORY.md 索引,条目分别保存为 Markdown 文件。未指定时,个人偏好与协作反馈写入全局,项目背景与外部资料写入工作区。

每个会话在第一次请求时读取一份记忆快照,之后保持不变;会话中途新写的记忆在下一个会话或本会话压缩上下文之后出现。记忆是历史记录,智能体依赖其中的路径或命令前会先核实,与你当前的要求冲突时以当前要求为准。

TUI 与桌面 / 网页对话都可以用 /memory:/memory list 列出两层记忆,/memory view 名称 查看,/memory forget 名称 删除,/memory off 与 /memory on 只对当前会话关闭或恢复记忆。

'''), + section("summary", "记忆摘要", + '''

设置 > 个性化 > 记忆中可以开启记忆摘要(默认关闭)。开启后,会话闲置约 30 分钟,ACECode 用摘要模型把这段对话中值得长期保留的经验提炼成观察,攒够一批后整合为正式记忆;每次自动改动都记录来源会话。摘要模型默认是该会话最后使用的模型,也可以指定一个已保存的模型。开启会额外调用模型。

想立即整理当前会话时执行 /memory flush,完成后对话里会出现处理结果。永久删除一个会话时,只来源于它的自动记忆会一并撤回;你手动删除的记忆在 90 天内不会被自动重新生成。

'''), section("maintenance", "更新与删除过时信息", - '''

当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。删除时同样指定对象,先查看内容再操作;TUI 的记忆命令提供查看、编辑、忘记和重新加载入口。

适合长期保存的是稳定偏好、反复需要的项目说明和可复用经验。账号密钥、临时验证码、大段日志和未经确认的推测不适合作为普通记忆保存。分享备份前检查记忆与配置中的私密内容。

如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。

''') -], ["web/src/components/SettingsPage.jsx", "src/domain/memory/memory_paths.cpp", "src/apps/tui/commands/memory_command.cpp", "src/adapters/tool/memory_read_tool.cpp", "src/adapters/tool/memory_write_tool.cpp"]) + '''

当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。也可以在设置 > 个性化 > 记忆中按“全局 / 当前工作区”浏览、编辑、删除条目,或在二次确认后重置某一层。手动编辑过的条目不会再被记忆摘要改动。

写入记忆前,ACECode 会把可识别的密钥、令牌和密码替换为 [REDACTED];即便如此,账号密钥、临时验证码、大段日志和未经确认的推测也不适合保存为记忆。分享备份前检查记忆与配置中的私密内容。

如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。

''') +], ["web/src/components/SettingsPage.jsx", "web/src/components/MemorySettings.jsx", "src/domain/memory/memory_paths.cpp", "src/host/session_host/memory_command.cpp", "src/host/session_host/memory_scheduler.cpp", "src/adapters/tool/memory_read_tool.cpp", "src/adapters/tool/memory_write_tool.cpp"]) } from group4_extensions import PAGES as EXTENSIONS diff --git a/docs/help-source/group6_reference.py b/docs/help-source/group6_reference.py index 685860c87..5f1e2ec95 100644 --- a/docs/help-source/group6_reference.py +++ b/docs/help-source/group6_reference.py @@ -6,14 +6,14 @@ table(["入口", "按键", "作用"], [["桌面 / Web", "Ctrl+K(macOS ⌘K)", "打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。"], ["桌面 / Web 输入框", "Enter", "空闲时发送;运行时按当前状态排队。"], ["桌面 / Web 输入框", "Shift+Enter", "换行。"], ["桌面文件编辑器", "Ctrl+S", "保存当前可编辑文件。"], ["桌面终端停靠区", "Ctrl+`", "显示或隐藏底部终端。"], ["TUI", "Esc", "等待时中断、退出 Shell 模式或拒绝当前确认。"], ["TUI", "Ctrl+C", "第一次清空输入并提示,1 秒内再次按下退出。"], ["TUI", "Ctrl+O", "全局展开或折叠工具结果。"], ["TUI", "Ctrl+E", "聚焦工具时切换该结果展开;否则移动到输入末尾。"], ["TUI", "Ctrl+P", "空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。"], ["@ 候选菜单", "↑ / ↓、Enter", "选择并引用文件或文件夹。"], ["@ 候选菜单", "Tab / →", "进入选中的文件夹。"]]), '''

按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。

'''), section("commands", "斜杠命令", - table(["命令", "用途与范围"], [["/help", "查看当前注册的可用命令。"], ["/model", "模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。"], ["/mode", "查看或切换权限模式。"], ["/config", "打开设置;/config show 查看文本概要。"], ["/tokens", "查看会话 token 用量。"], ["/compact", "调用模型压缩当前上下文。"], ["/init", "分析项目并生成或改进项目规则。"], ["/resume", "TUI 恢复历史会话。"], ["/rewind、/checkpoint", "TUI 打开回退选择器;代码恢复取决于检查点可用性。"], ["/fork", "TUI 从用户回合选择分叉,按选择器确认范围。"], ["/archive", "归档当前会话并开始新的对话状态。"], ["/clear、/new", "清理当前对话状态,开始新一段工作。"], ["/side 问题、/btw 问题", "独立的一次旁支问答,不调用工具。"], ["/turn 补充说明", "中断当前轮并发送指导;与排队卡片的插话时机不同。"], ["/skills、/mcp", "查看或管理技能与 MCP;带参数执行相应子命令。"], ["/tools、/hooks、/connectors", "打开对应能力管理入口。"], ["/lsp", "查看语言服务器状态。"], ["/memory", "列出、查看、编辑、遗忘或重载持久记忆。"], ["/tasks", "TUI 管理后台子任务。"], ["/proxy", "检查有效代理及临时覆盖状态。"], ["/rc、/remote-control", "激活已配置的默认渠道;show 查看,off 解绑。"], ["/exit", "退出 TUI。"]]), + table(["命令", "用途与范围"], [["/help", "查看当前注册的可用命令。"], ["/model", "模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。"], ["/mode", "查看或切换权限模式。"], ["/config", "打开设置;/config show 查看文本概要。"], ["/tokens", "查看会话 token 用量。"], ["/compact", "调用模型压缩当前上下文。"], ["/init", "分析项目并生成或改进项目规则。"], ["/resume", "TUI 恢复历史会话。"], ["/rewind、/checkpoint", "TUI 打开回退选择器;代码恢复取决于检查点可用性。"], ["/fork", "TUI 从用户回合选择分叉,按选择器确认范围。"], ["/archive", "归档当前会话并开始新的对话状态。"], ["/clear、/new", "清理当前对话状态,开始新一段工作。"], ["/side 问题、/btw 问题", "独立的一次旁支问答,不调用工具。"], ["/turn 补充说明", "中断当前轮并发送指导;与排队卡片的插话时机不同。"], ["/skills、/mcp", "查看或管理技能与 MCP;带参数执行相应子命令。"], ["/tools、/hooks、/connectors", "打开对应能力管理入口。"], ["/lsp", "查看语言服务器状态。"], ["/memory", "列出、查看、编辑、遗忘或重载全局与工作区记忆;flush 立即整理,off / on 只作用于当前会话。TUI 与网页输出一致。"], ["/tasks", "TUI 管理后台子任务。"], ["/proxy", "检查有效代理及临时覆盖状态。"], ["/rc、/remote-control", "激活已配置的默认渠道;show 查看,off 解绑。"], ["/exit", "退出 TUI。"]]), '''

不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。

'''), section("arguments", "命令行参数", table(["命令或参数", "用途"], [["acecode", "在当前目录打开交互式 TUI。"], ["acecode configure", "初次配置或重新配置。"], ["acecode --version", "显示版本。"], ["acecode -r", "打开 TUI 历史会话选择。"], ["acecode --resume [ID]", "TUI 恢复最近会话或指定会话;print 模式必须带 ID。"], ["acecode -p \"提示\"", "执行无界面单轮任务。"], ["--output-format text|json|stream-json", "选择 print 模式输出格式。"], ["--model NAME", "选择已保存预设。"], ["--permission-mode MODE", "print 模式支持 default、accept-edits、plan、yolo。"], ["--max-turns N", "print 模式的智能体循环上限。"], ["--session-id ID", "指定新会话 ID。"], ["-c、--continue", "print 模式继续当前目录最近会话。"], ["--list-tools、--list-skills、--list-mcp", "只列出可发现能力。"], ["--enable-skills 名称、--enable-mcp 名称", "为 print 模式显式开启扩展。"], ["--disable-tools 名称", "为 print 模式禁用指定工具。"], ["acecode daemon [start|status|stop]", "独立后台管理。"], ["acecode daemon --foreground", "前台运行后台服务。"], ["acecode daemon --cwd=路径 --port=端口", "指定独立后台的起始工作目录与端口。"], ["acecode upgrade", "按已配置更新源执行自更新。"]]), '''

完整 print 参数运行 acecode -p --help。危险启动参数 --yolo / --dangerous 会绕过权限与路径检查,不作为排错默认步骤。查看脚本示例及退出码,参见 CLI。

'''), section("data", "本地配置和数据", '''

下面的路径相对于个人数据目录 ~/.acecode/;Windows 为 %USERPROFILE%/.acecode/。Windows 服务另用 %PROGRAMDATA%/acecode/,远端进程使用远端身份的目录。

''', - table(["路径", "内容"], [["config.json", "全局配置、模型连接与扩展定义,可能包含凭据。"], ["config-backups/", "有效配置快照和损坏配置备份,也可能含凭据。"], ["projects/", "按项目范围保存的会话、元数据和输入历史等记录。"], ["skills/", "用户技能;还可读取其他已启用根目录。"], ["memory/", "MEMORY.md 索引与记忆条目。"], ["hooks.json", "用户级 Hooks 定义。"], ["logs/", "按运行方式与日期保存的日志。"], ["run/", "独立后台的 PID、端口、GUID、心跳和 Token。"], ["run/desktop-shared/", "桌面托管后台的独立运行身份。"]]), + table(["路径", "内容"], [["config.json", "全局配置、模型连接与扩展定义,可能包含凭据。"], ["config-backups/", "有效配置快照和损坏配置备份,也可能含凭据。"], ["projects/", "按项目范围保存的会话、元数据和输入历史等记录。"], ["skills/", "用户技能;还可读取其他已启用根目录。"], ["memory/", "全局记忆:MEMORY.md 索引、条目、inbox / archive 与记忆状态库 state.sqlite3;工作区记忆在 projects/<标识>/memory/。"], ["hooks.json", "用户级 Hooks 定义。"], ["logs/", "按运行方式与日期保存的日志。"], ["run/", "独立后台的 PID、端口、GUID、心跳和 Token。"], ["run/desktop-shared/", "桌面托管后台的独立运行身份。"]]), '''

项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。

备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见更新与卸载。

''') ], ["docs/user-manual.md", "src/apps/tui/commands/builtin_commands.cpp", "src/apps/headless/headless_options.cpp", "src/base/utils/paths.cpp", "docs/daemon-api.md", "src/domain/memory/memory_paths.cpp"]), } diff --git a/docs/help-source/sources.json b/docs/help-source/sources.json index e16746104..adc1dd08b 100644 --- a/docs/help-source/sources.json +++ b/docs/help-source/sources.json @@ -237,8 +237,10 @@ "file": "memory.html", "sources": [ "web/src/components/SettingsPage.jsx", + "web/src/components/MemorySettings.jsx", "src/domain/memory/memory_paths.cpp", - "src/apps/tui/commands/memory_command.cpp", + "src/host/session_host/memory_command.cpp", + "src/host/session_host/memory_scheduler.cpp", "src/adapters/tool/memory_read_tool.cpp", "src/adapters/tool/memory_write_tool.cpp" ] diff --git a/docs/help/assets/search-index.js b/docs/help/assets/search-index.js index b0e7cf319..13f7562a1 100644 --- a/docs/help/assets/search-index.js +++ b/docs/help/assets/search-index.js @@ -742,7 +742,7 @@ window.ACECODE_HELP_INDEX = [ "title": "记忆与个性化", "url": "memory.html", "description": "个性化指令用于持续表达偏好,记忆用于保存可复用的信息。让保存的内容简短、明确,并随实际情况更新。", - "text": "设置个性化指令 进入 设置 > 个性化 > 自定义指令 ,填写稳定的工作习惯、输出语言或协作要求。离开编辑区后等待“已保存”提示;保存失败时先处理错误再离开。 自定义指令保存在当前用户配置中,会参与模型请求。项目特有的约定更适合写进项目规则,避免让其他项目继承不适合的目录或技术栈要求。 自定义指令示例 复制 用简体中文解释结果。 修改代码前先阅读相关实现。 完成后说明改动与验证结果;没有执行的检查请明确写出。 个性化页面中的自定义指令编辑区。当前内容为空,浅灰文字是填写提示,可在此描述希望 ACECode 持续遵循的通用偏好。 Windows 实际界面 · 点击图片查看原图 保存和查看记忆 需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。 默认记忆目录是 ~/.acecode/memory/ ,其中 MEMORY.md 为索引,具体条目分别保存为 Markdown 文件。TUI 的 /memory 用于列出记忆,更多操作可查看 /memory help 。 记忆与聊天记录不同:恢复任务读取该任务的上下文,持久记忆用于后续工作重用信息。不要把整个项目源码或完整对话复制为一条记忆。 更新与删除过时信息 当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。删除时同样指定对象,先查看内容再操作;TUI 的记忆命令提供查看、编辑、忘记和重新加载入口。 适合长期保存的是稳定偏好、反复需要的项目说明和可复用经验。账号密钥、临时验证码、大段日志和未经确认的推测不适合作为普通记忆保存。分享备份前检查记忆与配置中的私密内容。 如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。" + "text": "设置个性化指令 进入 设置 > 个性化 > 自定义指令 ,填写稳定的工作习惯、输出语言或协作要求。离开编辑区后等待“已保存”提示;保存失败时先处理错误再离开。 自定义指令保存在当前用户配置中,会参与模型请求。项目特有的约定更适合写进项目规则,避免让其他项目继承不适合的目录或技术栈要求。 自定义指令示例 复制 用简体中文解释结果。 修改代码前先阅读相关实现。 完成后说明改动与验证结果;没有执行的检查请明确写出。 个性化页面中的自定义指令编辑区。当前内容为空,浅灰文字是填写提示,可在此描述希望 ACECode 持续遵循的通用偏好。 Windows 实际界面 · 点击图片查看原图 保存和查看记忆 需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。 记忆分两层: 全局记忆 保存跨项目的个人偏好,位于 ~/.acecode/memory/ ; 工作区记忆 只属于当前工作区,位于 ~/.acecode/projects/<工作区标识>/memory/ 。每层都有 MEMORY.md 索引,条目分别保存为 Markdown 文件。未指定时,个人偏好与协作反馈写入全局,项目背景与外部资料写入工作区。 每个会话在第一次请求时读取一份记忆快照,之后保持不变;会话中途新写的记忆在下一个会话或本会话压缩上下文之后出现。记忆是历史记录,智能体依赖其中的路径或命令前会先核实,与你当前的要求冲突时以当前要求为准。 TUI 与桌面 / 网页对话都可以用 /memory : /memory list 列出两层记忆, /memory view 名称 查看, /memory forget 名称 删除, /memory off 与 /memory on 只对当前会话关闭或恢复记忆。 记忆摘要 设置 > 个性化 > 记忆 中可以开启 记忆摘要 (默认关闭)。开启后,会话闲置约 30 分钟,ACECode 用摘要模型把这段对话中值得长期保留的经验提炼成观察,攒够一批后整合为正式记忆;每次自动改动都记录来源会话。摘要模型默认是该会话最后使用的模型,也可以指定一个已保存的模型。开启会额外调用模型。 想立即整理当前会话时执行 /memory flush ,完成后对话里会出现处理结果。永久删除一个会话时,只来源于它的自动记忆会一并撤回;你手动删除的记忆在 90 天内不会被自动重新生成。 更新与删除过时信息 当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。也可以在 设置 > 个性化 > 记忆 中按“全局 / 当前工作区”浏览、编辑、删除条目,或在二次确认后重置某一层。手动编辑过的条目不会再被记忆摘要改动。 写入记忆前,ACECode 会把可识别的密钥、令牌和密码替换为 [REDACTED] ;即便如此,账号密钥、临时验证码、大段日志和未经确认的推测也不适合保存为记忆。分享备份前检查记忆与配置中的私密内容。 如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。" }, { "page": "记忆与个性化", @@ -757,16 +757,24 @@ window.ACECODE_HELP_INDEX = [ "group": "配置与扩展", "title": "保存和查看记忆", "url": "memory.html#memory", - "description": "保存和查看记忆 需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。 默认记忆目录是 ~/.acecode/memory/ ", - "text": "保存和查看记忆 需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。 默认记忆目录是 ~/.acecode/memory/ ,其中 MEMORY.md 为索引,具体条目分别保存为 Markdown 文件。TUI 的 /memory 用于列出记忆,更多操作可查看 /memory help 。 记忆与聊天记录不同:恢复任务读取该任务的上下文,持久记忆用于后续工作重用信息。不要把整个项目源码或完整对话复制为一条记忆。" + "description": "保存和查看记忆 需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。 记忆分两层: 全局记忆 保存跨项目的个人偏好,位于 ~", + "text": "保存和查看记忆 需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。 记忆分两层: 全局记忆 保存跨项目的个人偏好,位于 ~/.acecode/memory/ ; 工作区记忆 只属于当前工作区,位于 ~/.acecode/projects/<工作区标识>/memory/ 。每层都有 MEMORY.md 索引,条目分别保存为 Markdown 文件。未指定时,个人偏好与协作反馈写入全局,项目背景与外部资料写入工作区。 每个会话在第一次请求时读取一份记忆快照,之后保持不变;会话中途新写的记忆在下一个会话或本会话压缩上下文之后出现。记忆是历史记录,智能体依赖其中的路径或命令前会先核实,与你当前的要求冲突时以当前要求为准。 TUI 与桌面 / 网页对话都可以用 /memory : /memory list 列出两层记忆, /memory view 名称 查看, /memory forget 名称 删除, /memory off 与 /memory on 只对当前会话关闭或恢复记忆。" + }, + { + "page": "记忆与个性化", + "group": "配置与扩展", + "title": "记忆摘要", + "url": "memory.html#summary", + "description": "记忆摘要 设置 > 个性化 > 记忆 中可以开启 记忆摘要 (默认关闭)。开启后,会话闲置约 30 分钟,ACECode 用摘要模型把这段对话中值得长期保留的经验提炼成观察,攒够一批后整合为正式记忆;每次自动改动都记录来源会话。摘要模型默认是该会话最", + "text": "记忆摘要 设置 > 个性化 > 记忆 中可以开启 记忆摘要 (默认关闭)。开启后,会话闲置约 30 分钟,ACECode 用摘要模型把这段对话中值得长期保留的经验提炼成观察,攒够一批后整合为正式记忆;每次自动改动都记录来源会话。摘要模型默认是该会话最后使用的模型,也可以指定一个已保存的模型。开启会额外调用模型。 想立即整理当前会话时执行 /memory flush ,完成后对话里会出现处理结果。永久删除一个会话时,只来源于它的自动记忆会一并撤回;你手动删除的记忆在 90 天内不会被自动重新生成。" }, { "page": "记忆与个性化", "group": "配置与扩展", "title": "更新与删除过时信息", "url": "memory.html#maintenance", - "description": "更新与删除过时信息 当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。删除时同样指定对象,先查看内容再操作;TUI 的记忆命令提供查看、编辑、忘记和重新加载入口。 适合长期保存的是稳定偏好、反复需要的项目说明和可复用经验", - "text": "更新与删除过时信息 当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。删除时同样指定对象,先查看内容再操作;TUI 的记忆命令提供查看、编辑、忘记和重新加载入口。 适合长期保存的是稳定偏好、反复需要的项目说明和可复用经验。账号密钥、临时验证码、大段日志和未经确认的推测不适合作为普通记忆保存。分享备份前检查记忆与配置中的私密内容。 如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。" + "description": "更新与删除过时信息 当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。也可以在 设置 > 个性化 > 记忆 中按“全局 / 当前工作区”浏览、编辑、删除条目,或在二次确认后重置某一层。手动编辑过的条目不会再被记忆摘要改动", + "text": "更新与删除过时信息 当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。也可以在 设置 > 个性化 > 记忆 中按“全局 / 当前工作区”浏览、编辑、删除条目,或在二次确认后重置某一层。手动编辑过的条目不会再被记忆摘要改动。 写入记忆前,ACECode 会把可识别的密钥、令牌和密码替换为 [REDACTED] ;即便如此,账号密钥、临时验证码、大段日志和未经确认的推测也不适合保存为记忆。分享备份前检查记忆与配置中的私密内容。 如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。" }, { "page": "内置工具", @@ -1390,7 +1398,7 @@ window.ACECODE_HELP_INDEX = [ "title": "速查手册", "url": "reference.html", "description": "按使用入口查找常用快捷键、命令与数据位置。具体可用项以当前安装的菜单和 /help 为准。", - "text": "快捷键 入口 按键 作用 桌面 / Web Ctrl+K (macOS ⌘K ) 打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏底部终端。 TUI Esc 等待时中断、退出 Shell 模式或拒绝当前确认。 TUI Ctrl+C 第一次清空输入并提示,1 秒内再次按下退出。 TUI Ctrl+O 全局展开或折叠工具结果。 TUI Ctrl+E 聚焦工具时切换该结果展开;否则移动到输入末尾。 TUI Ctrl+P 空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。 @ 候选菜单 ↑ / ↓ 、 Enter 选择并引用文件或文件夹。 @ 候选菜单 Tab / → 进入选中的文件夹。 按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。 斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查看文本概要。 /tokens 查看会话 token 用量。 /compact 调用模型压缩当前上下文。 /init 分析项目并生成或改进项目规则。 /resume TUI 恢复历史会话。 /rewind 、 /checkpoint TUI 打开回退选择器;代码恢复取决于检查点可用性。 /fork TUI 从用户回合选择分叉,按选择器确认范围。 /archive 归档当前会话并开始新的对话状态。 /clear 、 /new 清理当前对话状态,开始新一段工作。 /side 问题 、 /btw 问题 独立的一次旁支问答,不调用工具。 /turn 补充说明 中断当前轮并发送指导;与排队卡片的插话时机不同。 /skills 、 /mcp 查看或管理技能与 MCP;带参数执行相应子命令。 /tools 、 /hooks 、 /connectors 打开对应能力管理入口。 /lsp 查看语言服务器状态。 /memory 列出、查看、编辑、遗忘或重载持久记忆。 /tasks TUI 管理后台子任务。 /proxy 检查有效代理及临时覆盖状态。 /rc 、 /remote-control 激活已配置的默认渠道;show 查看,off 解绑。 /exit 退出 TUI。 不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。 命令行参数 命令或参数 用途 acecode 在当前目录打开交互式 TUI。 acecode configure 初次配置或重新配置。 acecode --version 显示版本。 acecode -r 打开 TUI 历史会话选择。 acecode --resume [ID] TUI 恢复最近会话或指定会话;print 模式必须带 ID。 acecode -p \"提示\" 执行无界面单轮任务。 --output-format text|json|stream-json 选择 print 模式输出格式。 --model NAME 选择已保存预设。 --permission-mode MODE print 模式支持 default、accept-edits、plan、yolo。 --max-turns N print 模式的智能体循环上限。 --session-id ID 指定新会话 ID。 -c 、 --continue print 模式继续当前目录最近会话。 --list-tools 、 --list-skills 、 --list-mcp 只列出可发现能力。 --enable-skills 名称 、 --enable-mcp 名称 为 print 模式显式开启扩展。 --disable-tools 名称 为 print 模式禁用指定工具。 acecode daemon [start|status|stop] 独立后台管理。 acecode daemon --foreground 前台运行后台服务。 acecode daemon --cwd=路径 --port=端口 指定独立后台的起始工作目录与端口。 acecode upgrade 按已配置更新源执行自更新。 完整 print 参数运行 acecode -p --help 。危险启动参数 --yolo / --dangerous 会绕过权限与路径检查,不作为排错默认步骤。查看脚本示例及退出码,参见 CLI 。 本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径 内容 config.json 全局配置、模型连接与扩展定义,可能包含凭据。 config-backups/ 有效配置快照和损坏配置备份,也可能含凭据。 projects/ 按项目范围保存的会话、元数据和输入历史等记录。 skills/ 用户技能;还可读取其他已启用根目录。 memory/ MEMORY.md 索引与记忆条目。 hooks.json 用户级 Hooks 定义。 logs/ 按运行方式与日期保存的日志。 run/ 独立后台的 PID、端口、GUID、心跳和 Token。 run/desktop-shared/ 桌面托管后台的独立运行身份。 项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。 备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见 更新与卸载 。" + "text": "快捷键 入口 按键 作用 桌面 / Web Ctrl+K (macOS ⌘K ) 打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。 桌面 / Web 输入框 Enter 空闲时发送;运行时按当前状态排队。 桌面 / Web 输入框 Shift+Enter 换行。 桌面文件编辑器 Ctrl+S 保存当前可编辑文件。 桌面终端停靠区 Ctrl+` 显示或隐藏底部终端。 TUI Esc 等待时中断、退出 Shell 模式或拒绝当前确认。 TUI Ctrl+C 第一次清空输入并提示,1 秒内再次按下退出。 TUI Ctrl+O 全局展开或折叠工具结果。 TUI Ctrl+E 聚焦工具时切换该结果展开;否则移动到输入末尾。 TUI Ctrl+P 空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。 @ 候选菜单 ↑ / ↓ 、 Enter 选择并引用文件或文件夹。 @ 候选菜单 Tab / → 进入选中的文件夹。 按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。 斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查看文本概要。 /tokens 查看会话 token 用量。 /compact 调用模型压缩当前上下文。 /init 分析项目并生成或改进项目规则。 /resume TUI 恢复历史会话。 /rewind 、 /checkpoint TUI 打开回退选择器;代码恢复取决于检查点可用性。 /fork TUI 从用户回合选择分叉,按选择器确认范围。 /archive 归档当前会话并开始新的对话状态。 /clear 、 /new 清理当前对话状态,开始新一段工作。 /side 问题 、 /btw 问题 独立的一次旁支问答,不调用工具。 /turn 补充说明 中断当前轮并发送指导;与排队卡片的插话时机不同。 /skills 、 /mcp 查看或管理技能与 MCP;带参数执行相应子命令。 /tools 、 /hooks 、 /connectors 打开对应能力管理入口。 /lsp 查看语言服务器状态。 /memory 列出、查看、编辑、遗忘或重载全局与工作区记忆;flush 立即整理,off / on 只作用于当前会话。TUI 与网页输出一致。 /tasks TUI 管理后台子任务。 /proxy 检查有效代理及临时覆盖状态。 /rc 、 /remote-control 激活已配置的默认渠道;show 查看,off 解绑。 /exit 退出 TUI。 不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。 命令行参数 命令或参数 用途 acecode 在当前目录打开交互式 TUI。 acecode configure 初次配置或重新配置。 acecode --version 显示版本。 acecode -r 打开 TUI 历史会话选择。 acecode --resume [ID] TUI 恢复最近会话或指定会话;print 模式必须带 ID。 acecode -p \"提示\" 执行无界面单轮任务。 --output-format text|json|stream-json 选择 print 模式输出格式。 --model NAME 选择已保存预设。 --permission-mode MODE print 模式支持 default、accept-edits、plan、yolo。 --max-turns N print 模式的智能体循环上限。 --session-id ID 指定新会话 ID。 -c 、 --continue print 模式继续当前目录最近会话。 --list-tools 、 --list-skills 、 --list-mcp 只列出可发现能力。 --enable-skills 名称 、 --enable-mcp 名称 为 print 模式显式开启扩展。 --disable-tools 名称 为 print 模式禁用指定工具。 acecode daemon [start|status|stop] 独立后台管理。 acecode daemon --foreground 前台运行后台服务。 acecode daemon --cwd=路径 --port=端口 指定独立后台的起始工作目录与端口。 acecode upgrade 按已配置更新源执行自更新。 完整 print 参数运行 acecode -p --help 。危险启动参数 --yolo / --dangerous 会绕过权限与路径检查,不作为排错默认步骤。查看脚本示例及退出码,参见 CLI 。 本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径 内容 config.json 全局配置、模型连接与扩展定义,可能包含凭据。 config-backups/ 有效配置快照和损坏配置备份,也可能含凭据。 projects/ 按项目范围保存的会话、元数据和输入历史等记录。 skills/ 用户技能;还可读取其他已启用根目录。 memory/ 全局记忆:MEMORY.md 索引、条目、inbox / archive 与记忆状态库 state.sqlite3;工作区记忆在 projects/<标识>/memory/。 hooks.json 用户级 Hooks 定义。 logs/ 按运行方式与日期保存的日志。 run/ 独立后台的 PID、端口、GUID、心跳和 Token。 run/desktop-shared/ 桌面托管后台的独立运行身份。 项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。 备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见 更新与卸载 。" }, { "page": "速查手册", @@ -1406,7 +1414,7 @@ window.ACECODE_HELP_INDEX = [ "title": "斜杠命令", "url": "reference.html#commands", "description": "斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查", - "text": "斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查看文本概要。 /tokens 查看会话 token 用量。 /compact 调用模型压缩当前上下文。 /init 分析项目并生成或改进项目规则。 /resume TUI 恢复历史会话。 /rewind 、 /checkpoint TUI 打开回退选择器;代码恢复取决于检查点可用性。 /fork TUI 从用户回合选择分叉,按选择器确认范围。 /archive 归档当前会话并开始新的对话状态。 /clear 、 /new 清理当前对话状态,开始新一段工作。 /side 问题 、 /btw 问题 独立的一次旁支问答,不调用工具。 /turn 补充说明 中断当前轮并发送指导;与排队卡片的插话时机不同。 /skills 、 /mcp 查看或管理技能与 MCP;带参数执行相应子命令。 /tools 、 /hooks 、 /connectors 打开对应能力管理入口。 /lsp 查看语言服务器状态。 /memory 列出、查看、编辑、遗忘或重载持久记忆。 /tasks TUI 管理后台子任务。 /proxy 检查有效代理及临时覆盖状态。 /rc 、 /remote-control 激活已配置的默认渠道;show 查看,off 解绑。 /exit 退出 TUI。 不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。" + "text": "斜杠命令 命令 用途与范围 /help 查看当前注册的可用命令。 /model 模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。 /mode 查看或切换权限模式。 /config 打开设置;/config show 查看文本概要。 /tokens 查看会话 token 用量。 /compact 调用模型压缩当前上下文。 /init 分析项目并生成或改进项目规则。 /resume TUI 恢复历史会话。 /rewind 、 /checkpoint TUI 打开回退选择器;代码恢复取决于检查点可用性。 /fork TUI 从用户回合选择分叉,按选择器确认范围。 /archive 归档当前会话并开始新的对话状态。 /clear 、 /new 清理当前对话状态,开始新一段工作。 /side 问题 、 /btw 问题 独立的一次旁支问答,不调用工具。 /turn 补充说明 中断当前轮并发送指导;与排队卡片的插话时机不同。 /skills 、 /mcp 查看或管理技能与 MCP;带参数执行相应子命令。 /tools 、 /hooks 、 /connectors 打开对应能力管理入口。 /lsp 查看语言服务器状态。 /memory 列出、查看、编辑、遗忘或重载全局与工作区记忆;flush 立即整理,off / on 只作用于当前会话。TUI 与网页输出一致。 /tasks TUI 管理后台子任务。 /proxy 检查有效代理及临时覆盖状态。 /rc 、 /remote-control 激活已配置的默认渠道;show 查看,off 解绑。 /exit 退出 TUI。 不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。" }, { "page": "速查手册", @@ -1422,7 +1430,7 @@ window.ACECODE_HELP_INDEX = [ "title": "本地配置和数据", "url": "reference.html#data", "description": "本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径", - "text": "本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径 内容 config.json 全局配置、模型连接与扩展定义,可能包含凭据。 config-backups/ 有效配置快照和损坏配置备份,也可能含凭据。 projects/ 按项目范围保存的会话、元数据和输入历史等记录。 skills/ 用户技能;还可读取其他已启用根目录。 memory/ MEMORY.md 索引与记忆条目。 hooks.json 用户级 Hooks 定义。 logs/ 按运行方式与日期保存的日志。 run/ 独立后台的 PID、端口、GUID、心跳和 Token。 run/desktop-shared/ 桌面托管后台的独立运行身份。 项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。 备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见 更新与卸载 。" + "text": "本地配置和数据 下面的路径相对于个人数据目录 ~/.acecode/ ;Windows 为 %USERPROFILE%/.acecode/ 。Windows 服务另用 %PROGRAMDATA%/acecode/ ,远端进程使用远端身份的目录。 路径 内容 config.json 全局配置、模型连接与扩展定义,可能包含凭据。 config-backups/ 有效配置快照和损坏配置备份,也可能含凭据。 projects/ 按项目范围保存的会话、元数据和输入历史等记录。 skills/ 用户技能;还可读取其他已启用根目录。 memory/ 全局记忆:MEMORY.md 索引、条目、inbox / archive 与记忆状态库 state.sqlite3;工作区记忆在 projects/<标识>/memory/。 hooks.json 用户级 Hooks 定义。 logs/ 按运行方式与日期保存的日志。 run/ 独立后台的 PID、端口、GUID、心跳和 Token。 run/desktop-shared/ 桌面托管后台的独立运行身份。 项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。 备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见 更新与卸载 。" }, { "page": "从源码构建", diff --git a/docs/help/memory.html b/docs/help/memory.html index 01e42647c..3ac5fead2 100644 --- a/docs/help/memory.html +++ b/docs/help/memory.html @@ -35,19 +35,20 @@

记忆与个性化

个性化指令用于持续表达偏好,记忆用于保存可复用的信息。让保存的内容简短、明确,并随实际情况更新。

-
本页内容
+
本页内容

设置个性化指令

进入设置 > 个性化 > 自定义指令,填写稳定的工作习惯、输出语言或协作要求。离开编辑区后等待“已保存”提示;保存失败时先处理错误再离开。

自定义指令保存在当前用户配置中,会参与模型请求。项目特有的约定更适合写进项目规则,避免让其他项目继承不适合的目录或技术栈要求。

自定义指令示例
用简体中文解释结果。
 修改代码前先阅读相关实现。
 完成后说明改动与验证结果;没有执行的检查请明确写出。
个性化页的自定义指令编辑区,当前为空,浅灰文字为表单提示
个性化页面中的自定义指令编辑区。当前内容为空,浅灰文字是填写提示,可在此描述希望 ACECode 持续遵循的通用偏好。Windows 实际界面 · 点击图片查看原图
-

保存和查看记忆

需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。

默认记忆目录是 ~/.acecode/memory/,其中 MEMORY.md 为索引,具体条目分别保存为 Markdown 文件。TUI 的 /memory 用于列出记忆,更多操作可查看 /memory help。

记忆与聊天记录不同:恢复任务读取该任务的上下文,持久记忆用于后续工作重用信息。不要把整个项目源码或完整对话复制为一条记忆。

-

更新与删除过时信息

当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。删除时同样指定对象,先查看内容再操作;TUI 的记忆命令提供查看、编辑、忘记和重新加载入口。

适合长期保存的是稳定偏好、反复需要的项目说明和可复用经验。账号密钥、临时验证码、大段日志和未经确认的推测不适合作为普通记忆保存。分享备份前检查记忆与配置中的私密内容。

如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。

+

保存和查看记忆

需要记住长期信息时,可以在任务中明确提出要求,例如“记住:我希望解释结果时先给结论,再给验证依据”。阅读执行结果,确认实际保存了什么,避免把一次聊天中出现的信息都当作已经持久化。

记忆分两层:全局记忆保存跨项目的个人偏好,位于 ~/.acecode/memory/;工作区记忆只属于当前工作区,位于 ~/.acecode/projects/<工作区标识>/memory/。每层都有 MEMORY.md 索引,条目分别保存为 Markdown 文件。未指定时,个人偏好与协作反馈写入全局,项目背景与外部资料写入工作区。

每个会话在第一次请求时读取一份记忆快照,之后保持不变;会话中途新写的记忆在下一个会话或本会话压缩上下文之后出现。记忆是历史记录,智能体依赖其中的路径或命令前会先核实,与你当前的要求冲突时以当前要求为准。

TUI 与桌面 / 网页对话都可以用 /memory:/memory list 列出两层记忆,/memory view 名称 查看,/memory forget 名称 删除,/memory off 与 /memory on 只对当前会话关闭或恢复记忆。

+

记忆摘要

设置 > 个性化 > 记忆中可以开启记忆摘要(默认关闭)。开启后,会话闲置约 30 分钟,ACECode 用摘要模型把这段对话中值得长期保留的经验提炼成观察,攒够一批后整合为正式记忆;每次自动改动都记录来源会话。摘要模型默认是该会话最后使用的模型,也可以指定一个已保存的模型。开启会额外调用模型。

想立即整理当前会话时执行 /memory flush,完成后对话里会出现处理结果。永久删除一个会话时,只来源于它的自动记忆会一并撤回;你手动删除的记忆在 90 天内不会被自动重新生成。

+

更新与删除过时信息

当工作习惯或项目信息变化时,明确告诉 ACECode 要更新哪条记忆以及新内容。也可以在设置 > 个性化 > 记忆中按“全局 / 当前工作区”浏览、编辑、删除条目,或在二次确认后重置某一层。手动编辑过的条目不会再被记忆摘要改动。

写入记忆前,ACECode 会把可识别的密钥、令牌和密码替换为 [REDACTED];即便如此,账号密钥、临时验证码、大段日志和未经确认的推测也不适合保存为记忆。分享备份前检查记忆与配置中的私密内容。

如果某条旧信息反复影响回答,先检查个性化指令、项目规则和记忆三个来源,修正对应来源后再开始新任务验证。

- +
diff --git a/docs/help/reference.html b/docs/help/reference.html index f244a4f25..c06ad0130 100644 --- a/docs/help/reference.html +++ b/docs/help/reference.html @@ -39,12 +39,12 @@

快捷键

入口按键作用
桌面 / WebCtrl+K(macOS ⌘K)打开或关闭全局搜索;输入关键词可同时查找任务、项目和设置项,选中设置项会直接打开设置并定位到该项。
桌面 / Web 输入框Enter空闲时发送;运行时按当前状态排队。
桌面 / Web 输入框Shift+Enter换行。
桌面文件编辑器Ctrl+S保存当前可编辑文件。
桌面终端停靠区Ctrl+`显示或隐藏底部终端。
TUIEsc等待时中断、退出 Shell 模式或拒绝当前确认。
TUICtrl+C第一次清空输入并提示,1 秒内再次按下退出。
TUICtrl+O全局展开或折叠工具结果。
TUICtrl+E聚焦工具时切换该结果展开;否则移动到输入末尾。
TUICtrl+P空闲普通输入状态切换权限;菜单或历史状态按对应导航处理。
@ 候选菜单↑ / ↓、Enter选择并引用文件或文件夹。
@ 候选菜单Tab / →进入选中的文件夹。

按键取决于焦点和当前模式。浏览器、操作系统或终端软件可能拦截组合键;遇到冲突优先使用可见按钮或菜单。本文档站自己的 Ctrl+K 只搜索文档内容,与 ACECode 产品内的 Ctrl+K 全局搜索是两个独立功能。

-

斜杠命令

命令用途与范围
/help查看当前注册的可用命令。
/model模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。
/mode查看或切换权限模式。
/config打开设置;/config show 查看文本概要。
/tokens查看会话 token 用量。
/compact调用模型压缩当前上下文。
/init分析项目并生成或改进项目规则。
/resumeTUI 恢复历史会话。
/rewind、/checkpointTUI 打开回退选择器;代码恢复取决于检查点可用性。
/forkTUI 从用户回合选择分叉,按选择器确认范围。
/archive归档当前会话并开始新的对话状态。
/clear、/new清理当前对话状态,开始新一段工作。
/side 问题、/btw 问题独立的一次旁支问答,不调用工具。
/turn 补充说明中断当前轮并发送指导;与排队卡片的插话时机不同。
/skills、/mcp查看或管理技能与 MCP;带参数执行相应子命令。
/tools、/hooks、/connectors打开对应能力管理入口。
/lsp查看语言服务器状态。
/memory列出、查看、编辑、遗忘或重载持久记忆。
/tasksTUI 管理后台子任务。
/proxy检查有效代理及临时覆盖状态。
/rc、/remote-control激活已配置的默认渠道;show 查看,off 解绑。
/exit退出 TUI。
+

斜杠命令

命令用途与范围
/help查看当前注册的可用命令。
/model模型选择;带名称可切换预设,TUI 支持 --cwd、--default 范围。
/mode查看或切换权限模式。
/config打开设置;/config show 查看文本概要。
/tokens查看会话 token 用量。
/compact调用模型压缩当前上下文。
/init分析项目并生成或改进项目规则。
/resumeTUI 恢复历史会话。
/rewind、/checkpointTUI 打开回退选择器;代码恢复取决于检查点可用性。
/forkTUI 从用户回合选择分叉,按选择器确认范围。
/archive归档当前会话并开始新的对话状态。
/clear、/new清理当前对话状态,开始新一段工作。
/side 问题、/btw 问题独立的一次旁支问答,不调用工具。
/turn 补充说明中断当前轮并发送指导;与排队卡片的插话时机不同。
/skills、/mcp查看或管理技能与 MCP;带参数执行相应子命令。
/tools、/hooks、/connectors打开对应能力管理入口。
/lsp查看语言服务器状态。
/memory列出、查看、编辑、遗忘或重载全局与工作区记忆;flush 立即整理,off / on 只作用于当前会话。TUI 与网页输出一致。
/tasksTUI 管理后台子任务。
/proxy检查有效代理及临时覆盖状态。
/rc、/remote-control激活已配置的默认渠道;show 查看,off 解绑。
/exit退出 TUI。

不同入口的管理命令可能打开界面或返回文本,不能把 TUI 选择器当作所有前端都有的窗口。桌面/Web 在命令菜单中选择后仍需提交。技能也可能注册自己的斜杠入口,以当前发现的名称为准。

命令行参数

命令或参数用途
acecode在当前目录打开交互式 TUI。
acecode configure初次配置或重新配置。
acecode --version显示版本。
acecode -r打开 TUI 历史会话选择。
acecode --resume [ID]TUI 恢复最近会话或指定会话;print 模式必须带 ID。
acecode -p "提示"执行无界面单轮任务。
--output-format text|json|stream-json选择 print 模式输出格式。
--model NAME选择已保存预设。
--permission-mode MODEprint 模式支持 default、accept-edits、plan、yolo。
--max-turns Nprint 模式的智能体循环上限。
--session-id ID指定新会话 ID。
-c、--continueprint 模式继续当前目录最近会话。
--list-tools、--list-skills、--list-mcp只列出可发现能力。
--enable-skills 名称、--enable-mcp 名称为 print 模式显式开启扩展。
--disable-tools 名称为 print 模式禁用指定工具。
acecode daemon [start|status|stop]独立后台管理。
acecode daemon --foreground前台运行后台服务。
acecode daemon --cwd=路径 --port=端口指定独立后台的起始工作目录与端口。
acecode upgrade按已配置更新源执行自更新。

完整 print 参数运行 acecode -p --help。危险启动参数 --yolo / --dangerous 会绕过权限与路径检查,不作为排错默认步骤。查看脚本示例及退出码,参见 CLI。

本地配置和数据

下面的路径相对于个人数据目录 ~/.acecode/;Windows 为 %USERPROFILE%/.acecode/。Windows 服务另用 %PROGRAMDATA%/acecode/,远端进程使用远端身份的目录。

-
路径内容
config.json全局配置、模型连接与扩展定义,可能包含凭据。
config-backups/有效配置快照和损坏配置备份,也可能含凭据。
projects/按项目范围保存的会话、元数据和输入历史等记录。
skills/用户技能;还可读取其他已启用根目录。
memory/MEMORY.md 索引与记忆条目。
hooks.json用户级 Hooks 定义。
logs/按运行方式与日期保存的日志。
run/独立后台的 PID、端口、GUID、心跳和 Token。
run/desktop-shared/桌面托管后台的独立运行身份。
+
路径内容
config.json全局配置、模型连接与扩展定义,可能包含凭据。
config-backups/有效配置快照和损坏配置备份,也可能含凭据。
projects/按项目范围保存的会话、元数据和输入历史等记录。
skills/用户技能;还可读取其他已启用根目录。
memory/全局记忆:MEMORY.md 索引、条目、inbox / archive 与记忆状态库 state.sqlite3;工作区记忆在 projects/<标识>/memory/。
hooks.json用户级 Hooks 定义。
logs/按运行方式与日期保存的日志。
run/独立后台的 PID、端口、GUID、心跳和 Token。
run/desktop-shared/桌面托管后台的独立运行身份。

项目规则和项目技能可能保存在仓库本身。TUI 的 acecode.log 位于启动工作目录,不在上述 logs 目录。Git 工作树路径以任务实际记录为准。

备份前先结束相关写入,并同时考虑会话数据与实际项目文件。不要只备份 Markdown 导出就假定能够完整恢复,也不要在运行时随意删除身份文件或数据库。卸载与保留数据见更新与卸载。

diff --git a/docs/releases/v0.9.31.md b/docs/releases/v0.9.31.md new file mode 100644 index 000000000..b9771b1e0 --- /dev/null +++ b/docs/releases/v0.9.31.md @@ -0,0 +1,11 @@ +# ACECode 0.9.31(未发布) + +- 记忆统一为一套实现:TUI、桌面 / 网页后台与 `acecode -p` 都提供 `memory_read` / `memory_write`,注入规则、`/memory` 命令输出一致。 +- 记忆分为全局与工作区两层,工作区按现有工作区标识区分;条目记录创建 / 更新时间与来源,写入前对密钥、令牌、密码脱敏。 +- 记忆注入改为会话快照:会话中途写入的记忆不再改变本会话的请求前缀,压缩上下文后重建;条目标注距今天数。 +- 记忆注入预算 `memory.max_index_bytes` 默认值由 32 KiB 改为每层 8 KiB,超出的条目以一行省略说明代替,仍可经 `memory_read` 查看。 +- 新增「设置 > 个性化 > 记忆」:使用记忆开关、记忆摘要(默认关闭,可选摘要模型)、按层浏览 / 编辑 / 删除条目与重置。 +- 新增记忆摘要:会话闲置后提炼经验、攒批整合为记忆,`/memory flush` 立即整理;永久删除会话会撤回只来源于它的自动记忆。 +- 长会话超出上下文时,先清理旧工具输出、再把旧回合精简为用户消息、摘要和最终回复,最后才整轮删除,并保留最近一次压缩摘要,减少模型遗忘原始任务和用户纠正。 +- 回复中夹带工具参数模板标记(`` / ``)时丢弃该步并自动重试一次,不再执行参数错乱的工具调用。 +- 升级时新版本自检的等待时间由 5 秒放宽到 120 秒,避免安全软件首次扫描新程序导致升级被回滚。 diff --git a/openspec/changes/unify-memory-system/.openspec.yaml b/openspec/changes/unify-memory-system/.openspec.yaml new file mode 100644 index 000000000..480ad4ded --- /dev/null +++ b/openspec/changes/unify-memory-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-01 diff --git a/openspec/changes/unify-memory-system/design.md b/openspec/changes/unify-memory-system/design.md new file mode 100644 index 000000000..7eaad53c7 --- /dev/null +++ b/openspec/changes/unify-memory-system/design.md @@ -0,0 +1,112 @@ +# Design: unify-memory-system + +## Context + +动机见 proposal.md。与方案相关的现状: + +- **记忆模块**:`src/domain/memory/`(`MemoryRegistry` 目录缓存、`MEMORY.md` 索引渲染、frontmatter 解析、路径校验),单一目录 `/memory/`。工具 `memory_read` / `memory_write` 在 `src/adapters/tool/`。TUI 命令 `src/apps/tui/commands/memory_command.cpp`(list / view / edit / forget / reload)。 +- **注入**:`build_user_memory_context_prompt` 把整份 `MEMORY.md` 拼进会话上下文块(与项目指令、自定义指令同一块),每次请求重建,由 `PromptContextCache` 按内容 key 钉住。记忆一变,本会话后续请求的上下文块就变。 +- **接线**:TUI 在 `tui_runtime_init.cpp` 注册记忆工具并把注册表传给子会话宿主;daemon(`worker.cpp`)与 headless(`headless_runner.cpp`)在 master 上传空指针。分支 `claude/fix-feedback-0930` 已给 daemon 接线,并让写入前按磁盘重扫。 +- **可复用设施**:共享设置修改层 `src/base/config/settings_mutations`(网页路由与 TUI 设置中心都走它);自动标题的后台模型解析与创建(`session_auto_title.cpp` 的 `explicit_profile` / `create_provider_from_entry`);SQLite RAII 封装 `platform/unique_sqlite.hpp`;会话永久删除 `SessionStorage::purge_session_files`;daemon 内置命令白名单 `SessionRegistry::execute_builtin_command`;`/lsp` 那种「一份实现、两端调用」的文本命令分发模式。 +- **分层约束**:`src/host/app_runtime/` 是重构计划为「三入口共用组合根」预留的模块,本期不得创建(重构 D10:本期保持三入口差异)。 +- **参考实现**(源码调研,均默认关闭):Codex 会话闲置 6 小时后用便宜模型逐会话提炼、再由单写者整合,注入 ≤2500 token 的摘要且只在会话开始 / 压缩后注入,作业靠 SQLite 租约;grok-build v2 每回合后提炼「观察」进收件箱,满 20 条或 24 小时整合为主题文件,整合操作必须引用观察作为证据,删除留墓碑,索引每会话注入一次并持久化。 + +## Goals / Non-Goals + +**Goals:** +- 记忆的装配、注入规则、命令语义只有一份实现,三个入口调用同一个入口函数。 +- 注入内容在会话内稳定,后台写入与其他进程写入都不会改变进行中会话的请求前缀。 +- 多进程共享数据目录时写入不丢、作业不重复。 +- 记忆摘要默认关闭;开启后成本有界(闲置触发、每轮上限、输入预算),每一处自动改动都可追溯(来源、依据、归档)。 + +**Non-Goals:** +- 不创建 `host/app_runtime`;记忆装配放在 `session_host`,接口按日后整体迁入组合根的方式设计。 +- 不引入新的第三方依赖(不做 FTS / 向量检索)。 +- 不用会调用工具的子代理做整合。 +- 会话记录文件格式不变,只在会话 meta 增加一个可省略字段。 + +## Decisions + +### D1 装配:`session_host` 里的 `MemoryRuntime` +新增 `src/host/session_host/memory_runtime.{hpp,cpp}`:持有记忆存储(全局一份、工作区按需打开)、运行时记忆配置快照、工具注册、上下文快照构建、`/memory` 命令分发,以及(daemon 与 TUI 中)记忆摘要调度器。三个组合根各调用一次 `create_memory_runtime(config, data_dir, surface)`,把它交给 `ToolExecutor`、`SessionRegistryDeps` 与 `AgentLoopServices`;子会话沿用父会话的运行时。 + +- 备选:现在就建 `host/app_runtime` —— 违反重构的阶段安排,拒绝。 +- 备选:把记忆工具塞进 `register_session_builtin_tools` —— 那里只放无状态工具,记忆工具需要按会话解析作用域,拒绝。 + +### D2 作用域:以会话自己的项目目录为准 +全局作用域 `/memory/`;工作区作用域 `get_project_dir(会话 cwd)/memory/`,与会话存储同一个标识,worktree 会话的存储本就归主工作区。解析作用域一律用**会话**的项目目录(工具上下文里的会话与工作区信息),不用进程 cwd —— daemon 一个进程服务多个工作区,拿进程 cwd 会读错目录(CLAUDE.md 记录过同族问题)。`MemoryRegistry` 泛化为按目录实例化的存储,全局与各工作区各一个实例。 + +- 备选:按 git 远端识别 —— 用户明确否决(对一般用户不友好)。 + +### D3 注入:按会话冻结的记忆快照 +把记忆块从会话上下文块里拆出来单独缓存:会话第一次请求时渲染一次,存进 `PromptContextCache`(连同当时的压缩代数);之后同一会话直接复用,压缩代数变化(摘要压缩或线程修复)或会话恢复时重建。渲染规则:按作用域分段,每段逐行取 `MEMORY.md` 条目并附条目 `updated_at` 距今天数,超过 `max_index_bytes`(默认改为 8 KiB,每个作用域)时截断并加省略说明;末尾附固定的使用说明(记忆是历史、用前核实、当前指令优先)。 + +- 备选:保留「按内容 key 每次请求重建」—— 记忆摘要与其他进程的写入会在回合中途改变请求内容,打穿 prompt cache,拒绝。 +- 备选:像 grok 那样把索引写进会话记录持久化 —— ACECode 的会话上下文约定是「只进请求、不落盘」,不改变这一约定。 + +### D4 条目元数据与兼容 +frontmatter 增加 `created_at`、`updated_at`、`source`、`source_sessions`;解析宽容,旧文件照常加载,系统写入时补齐(`created_at` 取文件原修改时间)。旧版本只认全局目录、忽略未知字段,因此新数据对旧版本向后兼容(工作区记忆对旧版本不可见)。 + +### D5 状态库:`/memory/state.sqlite3` +所有进程共用一个库(WAL、busy_timeout),表:提炼进度(每会话一行:工作区、提炼位置、尝试次数、租约持有者与到期时间、最近错误)、整合进度(每作用域一行,同样带租约)、墓碑(作用域、名字、规范化标题、删除时间)、状态摘要(最近提炼 / 整合时间与错误)。租约持有者令牌为「pid + 随机数」,长作业定期续约;过期即可被其他进程接手。提交做成幂等(按观察 id 与计划 hash 去重),崩溃后重放不会重复写入。 + +- 备选:锁文件(grok 旧版)—— 状态展示、墓碑、调度都需要查询,仍要一个库,拒绝。 +- 备选:复用各工作区的 `state.sqlite3`(goal 存储)—— 全局作用域与跨工作区调度需要单一的库;goal 存储以「文件存在」作为惰性打开条件,不宜混用,拒绝。 + +### D6 多进程写入:状态库事务做作用域写锁 +每次写条目 / 重建索引 / 整合应用,在状态库上开 `BEGIN IMMEDIATE` 事务作为该作用域的跨进程写锁,锁内先按磁盘重扫再写。重扫(已在修复分支落地)解决「缓存旧」,写锁解决「两个进程同时重扫再同时写」的窗口。 + +### D7 脱敏:手写扫描器,单一收口 +`src/domain/memory/secret_redaction.{hpp,cpp}`,在存储层写入口统一调用,覆盖工具写入、观察、整合产物、网页编辑四条路径。识别:已知前缀的密钥(`sk-`、`ghp_`、`gho_`、`xai-`、`AKIA` 等)、`Bearer` 令牌、PEM 私钥块、URL 中的用户名密码、`password|passwd|pwd|token|secret|api_key|apikey|密码` 后跟 `=` / `:` / `:` 的值。用逐字符扫描实现。 + +- 备选:`std::regex` —— 大输入下 MSVC 实现慢且有栈溢出风险,项目里同类判定都改成了手写扫描,拒绝。 + +### D8 `/memory`:一份文本实现,两端调用 +`dispatch_memory_command(args, ctx) -> text` 放在 `session_host`,TUI 内置命令与 daemon 内置命令白名单都调用它,网页斜杠下拉列出它;只有 `edit` 分端处理(TUI 开编辑器、网页提示去设置页)。`flush` 立即返回「已开始整理」,完成后以系统通知(只进界面记录)报告处理的观察数与改动的条目数,避免 REST 调用被模型请求阻塞。 + +### D9 会话级开关 +会话 meta 新增 `memory_mode`(`off` 时写入,默认省略即开启)。`AgentLoop` 在回合开始读取:关闭时不注入记忆块,并在模型侧工具表里过滤掉记忆工具(与按模型族裁剪编辑工具同一处),调度器跳过该会话。 + +### D10 设置:配置文件为准,调度器按修改时间重载 +配置新增 `memory.summary.{enabled=false, model_name="", idle_minutes=30, max_session_age_days=7}`;`max_index_bytes` 默认改为 8 KiB。修改走 `settings_mutations` 新增的 `set_memory_settings`,网页 `/api/config/memory` 与 TUI 设置中心共用。每个 daemon 各持一份内存配置,调度器每轮检查 `config.json` 修改时间,变了就重读 `memory` 段,所以别的进程改的设置一分钟内生效。 + +- 备选:把设置存进状态库 —— `config.json` 是用户可见、可手改的设置来源,其他设置都在那里,拒绝。 + +### D11 调度器 +`MemorySummaryScheduler` 只在 daemon 与 TUI 进程中启动(headless 不启动),每 2 分钟一轮:枚举本进程服务的工作区中最近 `max_session_age_days` 内活动过的会话(用分页的 meta 读取,不全量扫描),按规格筛出待提炼会话;会话是否「正在进行」看注册表的忙碌状态与会话写入租约;每进程同时最多一个提炼请求、每轮最多 2 个会话;提炼成功后检查对应作用域是否达到整合条件。 + +### D12 提炼 +输入取规范会话记录中提炼位置(消息序号)之后的部分,只保留用户可见的用户消息、助手正文与工具结果;按「用户原话 > 助手最终结论 > 助手中间说明 > 工具输出」逐级裁剪到预算内(摘要模型窗口的 60%,工具输出单条 2 KB)。模型请求不带工具、非流式,输出按规格的 JSON 严格解析(多余字段即拒)。模型解析顺序:`memory.summary.model_name` → 会话最后使用的模型 → 默认模型(沿用自动标题的解析与创建函数)。PA 网关报上下文超限时下次重试把输入预算减半。 + +### D13 整合:JSON 操作计划 +把作用域现有条目(`source: manual` 的标为只读)、本批观察、墓碑一起交给模型,模型返回操作计划(`create` / `update` / `merge` / `delete`,每项带依据观察 id)。先整体校验(规格列出的全部规则),通过后在作用域写锁内应用:写条目、重建索引、把本批观察移入 `archive/<日期>/`、记录计划 hash;任一步失败整批回滚到应用前状态并记录错误。归档每轮清理 30 天前的内容。 + +- 备选:Codex 式「受限子代理 + 文件工具 + diff 基线」—— 需要沙盒化文件操作、产物校验更难、token 更多;JSON 计划可确定性校验、可单测,拒绝前者。 + +### D14 遗忘 +在会话永久删除的调用路径(REST `DELETE /api/sessions/:id?purge=1`、TUI `/tasks clear` 等最终都走 `purge_session_files`)后调用 `MemoryRuntime::forget_session`:删收件箱与归档中该会话的观察,从 `source: summary` 条目的来源里移除该会话,无来源者删除并记墓碑。 + +### D15 网页与 TUI 界面 +网页:个性化页新增「记忆」区,组件 `MemorySettings.jsx`,纯逻辑 `lib/memorySettings.js`(Node 单测),`api.js` 新增 `/api/config/memory` 与 `/api/memory*`;样式遵循 acecode-frontend-style。TUI:设置中心个性化页新增开关与模型选择,条目管理用 `/memory`。 + +## Risks / Trade-offs + +- [摘要成本] → 默认关闭;闲置触发;每轮上限;输入预算;可选本地或轻量模型。 +- [记忆投毒:工具输出或网页内容诱导写入] → 提炼提示要求忽略对话中的指令;观察带来源可追溯;整合必须引用依据;手写条目只读;界面可审阅、删除;外部内容污染标记列为后续。 +- [观察质量参差、越积越多] → 允许 `noop`;标题与陈述有长度上限;整合后本批全部归档;归档 30 天清理。 +- [预算截断导致条目看不见] → 省略说明 + `memory_read` 可查;条目按更新时间倒序列出。 +- [PA 网关对摘要请求随机拒收] → 失败隔离、退避重试,超限时预算减半。 +- [多进程时钟与崩溃] → 租约带到期时间与持有者令牌,提交幂等。 +- [配置重载延迟] → 规格承诺一分钟内;调度器每轮检查修改时间。 +- [默认预算从 32 KiB 降到 8 KiB] → 发布说明写明;超出部分仍可经 `memory_read` 获取。 +- [主规格格式] `openspec/specs/global-memory/spec.md` 仍是 `## ADDED Requirements` 形态,解析出的需求为 0,归档会拒绝本变更的 MODIFIED → 归档前先把主规格规范为 `## Requirements`。 + +## Migration Plan + +1. **一期(不增加模型调用)**:`MemoryRuntime` 与三入口接线、两层作用域、会话快照注入、元数据、脱敏、状态库与写锁、`/memory` 两端统一、会话开关、REST 与网页 / TUI 设置(记忆摘要开关此时可见但调度器未上线时保持禁用)。 +2. **二期(记忆摘要)**:调度器、提炼、整合、遗忘与墓碑、状态展示;开关解禁。 +3. **回滚**:`memory.enabled=false` 关闭全部记忆;记忆摘要默认关闭;数据格式对旧版本向后兼容,回退版本后全局记忆照常可用。 + +## Open Questions + +- 闲置阈值 30 分钟、整合阈值(20 条 / 24 小时)与每轮上限 2 个会话是否合适,可在上线后按实际调用量调整,不影响规格与任务拆分。 diff --git a/openspec/changes/unify-memory-system/proposal.md b/openspec/changes/unify-memory-system/proposal.md new file mode 100644 index 000000000..3d23f687b --- /dev/null +++ b/openspec/changes/unify-memory-system/proposal.md @@ -0,0 +1,44 @@ +# Proposal: unify-memory-system + +## Why + +ACECode 的记忆只有「一个全局目录 + 每次请求注入索引 + `memory_read` / `memory_write` + TUI `/memory`」,而且只在 TUI 里完整接线:daemon(桌面版 / 网页版)和 headless 一直给记忆传空指针,桌面用户的模型根本没有记忆工具。反馈 LINDANDAN069 暴露了后果:用户让模型「记住这个处理方式」,模型只能把经验写进子目录 `CLAUDE.md`、`.acecode/MEMORY.md` 这类 ACECode 从不自动加载的文件,压缩之后就「忘了」,还重写已验证过的函数。 + +对照 Codex(`codex-rs/memories`)与 grok-build(`xai-grok-memory` v2)的源码,两家都把记忆做成了后台自动积累、分层整理、有预算注入的一套机制;ACECode 缺的不是某个工具,而是一套三个入口共用、能自动积累、用户看得见管得了的记忆系统。 + +## What Changes + +- **一套实现,三个入口共用**:TUI、daemon、headless 通过同一个装配入口拿到记忆服务(存储、工具、注入、命令),不再各自接线;子会话沿用父会话的记忆服务。 +- **两层作用域**:全局(个人偏好,`/memory/`,现有条目原样保留)+ 工作区(`/projects/<工作区 hash>/memory/`,按现有工作区 hash 识别,不看 git 远端)。worktree 会话的存储本来就归主工作区,记忆随之共享。 +- **注入改为会话快照**:会话首次请求时生成一份有预算的记忆索引(全局 + 工作区),压缩后重建;会话中途写入不改变本会话已注入的内容,不打穿 prompt cache。索引条目带「N 天前」,提示词明确记忆是历史记录、用前核实、用户当前指令优先。 +- **条目元数据**:创建 / 更新时间、来源(手写 / 摘要生成)、来源会话;写入前统一做敏感信息脱敏(密钥、token、密码、带凭据的 URL)。 +- **工具扩展**:`memory_read` 支持作用域与关键字查找;`memory_write` 支持作用域(未指定时按类型推断),写入走脱敏与来源记录。 +- **管理界面统一**:`/memory` 在 TUI 与网页斜杠命令里是同一套子命令(列出 / 查看 / 删除 / 立即整理 / 本会话开关);网页「设置 > 个性化」新增「记忆」区:使用记忆开关、**记忆摘要开关(默认关)与摘要模型(默认当前模型)**、按作用域浏览 / 编辑 / 删除条目、一键重置。 +- **多进程安全**:新增记忆状态库(SQLite,WAL + 租约),TUI 与多个 daemon 同时运行时写入与后台作业互斥;写前按磁盘重扫(已在 `claude/fix-feedback-0930` 落地)。 +- **自动积累(记忆摘要,默认关)**:会话闲置一段时间后,用摘要模型把会话提炼成「观察」放进作用域的收件箱;收件箱攒够一批或到时间后整合成正式条目(新建 / 更新 / 合并 / 删除,每步必须引用收件箱里的观察作为依据),原观察归档;`/memory flush` 手动触发。清除会话时撤回只来源于它的记忆;用户删除的条目留墓碑,整合不得再造。 + +## Capabilities + +### New Capabilities + +- `memory-summarization`:记忆摘要(自动提炼 + 整合)的开关、模型选择、触发时机、提炼与整合规则、遗忘与墓碑、作业并发与失败处理。 + +### Modified Capabilities + +- `global-memory`:目录布局改为全局 + 工作区两层并新增收件箱 / 归档 / 状态库;条目元数据扩展;注入从「每次请求注入全局索引」改为「会话快照 + 预算 + 陈旧度标注」;`memory_read` / `memory_write` 增加作用域与脱敏;`/memory` 命令三端一致并新增子命令;线程安全扩展为多进程安全;新增三入口一致与网页管理界面要求。 + +## Non-goals + +- 向量 / 嵌入检索与 FTS 全文检索(两家参考实现默认都不启用;记忆规模上来后另立项)。 +- 按 git 远端识别工作区。 +- 模型主动删除记忆(删除只来自用户操作与会话清除;模型只能新增 / 更新)。 +- 引用统计与按使用率淘汰、外部内容污染标记、从 Claude Code / Codex 导入记忆(列为后续候选)。 +- 修改项目指令文件(AGENTS.md / CLAUDE.md)的加载规则。 + +## Impact + +- 代码:`src/domain/memory/`(作用域、元数据、脱敏、状态库、墓碑)、新增记忆服务装配与后台作业(`src/host/`)、`src/adapters/tool/memory_*_tool`、`src/engine/prompt/system_prompt.cpp` 与请求上下文注入、`src/apps/{tui,daemon,headless}` 三个入口、`SessionRegistry` 内置命令白名单与会话清除钩子、`src/base/config`(`memory.summary.*` 等新配置)。 +- 接口:新增 `/api/config/memory`、`/api/memory*` REST;`docs/daemon-api.md` 同步。 +- 前端:`SettingsPage` 个性化区、新组件与纯逻辑模块、`api.js`、i18n 目录。 +- 数据:新增目录与 `memory/state.sqlite3`;现有 `~/.acecode/memory/` 条目兼容读取、首次写入时补元数据。 +- 成本:记忆摘要开启后,每个闲置会话一次模型调用、每批整合一次模型调用;默认关闭。 diff --git a/openspec/changes/unify-memory-system/specs/global-memory/spec.md b/openspec/changes/unify-memory-system/specs/global-memory/spec.md new file mode 100644 index 000000000..846fccd45 --- /dev/null +++ b/openspec/changes/unify-memory-system/specs/global-memory/spec.md @@ -0,0 +1,280 @@ +## MODIFIED Requirements + +### Requirement: Memory Directory Layout +系统 SHALL 把记忆分两个作用域存放: +- **全局作用域**:`/memory/`(默认数据目录下即 `~/.acecode/memory/`,与现有位置相同),存放跨项目的个人偏好。 +- **工作区作用域**:`/projects/<工作区标识>/memory/`,`<工作区标识>` 与该工作区会话存储目录使用的标识相同(由会话所属工作目录得出),MUST NOT 读取 git 远端地址参与计算。 + +每个作用域目录 MUST 只在顶层识别条目文件 `.md`(名字仅含 `[A-Za-z0-9_-]`,1–64 字节)与生成的索引 `MEMORY.md`;保留子目录 `inbox/`(待整合的观察)与 `archive/`(已整合的观察)供记忆摘要使用,其余子目录一律忽略。所有进程共用的记忆状态库位于 `/memory/state.sqlite3`。 + +#### Scenario: First-run creation +- **WHEN** 记忆启用且全局目录不存在 +- **THEN** 创建空的全局目录;工作区目录在首次写入该作用域时才创建;两个作用域都没有条目时不注入任何记忆上下文 + +#### Scenario: Read existing index +- **WHEN** 升级前 `~/.acecode/memory/` 下已有合法条目 +- **THEN** 它们作为全局条目原样加载,不需要迁移、不改写文件 + +#### Scenario: worktree 会话共享工作区记忆 +- **WHEN** 工作区 W 的会话进入 ACECode worktree 后读写工作区记忆 +- **THEN** 读写的都是 W 的工作区作用域,与在主检出中运行的会话看到同一份条目 + +#### Scenario: Oversized index truncated +- **WHEN** 某个作用域的条目索引超过 `config.memory.max_index_bytes` +- **THEN** 注入的记忆上下文只列出预算内的条目并附一行省略说明;磁盘上的 `MEMORY.md` 不被修改 + +#### Scenario: Unexpected subdirectory +- **WHEN** 作用域目录下存在 `skills/` 等非保留子目录 +- **THEN** 该子目录不被扫描,只有顶层 `*.md`(`MEMORY.md` 除外)算作条目 + +### Requirement: Memory Entry Frontmatter +每个条目文件 SHALL 以 `---` 包围的 YAML frontmatter 开头。必填字段:`name`(非空)、`description`(非空)、`type`(`user` / `feedback` / `project` / `reference` 之一)。系统维护的来源字段:`created_at`、`updated_at`(UTC ISO-8601)、`source`(`manual` 或 `summary`)、`source_sessions`(会话 id 列表)。未知字段 MUST 保留在磁盘上并被忽略。缺少来源字段的旧条目 MUST 仍然有效,并在下一次被系统写入时补齐。 + +#### Scenario: Valid entry +- **WHEN** 条目含 `name`、`description`、`type: user` 与正文 +- **THEN** 条目加载成功,正文可经 `memory_read` 读取 + +#### Scenario: Missing required field +- **WHEN** 条目缺少 `type` +- **THEN** 该条目不加载、记录警告,进程不崩溃 + +#### Scenario: Invalid type value +- **WHEN** 条目 `type: notes`(不在四类之内) +- **THEN** 该条目不加载并记录警告 + +#### Scenario: Malformed frontmatter +- **WHEN** frontmatter 缺少结束的 `---` 或引号不配对 +- **THEN** 退回简单 `key: value` 解析;仍无法解析则跳过该条目并记录警告 + +#### Scenario: 旧条目补齐来源字段 +- **WHEN** 一个没有 `created_at` / `updated_at` 的旧条目被 `memory_write` 以 update 方式改写 +- **THEN** 写入后的文件含 `updated_at`(本次时间)与 `source: manual`,`created_at` 取文件原有的修改时间 + +### Requirement: System Prompt Injection +记忆启用且会话未关闭记忆时,系统 SHALL 在会话的第一次模型请求时生成一份**记忆上下文快照**,包含全局与当前工作区两个作用域的索引,并在该会话之后的每次请求中逐字节复用,直到该会话的历史被压缩(摘要压缩或改写历史的线程修复)才从磁盘重建。快照 MUST 满足: +- 每个作用域的索引不超过 `config.memory.max_index_bytes`(默认 8 KiB);超出的条目不列出,改为一行说明省略了多少条、可用 `memory_read` 查看。 +- 条目带有时间信息时,每行标注距今天数(如「3 天前」)。 +- 附带使用说明:记忆是历史记录,依赖其中的路径、命令、仓库状态前 MUST 用工具核实;与用户当前指令冲突时以当前指令为准。 +- 快照只进入发给模型的请求,MUST NOT 写入会话记录文件。 + +两个作用域都没有条目、记忆被配置关闭或本会话关闭记忆时,MUST NOT 出现记忆上下文。 + +#### Scenario: Index present +- **WHEN** 全局或当前工作区作用域中有条目,会话发出第一次请求 +- **THEN** 请求中包含记忆上下文,按作用域分段列出条目(带距今天数)与使用说明 + +#### Scenario: 会话内快照稳定 +- **WHEN** 会话进行中通过 `memory_write` 新增了一条工作区记忆 +- **THEN** 本会话后续请求中的记忆上下文与写入前逐字节相同;新条目在下一个会话或本会话压缩之后出现 + +#### Scenario: 压缩后重建 +- **WHEN** 会话发生摘要压缩后发出下一次请求 +- **THEN** 记忆上下文按当前磁盘内容重建,包含压缩前新写入的条目 + +#### Scenario: 超出预算 +- **WHEN** 工作区索引超过 `max_index_bytes` +- **THEN** 只注入预算内的条目,并注入一行省略说明;磁盘上的 `MEMORY.md` 不被修改 + +#### Scenario: 工作区隔离 +- **WHEN** 工作区 A 的会话发出请求 +- **THEN** 记忆上下文只含全局与 A 的工作区条目,不含工作区 B 的条目 + +#### Scenario: Empty index +- **WHEN** 两个作用域都没有条目 +- **THEN** 请求中不出现记忆上下文 + +#### Scenario: Memory disabled by config +- **WHEN** `config.memory.enabled=false`,或本会话执行过 `/memory off` +- **THEN** 请求中不出现记忆上下文,记忆工具也不在模型侧工具表中 + +### Requirement: memory_read Tool +系统 SHALL 向模型提供 `memory_read` 工具,参数均可选:`scope`(`global` / `workspace` / `all`,默认 `all`)、`name`、`type`、`query`(对名字、描述、正文做不区分大小写的子串匹配)。行为: +- 无参数:返回两个作用域的条目列表,每项含作用域、`name`、`description`、`type`、`updated_at`。 +- 给 `name`:返回该条目的全部字段与正文;未指定作用域时先查工作区再查全局。 +- 给 `query`:返回匹配条目及一小段命中上下文。 +- 条目不存在:返回 `{found:false}`,不算错误。 +读取结果 MUST 反映磁盘当前内容,包括其他进程刚写入的条目。 + +#### Scenario: List all +- **WHEN** 模型调用 `memory_read({})` +- **THEN** 返回全局与当前工作区的全部条目,每项标明所属作用域 + +#### Scenario: Filter by type +- **WHEN** 模型调用 `memory_read({type:"feedback"})` +- **THEN** 只返回两个作用域中 `type` 为 `feedback` 的条目 + +#### Scenario: Load specific entry +- **WHEN** 全局与工作区各有一个名为 `build` 的条目,模型调用 `memory_read({name:"build"})` +- **THEN** 返回工作区那一条;指定 `scope:"global"` 时返回全局那一条 + +#### Scenario: 关键字查找 +- **WHEN** 模型调用 `memory_read({query:"撰写窗口"})` +- **THEN** 返回名字、描述或正文含该词的条目及命中片段 + +#### Scenario: 读到其他进程的写入 +- **WHEN** 另一个 ACECode 进程刚写入条目 `x`,本进程随后调用 `memory_read({name:"x"})` +- **THEN** 返回 `found:true` 与该条目内容 + +#### Scenario: Entry not found +- **WHEN** 模型读取不存在的名字 +- **THEN** 返回 `{found:false}`,工具调用状态为成功 + +### Requirement: memory_write Tool +系统 SHALL 向模型提供 `memory_write` 工具。参数:`name`(必填,清洗为 `[A-Za-z0-9_-]{1,64}`)、`type`(必填,四类之一)、`description`(必填,非空)、`body`(必填)、`mode`(`create` / `update` / `upsert`,默认 `upsert`)、`scope`(`global` / `workspace`,可选)。未给 `scope` 时:`user`、`feedback` 写入全局,`project`、`reference` 写入工作区;会话没有所属工作区时一律写入全局。 + +写入 MUST 原子完成(临时文件 + 重命名),并更新该作用域的 `MEMORY.md`,每个条目恰好一行索引。写入前 MUST 对 `description` 与 `body` 做敏感信息脱敏;发生脱敏时工具结果 MUST 说明。系统 MUST 记录来源字段(`source: manual`、当前会话 id、时间)。模型显式写入不受墓碑限制。 + +#### Scenario: Create new entry +- **WHEN** `memory_write` 以 `mode: "create"` 写入一个不存在的条目 +- **THEN** 条目文件创建在对应作用域目录,该作用域的 `MEMORY.md` 多出一行索引,条目带来源字段 + +#### Scenario: 按类型选择作用域 +- **WHEN** 模型写入 `type: project` 的条目且未指定作用域 +- **THEN** 条目写入当前工作区作用域;`type: user` 的条目写入全局作用域 + +#### Scenario: 显式指定作用域 +- **WHEN** 模型写入 `type: feedback` 且 `scope: "workspace"` +- **THEN** 条目写入当前工作区作用域 + +#### Scenario: Update existing entry +- **WHEN** `mode: "update"` 更新已存在的条目 +- **THEN** 条目文件被改写,`MEMORY.md` 中对应行原地替换,其他行顺序不变 + +#### Scenario: Create mode collision +- **WHEN** `mode: "create"` 但同名条目已存在 +- **THEN** 返回错误,磁盘不变 + +#### Scenario: Update mode missing +- **WHEN** `mode: "update"` 但条目不存在 +- **THEN** 返回错误,磁盘不变 + +#### Scenario: Path traversal attempt +- **WHEN** `name` 为 `../../etc/passwd` +- **THEN** 名字清洗后被拒绝,返回错误,不写任何文件 + +#### Scenario: 写入前脱敏 +- **WHEN** `body` 含 `password=hunter2` 与 `sk-` 开头的密钥 +- **THEN** 落盘内容中二者被替换为 `[REDACTED]`,工具结果提示发生了脱敏 + +#### Scenario: Atomicity under interruption +- **WHEN** 进程在写临时文件与重命名之间被杀掉 +- **THEN** 作用域目录中不出现半截的条目文件;重启后只看到已提交的条目 + +### Requirement: Memory Permission Model +`memory_read` SHALL 按只读工具处理,在所有权限模式下自动放行。`memory_write` 的目标解析到当前数据目录下某个记忆作用域目录内时,SHALL 在除 Yolo 以外的模式下自动放行(Yolo 本就全部放行);解析到作用域目录之外(包括经符号链接逃逸)时 MUST 在任何模式下拒绝。 + +#### Scenario: Read auto-approve +- **WHEN** `default` 权限模式下调用 `memory_read` +- **THEN** 不弹确认直接执行 + +#### Scenario: Write auto-approve in-scope +- **WHEN** `default` 模式下 `memory_write` 写入当前工作区作用域 +- **THEN** 不弹确认直接执行 + +#### Scenario: Write rejected out-of-scope +- **WHEN** `memory_write` 的目标经符号链接解析到作用域目录之外 +- **THEN** 无论何种权限模式都拒绝并返回错误 + +### Requirement: `/memory` Slash Command +系统 SHALL 在 TUI 与网页 / 桌面对话里提供同一套 `/memory` 命令,两处对同一输入产生相同的结果文本(`edit` 除外): +- `/memory` 或 `/memory list [--scope=global|workspace] [--type=]`:列出条目,按作用域分组。 +- `/memory view `:显示条目字段与正文。 +- `/memory edit `:TUI 用 `$EDITOR` 打开;网页提示到「设置 > 个性化 > 记忆」编辑。 +- `/memory forget `:删除条目及其索引行,并记录墓碑。 +- `/memory flush`:立即为当前会话提炼观察并整合当前工作区与全局的收件箱;记忆摘要未开启时只说明如何开启,不做任何模型调用。 +- `/memory off` / `/memory on`:本会话关闭 / 恢复记忆(不注入记忆上下文、不提供记忆工具、不参与记忆摘要),设置随会话持久化。 +- `/memory reload`:从磁盘重新扫描。 + +#### Scenario: List all +- **WHEN** 在 TUI 与网页对话中分别执行 `/memory list` +- **THEN** 两处输出相同的分组列表 + +#### Scenario: View specific +- **WHEN** 用户执行 `/memory view go_expert` 且条目存在 +- **THEN** 显示条目所属作用域、字段与正文(未指定作用域时先工作区后全局) + +#### Scenario: Forget removes both file and index line +- **WHEN** 用户执行 `/memory forget build_steps` +- **THEN** 条目文件与索引行被删除,并记录该名字的墓碑 + +#### Scenario: Reload picks up external edits +- **WHEN** 用户在 ACECode 之外编辑了条目文件,然后执行 `/memory reload` +- **THEN** 之后的 `/memory list` 与 `memory_read` 返回新内容 + +#### Scenario: 未开启记忆摘要时 flush +- **WHEN** 记忆摘要关闭时执行 `/memory flush` +- **THEN** 返回说明文本(如何在个性化设置中开启),不发起模型调用 + +#### Scenario: 本会话关闭记忆 +- **WHEN** 用户执行 `/memory off` 后继续对话,并在之后恢复该会话 +- **THEN** 此后的请求都不含记忆上下文、不含记忆工具,该会话不会被记忆摘要处理;恢复会话后仍保持关闭,直到执行 `/memory on` + +### Requirement: MemoryRegistry Thread Safety +记忆的所有读写 SHALL 在多线程与多进程(TUI、多个 daemon、headless 共用同一数据目录)下保持一致:条目写入原子;重新生成 `MEMORY.md` 时以写入时刻磁盘上的条目为准,MUST NOT 丢掉其他进程写入的条目;后台作业通过共享状态库的租约协调,同一作业同一时刻最多在一个进程中运行;持有租约的进程崩溃后,租约过期即可被其他进程重新执行。 + +#### Scenario: Concurrent read and write +- **WHEN** 工作线程调用 `memory_write` 的同时 UI 线程执行 `/memory list` +- **THEN** 两个调用都无数据竞争地完成;列表一致地反映写入前或写入后的状态 + +#### Scenario: 两个进程各写一条 +- **WHEN** 两个进程都在启动时扫描过空目录,之后先后写入条目 `a`、`b` +- **THEN** `MEMORY.md` 同时包含 `a` 与 `b` 两行 + +#### Scenario: 同一作用域并发整合 +- **WHEN** 两个进程同时尝试整合同一作用域的收件箱 +- **THEN** 只有一个进程执行,另一个跳过 + +#### Scenario: 崩溃后接续 +- **WHEN** 执行整合的进程在中途被杀掉 +- **THEN** 租约过期后,该批次由任一进程重新执行,已整合的内容不被重复写入 + +## ADDED Requirements + +### Requirement: Memory Surface Parity +同一配置下,TUI、daemon(桌面 / 网页)与 headless 会话 SHALL 具有相同的记忆行为:相同的记忆工具、相同的记忆上下文注入规则、相同的作用域解析。子会话 MUST 使用父会话的工作区作用域。headless 会话 MUST 提供记忆工具(可被 `--disable-tools` 移除)并注入记忆上下文,但不参与记忆摘要。 + +#### Scenario: 桌面会话具备记忆 +- **WHEN** 在桌面版中新建会话并发出第一次请求,且存在全局记忆条目 +- **THEN** 请求中包含记忆上下文,工具表中包含 `memory_read` 与 `memory_write` + +#### Scenario: headless 工具清单 +- **WHEN** 执行 `acecode -p --list-tools` +- **THEN** 清单中包含 `memory_read` 与 `memory_write`,且二者可被 `--disable-tools` 接受 + +#### Scenario: 子会话共享父会话工作区 +- **WHEN** 工作区 W 的会话派生子会话,子会话调用 `memory_read({})` +- **THEN** 返回全局与 W 的工作区条目 + +### Requirement: Memory Secret Redaction +任何内容写入记忆之前(模型显式写入、记忆摘要产生的观察、整合产生的条目、用户经网页编辑的条目),系统 SHALL 把可识别的敏感信息替换为 `[REDACTED]`,至少覆盖:常见前缀的 API 密钥与令牌(如 `sk-`、`ghp_`、`xai-`、`AKIA`)、`Bearer` 令牌、私钥块、URL 中携带的用户名密码,以及形如 `password=`、`passwd:`、`token=`、`api_key=`、`密码:` / `密码:` 的赋值中的值。 + +#### Scenario: 私钥块 +- **WHEN** 待写入内容包含 `-----BEGIN PRIVATE KEY-----` 到对应结束行之间的内容 +- **THEN** 整个私钥块被替换为 `[REDACTED]` + +#### Scenario: 中文密码赋值 +- **WHEN** 待写入内容包含「测试账号 tester01 / 密码:abc#2026」 +- **THEN** 密码值被替换为 `[REDACTED]`,其余文字保留 + +#### Scenario: 带凭据的 URL +- **WHEN** 待写入内容包含 `https://user:pass@example.com/path` +- **THEN** 凭据部分被替换为 `[REDACTED]`,主机与路径保留 + +### Requirement: Memory Management Interface +daemon SHALL 提供经鉴权的接口,用于列出(按作用域)、读取、更新、删除条目,重置某个作用域的全部记忆,以及读取与修改记忆设置;网页「设置 > 个性化」SHALL 提供「记忆」区,包含:使用记忆开关、记忆摘要开关与摘要模型(见 `memory-summarization`)、按「全局 / 当前工作区」浏览条目、查看与编辑条目(保存时脱敏并更新 `updated_at`)、删除条目(记录墓碑)、带二次确认的重置。TUI 设置中心的「个性化」页 SHALL 提供同样的使用记忆开关与记忆摘要设置,条目的浏览与删除在 TUI 中通过 `/memory` 完成。 + +#### Scenario: 编辑条目 +- **WHEN** 用户在个性化页修改某条工作区记忆的正文并保存 +- **THEN** 条目文件原子更新,`updated_at` 刷新,`MEMORY.md` 对应行更新 + +#### Scenario: 删除条目 +- **WHEN** 用户在个性化页删除一条记忆 +- **THEN** 条目与索引行被删除,并记录墓碑;TUI 执行 `/memory list` 不再显示该条 + +#### Scenario: 重置作用域 +- **WHEN** 用户确认重置当前工作区记忆 +- **THEN** 该作用域的条目、索引、收件箱与归档全部清空,全局作用域不受影响 + +#### Scenario: 关闭使用记忆 +- **WHEN** 用户关闭「使用记忆」 +- **THEN** 之后新开始的会话不注入记忆上下文、不提供记忆工具;已有条目保留在磁盘上 diff --git a/openspec/changes/unify-memory-system/specs/memory-summarization/spec.md b/openspec/changes/unify-memory-system/specs/memory-summarization/spec.md new file mode 100644 index 000000000..68e51a98d --- /dev/null +++ b/openspec/changes/unify-memory-system/specs/memory-summarization/spec.md @@ -0,0 +1,138 @@ +## Purpose + +记忆摘要让 ACECode 在后台把闲置会话里值得长期保留的经验提炼出来、攒批整合成全局或工作区记忆,用户不必逐条要求模型「记住」;默认关闭,由用户在个性化设置中开启并选择摘要模型。 + +## ADDED Requirements + +### Requirement: 记忆摘要设置 +系统 SHALL 提供记忆摘要开关 `memory.summary.enabled`(默认 `false`)与摘要模型 `memory.summary.model_name`(默认空,表示「当前模型」)。网页「设置 > 个性化 > 记忆」与 TUI 设置中心的「个性化」页 MUST 提供同样的开关与模型选择,模型选项为「当前模型」加已保存的模型列表,两处修改写入同一份配置。「当前模型」指:提炼时使用被提炼会话最后一次使用的模型;整合时使用触发本次整合的提炼(或 `/memory flush` 所在会话)使用的模型;该模型已不可用时使用默认模型。「使用记忆」关闭时记忆摘要 MUST 不运行。设置变更 MUST 在一分钟内对所有正在运行的 ACECode 进程生效。 + +#### Scenario: 默认关闭 +- **WHEN** 全新安装的用户正常使用若干会话 +- **THEN** 不产生任何提炼或整合的模型调用,收件箱保持为空 + +#### Scenario: 开启并使用当前模型 +- **WHEN** 用户开启记忆摘要且模型保持「当前模型」,某会话使用模型 M 后闲置 +- **THEN** 该会话的提炼请求发给模型 M + +#### Scenario: 指定摘要模型 +- **WHEN** 用户把摘要模型设为已保存的模型 S +- **THEN** 之后的提炼与整合请求都发给 S + +#### Scenario: TUI 与网页设置一致 +- **WHEN** 用户在 TUI 设置中心开启记忆摘要并选择模型 S +- **THEN** 网页个性化页显示为已开启、模型为 S + +#### Scenario: 其他进程同步设置 +- **WHEN** 用户在工作区 A 的桌面窗口开启记忆摘要,工作区 B 的 daemon 正在运行 +- **THEN** 一分钟内 B 的 daemon 也按开启状态调度作业 + +#### Scenario: 关闭后停止 +- **WHEN** 用户关闭记忆摘要时恰有一个提炼请求在进行 +- **THEN** 不再启动新作业,进行中的请求结果被丢弃不落盘;已有的收件箱与条目保留 + +### Requirement: 闲置触发提炼 +记忆摘要开启时,会话 SHALL 在同时满足下列条件时成为待提炼会话:属于某个工作区;不是子会话;不是 headless 会话;本会话未执行 `/memory off`;在上次提炼位置之后有新的用户可见消息;没有正在进行的回合且距最后一条消息已超过 `memory.summary.idle_minutes`(默认 30 分钟);最后活动在 `memory.summary.max_session_age_days`(默认 7 天)以内。待提炼会话 MUST 在该工作区有 ACECode 进程运行期间被处理;会话出现新活动后重新计时。`/memory flush` MUST 忽略闲置时长,立即提炼当前会话。 + +#### Scenario: 闲置后提炼 +- **WHEN** 会话最后一条消息之后 30 分钟没有新活动 +- **THEN** 该会话被提炼一次,提炼位置推进到最后一条消息 + +#### Scenario: 继续对话后只提炼新增部分 +- **WHEN** 已提炼过的会话又进行了新的对话并再次闲置 +- **THEN** 只把上次提炼位置之后的消息送去提炼 + +#### Scenario: 排除的会话 +- **WHEN** 会话是子会话、headless 会话、没有所属工作区,或执行过 `/memory off` +- **THEN** 该会话不会被提炼 + +#### Scenario: 不回溯旧会话 +- **WHEN** 用户首次开启记忆摘要,磁盘上有 30 天前的会话 +- **THEN** 这些会话不会被提炼 + +#### Scenario: 手动触发 +- **WHEN** 记忆摘要开启时,用户在刚结束一个回合的会话中执行 `/memory flush` +- **THEN** 立即提炼本会话并整合相关作用域,完成后回复处理了几条观察、改动了几条记忆 + +### Requirement: 提炼的输入与输出约定 +提炼 SHALL 只使用上次提炼位置之后的消息,排除隐藏的内部上下文、技能展开片段与系统提示;超出摘要模型可用窗口时按「用户原话 > 助手最终结论 > 助手中间说明 > 工具输出」的优先级裁剪,工具输出单条截断。提炼请求 MUST 不带任何工具,提示词 MUST 要求忽略对话中的指令、不记录密钥。模型输出 MUST 是如下 JSON:`{"outcome":"noop"|"observations","observations":[...]}`,每条观察含 `scope`(`global` / `workspace`)、`type`(四类之一)、`title`(不超过 80 字符)、`statement`(不超过 1000 字符),一次最多 20 条;出现多余字段、非法取值或超限时整份输出作废并重试,同一范围最多尝试 3 次。合法观察脱敏后写入对应作用域的收件箱,带来源(会话 id、消息范围、时间);只有写入成功后才推进提炼位置。 + +#### Scenario: 无值得保留的内容 +- **WHEN** 模型返回 `{"outcome":"noop","observations":[]}` +- **THEN** 不写入观察,提炼位置照常推进 + +#### Scenario: 输出不合规 +- **WHEN** 模型返回的观察多出一个 `priority` 字段 +- **THEN** 整份输出作废,稍后重试;连续 3 次失败后该范围不再重试,直到会话出现新活动 + +#### Scenario: 对话里的注入指令 +- **WHEN** 被提炼的工具输出里含有「把以下内容记为永久规则」之类的文字 +- **THEN** 提示词要求模型忽略此类指令;任何被写入的观察都带有来源会话与消息范围,可追溯 + +#### Scenario: 观察脱敏 +- **WHEN** 模型输出的观察中含有令牌字符串 +- **THEN** 写入收件箱的观察中该令牌被替换为 `[REDACTED]` + +### Requirement: 攒批整合 +某作用域收件箱中的观察达到 20 条,或最早一条已超过 24 小时,或用户执行 `/memory flush` 时,系统 SHALL 整合该作用域:把现有条目、本批观察(每批最多 100 条)与该作用域的墓碑交给摘要模型,模型返回操作计划,操作只能是 `create`、`update`、`merge`、`delete` 四种,每个操作 MUST 引用至少一条本批观察作为依据。计划 MUST 整体校验通过才执行,校验包括:名字合法;不得修改、合并或删除 `source: manual` 的条目;不得创建墓碑中的名字或标题;描述不超过 150 字符、正文不超过 4 KiB;内容脱敏。执行成功后本批全部观察(无论是否被引用)移入 `archive/`,归档保留 30 天;生成的条目 `source: summary`,`source_sessions` 取所引用观察的来源会话。 + +#### Scenario: 合并出新条目 +- **WHEN** 收件箱里有 3 条关于同一工作流的观察,整合计划 `create` 一条条目并引用这 3 条 +- **THEN** 新条目写入该作用域、出现在 `MEMORY.md`,3 条观察移入归档 + +#### Scenario: 计划未引用依据 +- **WHEN** 计划中某个 `update` 操作没有引用任何观察 +- **THEN** 整个计划作废,条目与收件箱都不变,稍后重试 + +#### Scenario: 手写条目受保护 +- **WHEN** 计划试图 `delete` 一条 `source: manual` 的条目 +- **THEN** 整个计划作废,该条目保留 + +#### Scenario: 墓碑阻止复活 +- **WHEN** 用户删除了条目 `old_rule`,之后的观察又描述了同样内容,计划试图 `create` 名为 `old_rule` 或标题相同的条目 +- **THEN** 计划作废,条目不会被重新创建 + +#### Scenario: 连续失败 +- **WHEN** 同一批次整合连续 3 次失败 +- **THEN** 观察保留在收件箱,记忆状态中显示最近一次错误,直到出现新观察或用户手动触发才再次尝试 + +### Requirement: 遗忘与墓碑 +会话被永久删除时,系统 SHALL 删除该会话在收件箱与归档中的观察;`source: summary` 的条目 MUST 从 `source_sessions` 中移除该会话,移除后没有来源的条目 MUST 删除并记录墓碑。用户通过 `/memory forget` 或网页删除条目时 MUST 记录墓碑(作用域、名字、标题),墓碑保留 90 天,期间整合不得创建同名或同标题(忽略大小写)的条目;模型显式调用 `memory_write` 不受墓碑限制。 + +#### Scenario: 删除会话撤回其记忆 +- **WHEN** 条目 E 只来源于会话 S,用户永久删除 S +- **THEN** E 被删除并记录墓碑;来源同时包含其他会话的条目只移除 S + +#### Scenario: 手写条目不受影响 +- **WHEN** 用户永久删除一个曾调用 `memory_write` 写过手写条目的会话 +- **THEN** 该手写条目保留 + +#### Scenario: 墓碑过期 +- **WHEN** 条目被删除 91 天后,新的观察描述了同样内容 +- **THEN** 整合可以重新创建该条目 + +### Requirement: 作业并发与失败隔离 +提炼与整合 SHALL 在后台运行,MUST NOT 阻塞或改变任何正在进行的回合,失败 MUST NOT 以错误形式出现在会话中(`/memory flush` 除外,它回复处理结果)。多个进程共享同一数据目录时,同一会话的提炼、同一作用域的整合在同一时刻最多由一个进程执行;每个进程同一时刻最多运行一个提炼请求,每轮调度最多提炼 2 个会话。失败的作业按退避间隔重试。 + +#### Scenario: 回合进行中不打扰 +- **WHEN** 用户正在某会话中进行回合,后台正在提炼另一个会话 +- **THEN** 该回合的请求、工具执行与界面不受影响 + +#### Scenario: 多进程只执行一次 +- **WHEN** TUI 与桌面 daemon 同时服务同一个工作区,某会话同时满足提炼条件 +- **THEN** 该会话只被提炼一次 + +#### Scenario: 调度上限 +- **WHEN** 开启记忆摘要时有 10 个会话同时满足条件 +- **THEN** 每轮调度每个进程最多提炼 2 个,其余在后续轮次处理 + +### Requirement: 记忆摘要状态可见 +网页「设置 > 个性化 > 记忆」SHALL 显示记忆摘要状态:是否开启、当前工作区与全局收件箱中的观察数、最近一次提炼与整合的时间、最近一次错误(如有)。 + +#### Scenario: 显示待整合数量 +- **WHEN** 当前工作区收件箱中有 7 条观察尚未整合 +- **THEN** 个性化页显示当前工作区有 7 条待整合观察 + +#### Scenario: 显示最近错误 +- **WHEN** 最近一次整合因摘要模型返回非法计划而失败 +- **THEN** 个性化页显示失败时间与原因摘要 diff --git a/openspec/changes/unify-memory-system/tasks.md b/openspec/changes/unify-memory-system/tasks.md new file mode 100644 index 000000000..31b9a05c2 --- /dev/null +++ b/openspec/changes/unify-memory-system/tasks.md @@ -0,0 +1,73 @@ +# Tasks: unify-memory-system + +一期 = 第 1–7 组(统一实现、作用域、注入、命令、设置与界面,不增加模型调用);二期 = 第 8–9 组(记忆摘要)。测试源码按项目约定写中文场景注释。 + +## 1. 规格基线 + +- [x] 1.1 把主规格 `openspec/specs/global-memory/spec.md` 的 `## ADDED Requirements` 规范为 `## Requirements`,验证:`openspec show global-memory --type spec --json --no-scenarios` 解析出 8 条需求,本变更 `openspec validate` 不再提示归档会拒绝 MODIFIED + +## 2. 存储层(`src/domain/memory/`) + +- [x] 2.1 把 `MemoryRegistry` 泛化为按目录实例化的存储(全局 / 工作区各一个实例),原有接口行为不变,验证:`tests/memory/memory_registry_test.cpp` 现有用例全过,新增「两个作用域互不影响」用例 +- [x] 2.2 frontmatter 读写 `created_at` / `updated_at` / `source` / `source_sessions`,旧条目在系统写入时补齐(`created_at` 取文件修改时间),未知字段保留,验证:frontmatter 单测覆盖旧条目补齐与未知字段往返 +- [x] 2.3 新增 `secret_redaction`(逐字符扫描,不用 `std::regex`),覆盖规格列出的密钥前缀、`Bearer`、PEM 私钥块、URL 凭据、`password|passwd|pwd|token|secret|api_key|apikey|密码` 赋值(含全角冒号),验证:单测逐类命中且普通文本(含 `token` 一词的自然语言)不被误改 +- [x] 2.4 新增状态库 `/memory/state.sqlite3`(WAL、busy_timeout):提炼进度、整合进度、墓碑、状态摘要四张表;租约(持有者令牌、到期、续约、过期接管)与幂等提交,验证:单测覆盖租约互斥、过期接管、墓碑 90 天过期 +- [x] 2.5 作用域写锁:写条目 / 重建索引 / 应用整合时在状态库上开 `BEGIN IMMEDIATE`,锁内按磁盘重扫,验证:两个存储实例 + 两个库连接交替写入,`MEMORY.md` 保留全部条目(在修复分支的 `UpsertKeepsEntriesWrittenByAnotherProcess` 基础上扩展) + +## 3. 记忆运行时与三入口接线 + +- [x] 3.1 新增 `src/host/session_host/memory_runtime.{hpp,cpp}`:`create_memory_runtime`、按会话项目目录解析作用域(不用进程 cwd)、工具注册、快照构建入口、`forget_session`,验证:单测覆盖 daemon 多工作区时按会话解析到正确目录 +- [x] 3.2 TUI(`tui_runtime_init.cpp`)、daemon(`worker.cpp`)、headless(`headless_runner.cpp`)改为调用 `create_memory_runtime`,移除各自的记忆接线(含修复分支里 daemon 的临时接线),验证:`acecode -p --list-tools` 列出 `memory_read` / `memory_write`;web smoke 新建会话的工具表含两者;TUI 现有记忆用例通过 +- [x] 3.3 子会话沿用父会话的记忆运行时与工作区作用域,验证:子会话 `memory_read({})` 返回父会话工作区条目的用例 +- [x] 3.4 headless 的记忆工具可被 `--disable-tools` 移除且不启动记忆摘要调度器,验证:headless 选项单测 + +## 4. 记忆工具 + +- [x] 4.1 `memory_read` 增加 `scope` / `query`,按名字先工作区后全局,读前重扫,验证:`memory_read_tool_test` 覆盖作用域优先级、关键字命中片段、读到另一个存储实例刚写的条目 +- [x] 4.2 `memory_write` 增加 `scope`(缺省按类型推断,无工作区时写全局),写前脱敏并在结果中提示,记录来源与时间,走作用域写锁,验证:`memory_write_tool_test` 覆盖推断规则、显式作用域、脱敏提示、来源字段 +- [x] 4.3 权限:两类作用域目录内的写入按规格自动放行,解析到作用域外(含符号链接)一律拒绝,验证:`permissions_test` 新增工作区作用域与符号链接逃逸用例 + +## 5. 记忆上下文快照 + +- [x] 5.1 把记忆块从会话上下文块拆出,由 `PromptContextCache` 按会话冻结,压缩代数变化或会话恢复时重建,验证:agent loop 用例「会话中 `memory_write` 后后续请求逐字节不变、压缩后出现新条目」,且 `RequestPrefixIsByteStableAcrossIterationsInATurn` 仍通过 +- [x] 5.2 渲染:按作用域分段、条目附距今天数、每作用域 `max_index_bytes` 预算与省略说明、固定使用说明;两个作用域都为空时不注入,验证:`system_prompt_test` 新增预算截断、天数标注、为空不注入、工作区隔离用例 +- [x] 5.3 `memory.max_index_bytes` 默认改为 8 KiB,验证:配置默认值单测;发布说明记录该变化 + +## 6. `/memory` 命令与会话开关 + +- [x] 6.1 在 `session_host` 实现 `dispatch_memory_command`(list / view / forget / flush / off / on / reload,edit 分端),forget 记录墓碑,验证:单测逐个子命令核对输出文本 +- [x] 6.2 TUI `/memory` 改为调用 `dispatch_memory_command`(edit 仍用 `$EDITOR`),验证:TUI 命令单测 +- [x] 6.3 daemon 内置命令白名单加入 `memory`,网页斜杠下拉列出,验证:`commands_handler_test` 与 web smoke 中 `/memory list` 输出与 TUI 相同 +- [x] 6.4 会话 meta 新增 `memory_mode`,`AgentLoop` 关闭时不注入记忆块、在模型侧工具表过滤记忆工具,验证:agent loop 用例覆盖 `/memory off` 后请求不含记忆块与工具、恢复会话后保持、`/memory on` 后恢复 + +## 7. 设置、REST 与界面 + +- [x] 7.1 配置新增 `memory.summary.{enabled,model_name,idle_minutes,max_session_age_days}`(稀疏写回),`settings_mutations` 新增 `set_memory_settings`,验证:配置解析 / 写回单测与 settings_mutations 单测 +- [x] 7.2 REST:`GET/PUT /api/config/memory`,`GET /api/memory`(按工作区列出两个作用域 + 状态)、`GET/PUT/DELETE /api/memory//`、`POST /api/memory/reset`;纯函数 handler + routes,编辑走脱敏与写锁,删除记墓碑,验证:handler 单测 + web smoke;`docs/daemon-api.md` 同步 +- [x] 7.3 网页「设置 > 个性化」新增「记忆」区:使用记忆开关、记忆摘要开关与模型(「当前模型」+ 已保存模型,一期后端调度未上线前禁用)、全局 / 当前工作区条目浏览、查看编辑删除、二次确认重置、状态显示;纯逻辑 `lib/memorySettings.js` 登记 `runTests.js`,验证:`pnpm test`、`pnpm build` 通过;新中文文案先补 `i18n-en-overrides.mjs` 再 `pnpm i18n:catalog` +- [x] 7.4 TUI 设置中心个性化页新增使用记忆开关、记忆摘要开关与模型选择,写入同一配置,验证:在 TUI 修改后 `GET /api/config/memory` 返回一致的值 + +## 8. 记忆摘要:调度与提炼(二期) + +- [x] 8.1 `MemorySummaryScheduler`:只在 daemon 与 TUI 启动,2 分钟一轮,每轮检查 `config.json` 修改时间并重读 `memory` 段,每进程同时 1 个提炼、每轮最多 2 个会话,验证:注入时钟与配置读取的单测覆盖重载与上限 +- [x] 8.2 待提炼会话筛选:工作区会话、非子会话、非 headless、未关闭记忆、有新的可见消息、闲置 ≥ `idle_minutes`、7 天内活动、未被其他进程持有,验证:单测逐条排除规则 +- [x] 8.3 提炼输入:提炼位置之后的可见消息,按优先级裁剪到摘要模型窗口 60%,工具输出单条 2 KB,验证:单测覆盖裁剪顺序与预算 +- [x] 8.4 模型解析(`memory.summary.model_name` → 会话最后使用的模型 → 默认模型)与无工具请求,复用自动标题的解析与创建函数,验证:stub provider 单测断言请求发给了正确模型且不带工具 +- [x] 8.5 输出严格校验(多余字段、非法取值、超长即作废),同一范围最多 3 次,PA 上下文超限时预算减半,验证:单测覆盖 noop、多余字段、超长、重试上限 +- [x] 8.6 观察脱敏后写入对应作用域 `inbox/`(带会话 id、消息范围、时间),写入成功才推进提炼位置,崩溃重放不重复,验证:单测模拟写入后崩溃再重放 +- [x] 8.7 关闭记忆摘要或关闭使用记忆时停止调度并丢弃进行中结果,验证:单测 + +## 9. 记忆摘要:整合、遗忘与状态(二期) + +- [x] 9.1 整合触发(满 20 条 / 最早超 24 小时 / `flush`)与作用域租约,每批最多 100 条,验证:单测覆盖触发条件与并发只执行一次 +- [x] 9.2 计划校验:每个操作引用本批观察、名字合法、`source: manual` 只读、墓碑名字与标题(忽略大小写)不得创建、描述 ≤150 字符、正文 ≤4 KiB、脱敏,验证:单测逐条规则各一个作废用例 +- [x] 9.3 应用与归档:写锁内写条目、重建索引、本批观察全部移入 `archive/<日期>/`、记录计划 hash;任一步失败回滚;归档 30 天清理;连续 3 次失败保留观察并记录错误,验证:单测覆盖成功、回滚、幂等重放、清理 +- [x] 9.4 `forget_session` 接入会话永久删除路径(REST `?purge=1`、TUI `/tasks clear`):删观察、从来源移除、无来源条目删除并记墓碑、手写条目保留,验证:web smoke 永久删除后条目撤回 +- [x] 9.5 `/memory flush` 异步执行,立即回复已开始,完成后发系统通知报告观察数与改动条目数;未开启时只返回说明,验证:agent / web 测试 +- [x] 9.6 状态摘要(两个作用域收件箱计数、最近提炼与整合时间、最近错误)接入 `GET /api/memory` 与个性化页,开关解禁,验证:handler 单测 + 前端测试 + +## 10. 文档与整体验证 + +- [x] 10.1 更新 CLAUDE.md(记忆一节)、`docs/daemon-api.md`、用户帮助中记忆相关内容,验证:文档描述与接口、默认值一致 +- [x] 10.2 Windows 本机验证:构建 `acecode`、`acecode-desktop`、`acecode_unit_tests`,`python scripts/refactor/run_fast_tests.py --profile fast` 全绿,`pnpm test` / `pnpm build` 通过 +- [x] 10.3 端到端手测并记录:桌面版开启记忆摘要 → 会话闲置后收件箱出现观察 → `/memory flush` 整合出条目 → 新会话注入可见 → 永久删除来源会话后条目撤回;TUI 与网页 `/memory list` 输出一致 diff --git a/openspec/changes/unify-memory-system/verification.md b/openspec/changes/unify-memory-system/verification.md new file mode 100644 index 000000000..09c93d7f1 --- /dev/null +++ b/openspec/changes/unify-memory-system/verification.md @@ -0,0 +1,53 @@ +# 验证记录 + +本次在 worktree `claude/unify-memory`(起点 master `5e9258d1`,另含同分支的两个反馈修复提交)上实现,Windows 11 本机 Ninja Release 构建。端到端使用临时 HOME 与用户现有模型配置的副本,没有读写用户真实的 `~/.acecode` 数据。 + +## 自动化(任务 10.2) + +- 构建:`acecode`、`acecode-desktop`、`acecode_unit_tests` 均成功;前端 `pnpm build` 之后重新 configure,`acecode.exe` 嵌入的是新前端(daemon 实际返回的页面含记忆设置文案)。 +- `python scripts/refactor/run_fast_tests.py --profile fast`:inventory 5425,执行 4811,失败 0(`not_run` 613 为快速档按设计排除的慢用例)。 +- 追加跑了 `--profile full`:执行 5424,失败 0。 +- 记忆相关筛选(`*Memory*:*memory*:SecretRedaction*:...`)275 项通过,包括 `MemorySummaryTest.ToleratesReasoningAroundJson`(模型把推理写进正文时仍能取出 JSON)。 +- `web`:`pnpm test` 2920 项通过、0 失败;`pnpm build` 通过,正则兼容性检查通过。新中文文案经 `i18n-en-overrides.mjs` + `pnpm i18n:catalog` 生成。 +- 静态闸门:`check_layers.py --layout final --strict`、`check_doc_paths.py --strict`、`normalize_includes.py --check`(src / tests)、`check_file_size.py --strict`、`check_ownership.py --strict --final`、`validate_map.py --strict`、`check_layer_libraries.py`(File API)、`scripts/refactor/tests` 99 项均通过。 +- `acecode --validate-models-registry` 在指定 `ACECODE_MODELS_DEV_DIR` 后通过(构建目录不在源码树内,默认向上查找不到 `assets/models_dev`,与本次改动无关)。 +- `openspec validate unify-memory-system --strict --no-interactive` 与 `git diff --cached --check` 通过。 + +## 端到端:/memory flush 路径 + +临时 HOME + daemon REST(桌面版与网页共用的后台),真实模型 `darkness`(会把推理写进正文)。 + +1. `PUT /api/config/memory {"summary":{"enabled":true}}` 生效,`summary_available: true`。 +2. 新会话陈述「本项目正式发布只在周五进行」,只要求回复「收到」,不要求模型记忆。 +3. `/memory flush` 立即返回已开始;完成后对话里出现系统消息 `Memory flush finished: 1 new observation(s), 1 observation(s) consolidated, 1 memory entry changed.`,工作区出现条目 `release_friday_only`(`type: project`,`source: summary`,`source_sessions` 为该会话),状态中两个收件箱回到 0、最近提炼 / 整合时间已记录。 +4. 另一个新会话只凭上下文回答「发布安排在星期几」:模型引用了注入的 `[project] release_friday_only — 本项目正式发布仅安排在周五进行…`,回答「星期五」。 +5. 归档并 `DELETE /api/sessions/?purge=1` 后,工作区条目被撤回(引用该会话的摘要条目为空)。 + +首轮手测发现推理型模型输出的 JSON 前后带推理文字,提炼被判为无效;修复为容错提取 JSON 对象并在无效时记录输出样本,flush 报告改为写入对话记录,修复后重跑即上面的结果。 + +## 端到端:会话闲置自动提炼路径(任务 10.3) + +同样的临时 HOME 与模型,`PUT /api/config/memory {"summary":{"enabled":true,"idle_minutes":5}}`(允许的最小闲置时间)。 + +1. 新会话说明「习惯用 pnpm 而不是 npm 管理前端依赖」,只要求回复「好的」。 +2. 不做任何操作。回合结束约 375 秒后(闲置满 5 分钟后的第一轮调度),状态变为全局收件箱 1、记录了最近提炼时间,`memory/inbox/<会话>-0-5.json` 中是一条 `feedback` 观察「前端依赖管理统一使用 pnpm」;未满 20 条,尚未整合。 +3. `/memory flush`:`Memory flush finished: 0 new observation(s), 1 observation(s) consolidated, 1 memory entry changed.`,全局出现 `frontend_pnpm`(`source: summary`,来源为该会话),收件箱回到 0,记录了最近整合时间。 +4. 新会话只凭上下文回答「平时用哪个包管理器」:回答「pnpm」。 +5. 归档并永久删除来源会话后,`frontend_pnpm` 被撤回,两个作用域都没有剩余条目。 + +## 网页界面 + +daemon 使用嵌入资源(去掉配置副本里指向主检出旧前端的 `web.static_dir`),浏览器打开: + +- 设置 > 个性化 > 记忆:使用记忆、记忆摘要开关、摘要模型(「当前模型」+ 已保存模型)、全局 / 当前工作区条目页签、重置;无工作区时提示「当前没有打开工作区」。 +- 打开记忆摘要后出现状态区(待整合观察:全局 / 当前工作区计数,最近提炼、最近整合);`GET /api/config/memory` 确认已写回。 +- 在工作区会话里让模型用 `memory_write` 记一条项目约定,设置页「当前工作区 1」列出该条目;编辑正文追加 `password=...` 保存后提示 1 处疑似密钥已替换,`GET /api/memory/workspace/` 返回 `password=[REDACTED]`;删除有二次确认,删除后条目消失并写入墓碑。 +- 会话里输入 `/memory list`,对话出现「记忆信息」系统通知,内容为全局无条目、工作区 1 条 `[project] commit-message-language — … (today)`。 + +## TUI 与网页 /memory 一致性 + +TUI 的 `/memory` 与网页内置命令都调用 `dispatch_memory_command`;`TuiMemoryCommand.ListUsesSharedDispatcherText` 注册真实的 TUI 命令执行 `/memory list`,与网页那份文本逐字比较。本次没有在交互式终端里手动运行 TUI。 + +## 验证边界 + +端到端走的是桌面版所用的 daemon 与嵌入网页,在浏览器里操作;没有启动 `acecode-desktop` 窗口本身,也没有在 macOS / Linux 上验证(按仓库约定留待多平台补验)。 diff --git a/src/adapters/pa/README.md b/src/adapters/pa/README.md index 00b926473..c5db08cdd 100644 --- a/src/adapters/pa/README.md +++ b/src/adapters/pa/README.md @@ -138,11 +138,17 @@ exhausted」。 1. **原样重发** 2 次(2 秒、5 秒)。先当它是抽风:过了就一点上下文都不丢, 也不给学习器记账。 -2. **逐档收缩**,每次被拒把请求缩到上一次的 85%,先丢最旧的整组回合,老回合 - 丢完了清本回合里最旧的工具输出(最近一条保留),再不够清体积超 4KB 的工具 - 调用参数(典型是 file_write 的整篇文件)。这是 `ThreadRepairOptions:: - clear_tool_outputs`,默认关闭,只有 PA 兜底与摘要失败后的机械兜底打开。 - 没有次数上限,直到一点空间都腾不出来。 +2. **逐档收缩**,每次被拒把请求缩到上一次的 85%,按「最占地方、最能找回」的 + 顺序腾空间:先从最旧的开始清整段历史里的工具输出(最近一条工具结果保留, + 按位置数,哪怕它很短),再清体积超 4KB 的工具调用参数(典型是 file_write + 的整篇文件);还不够就**精简**老回合 —— 只删工具往返,保留用户消息、压缩 + 摘要和助手的纯文本结论;最后才整组丢最旧的回合,且保留最新一份压缩摘要。 + 这是 `ThreadRepairOptions::clear_tool_outputs` + `thin_old_turns_first`, + 默认关闭,只有 PA 兜底与摘要失败后的机械兜底打开。没有次数上限,直到一点 + 空间都腾不出来。修复后清空「已读观测」,再读文件拿到的是真实内容。 + **旧顺序是先整组丢老回合**(反馈 LINDANDAN069,模型 aicoder-pro):一次拒收 + 把最初的任务说明、用户的多次纠正和压缩摘要一起删掉,而真正腾出空间的是随后 + 清掉的工具输出 —— 模型此后「忘了」任务要求和刚总结的做法。别改回去。 3. **紧急档**。 4. **等待重发**:5 秒起、每次翻倍、封顶 60 秒,最多 12 次(约 9.5 分钟), 界面持续显示等待状态,用户可随时停止。只有这个也耗尽才报错。 diff --git a/src/adapters/pa/pa_rescue_driver.cpp b/src/adapters/pa/pa_rescue_driver.cpp index 9a4d88bcc..eadb6218c 100644 --- a/src/adapters/pa/pa_rescue_driver.cpp +++ b/src/adapters/pa/pa_rescue_driver.cpp @@ -84,6 +84,7 @@ bool run_rescue(PaRescueHost& host, RescueState& state, const ProviderErrorInfo& options.force_prune_one_group = true; options.clear_tool_outputs = true; options.keep_recent_tool_outputs = 1; + options.thin_old_turns_first = true; auto repair = host.repair(options); LOG_WARN("[pa-rescue] shrink status=" + std::string(to_string(repair.status)) + @@ -93,6 +94,8 @@ bool run_rescue(PaRescueHost& host, RescueState& state, const ProviderErrorInfo& std::to_string(repair.pruned_groups) + " cleared_tool_outputs=" + std::to_string(repair.cleared_tool_outputs) + + " thinned_groups=" + + std::to_string(repair.thinned_groups) + " reason=" + repair.reason); if (!repair.repaired()) { // 一点空间都没腾出来:这一轮不再提议收缩,立刻换下一招。 @@ -106,6 +109,24 @@ bool run_rescue(PaRescueHost& host, RescueState& state, const ProviderErrorInfo& {"label", plan.label}, {"detail", repair.reason}, }); + if (repair.thinned_groups > 0) { + host.notice( + "[智能压缩] 服务端拒收请求(第 " + + std::to_string(state.shrink_rounds) + + " 次收缩):已清除 " + + std::to_string(repair.cleared_tool_outputs) + + " 条旧工具输出、精简 " + + std::to_string(repair.thinned_groups) + + " 组旧回合(保留用户消息与结论)、丢弃 " + + std::to_string(repair.pruned_groups) + + " 组最旧历史后重试。", + make_system_notice_metadata("context_history_thinned", + {{"round", state.shrink_rounds}, + {"groups", repair.pruned_groups}, + {"thinned", repair.thinned_groups}, + {"outputs", repair.cleared_tool_outputs}})); + return true; + } host.notice( "[智能压缩] 服务端拒收请求(第 " + std::to_string(state.shrink_rounds) + diff --git a/src/adapters/provider/leaked_tool_markup.cpp b/src/adapters/provider/leaked_tool_markup.cpp new file mode 100644 index 000000000..fb2128762 --- /dev/null +++ b/src/adapters/provider/leaked_tool_markup.cpp @@ -0,0 +1,29 @@ +// 回复正文里泄漏的工具参数模板标记( / )检测,声明见 +// text_tool_call_recovery.hpp。单独成文件让 text_tool_call_recovery.cpp 保持在 +// 行数基线内。 +#include "text_tool_call_recovery.hpp" + +#include "markdown_fence_tracker.hpp" + +namespace acecode { + +std::optional find_leaked_tool_argument_markup(std::string_view text) { + static constexpr std::string_view kMarkers[] = { + "", "", "", ""}; + // 快路径:绝大多数回复里连 "arg_" 都没有,不必逐字符跟踪围栏。 + if (text.find("arg_") == std::string_view::npos) return std::nullopt; + MarkdownFenceTracker fence; + for (std::size_t i = 0; i < text.size(); ++i) { + const char c = text[i]; + if (c == '<' && !fence.in_fence() && !fence.line_opening_fence() && + !fence.in_inline_code()) { + for (const auto marker : kMarkers) { + if (text.compare(i, marker.size(), marker) == 0) return std::string(marker); + } + } + fence.feed(c); + } + return std::nullopt; +} + +} // namespace acecode diff --git a/src/adapters/provider/text_tool_call_recovery.hpp b/src/adapters/provider/text_tool_call_recovery.hpp index 0b69fbaf8..0706dff8e 100644 --- a/src/adapters/provider/text_tool_call_recovery.hpp +++ b/src/adapters/provider/text_tool_call_recovery.hpp @@ -93,6 +93,15 @@ std::optional detect_suspicious_text_tool_call( // DSML 标记也算。围栏与行内代码里的不算。 bool text_contains_tool_call_markup(std::string_view text); +// 输出损坏检测:工具参数模板标记(GLM 系聊天模板的 `` / `` +// 及闭合标签)出现在正文里,围栏与行内代码之外。原生工具调用时这些标记由服务端 +// 解析掉,不会进正文;出现就说明网关把模板残片吐进了输出流 —— 这一步的正文和 +// 同一回复里的工具调用参数都不可信(反馈 huangyuan816:正文是一串数字加 +// ``,同一回复的 bash 命令也夹着乱码)。返回命中的标记,没有则空。 +// `` 刻意不算:把推理写进正文的模型会合法地输出它。 +std::optional find_leaked_tool_argument_markup( + std::string_view text); + // 混合形态:文本调用与原生调用逐个比对(名字按请求工具表规范化,参数 parse 成 // JSON 比较),完全一致的算回显,从 text_calls 里剔除;每个原生调用最多抵消一个。 void drop_echoes_of_native_calls(std::vector& text_calls, diff --git a/src/adapters/tool/file_read_tool.cpp b/src/adapters/tool/file_read_tool.cpp index d70791221..811ace4d8 100644 --- a/src/adapters/tool/file_read_tool.cpp +++ b/src/adapters/tool/file_read_tool.cpp @@ -742,7 +742,7 @@ ToolResult execute_file_read( request.end_line, request.byte_mode, request.byte_offset, - request.requested_max_bytes); + request.requested_max_bytes, ctx.session_id); // 按会话去重 if (unchanged_observation.has_value()) { ToolSummary summary; summary.verb = "Read"; @@ -963,7 +963,7 @@ ToolResult execute_file_read( request.end_line, request.byte_mode, request.byte_offset, - request.requested_max_bytes); + request.requested_max_bytes, ctx.session_id); bool hint_added = false; if (!request.byte_mode && diff --git a/src/adapters/tool/memory_read_tool.cpp b/src/adapters/tool/memory_read_tool.cpp index d1f0bef3a..110efe43f 100644 --- a/src/adapters/tool/memory_read_tool.cpp +++ b/src/adapters/tool/memory_read_tool.cpp @@ -1,27 +1,88 @@ #include "memory_read_tool.hpp" -#include "memory/memory_registry.hpp" +#include "memory_tool_scope.hpp" + +#include "memory/memory_service.hpp" #include "memory/memory_types.hpp" #include "utils/logger.hpp" #include "utils/utf8_path.hpp" #include +#include + namespace acecode { -ToolImpl create_memory_read_tool(MemoryRegistry& registry, - std::size_t max_index_bytes) { +namespace { + +struct ScopedEntry { + MemoryScope scope = MemoryScope::Global; + MemoryEntry entry; +}; + +std::string ascii_lower(std::string text) { + for (auto& c : text) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + } + return text; +} + +bool is_utf8_continuation(char c) { + return (static_cast(c) & 0xC0) == 0x80; +} + +// 命中位置前后各约 60 字节的片段,按 UTF-8 字符边界对齐,换行折成空格。 +std::string snippet_around(const std::string& text, std::size_t pos, std::size_t len) { + std::size_t start = pos > 60 ? pos - 60 : 0; + std::size_t end = std::min(text.size(), pos + len + 60); + while (start > 0 && start < text.size() && is_utf8_continuation(text[start])) --start; + while (end < text.size() && is_utf8_continuation(text[end])) ++end; + std::string out = text.substr(start, end - start); + for (auto& c : out) { + if (c == '\n' || c == '\r' || c == '\t') c = ' '; + } + if (start > 0) out = "..." + out; + if (end < text.size()) out += "..."; + return out; +} + +nlohmann::json summary_json(const ScopedEntry& item) { + return { + {"scope", memory_scope_to_string(item.scope)}, + {"name", item.entry.name}, + {"description", item.entry.description}, + {"type", memory_type_to_string(item.entry.type)}, + {"updated_at", item.entry.updated_at}, + }; +} + +nlohmann::json error_json(const std::string& message) { + return {{"success", false}, {"error", message}}; +} + +} // namespace + +ToolImpl create_memory_read_tool(std::shared_ptr memory) { ToolDef def; def.name = "memory_read"; def.description = - "Read persistent user memory from ~/.acecode/memory/. " - "With no arguments, returns the MEMORY.md index plus a list of all entries " - "(name, description, type). With only 'type', filters entries by one of " - "user|feedback|project|reference. With 'name', returns that entry's full body. " - "Missing entries return {found:false} rather than erroring. Read-only."; + "Read persistent memory. Two scopes exist: 'global' (the user's cross-project " + "preferences) and 'workspace' (memory for the current workspace). With no " + "arguments, lists the entries of both scopes (scope, name, description, type, " + "updated_at). 'scope' limits the listing to global, workspace or all (default). " + "'type' filters by user|feedback|project|reference. 'name' returns one entry's " + "full body, checking the workspace scope before the global scope unless 'scope' " + "is given. 'query' does a case-insensitive substring search over names, " + "descriptions and bodies and returns matching entries with a snippet. Missing " + "entries return {found:false} rather than erroring. Read-only."; def.parameters = nlohmann::json({ {"type", "object"}, {"properties", { + {"scope", { + {"type", "string"}, + {"enum", nlohmann::json::array({"global", "workspace", "all"})}, + {"description", "Which scope to read. Default 'all'."} + }}, {"name", { {"type", "string"}, {"description", "Memory entry file stem (the in .md)."} @@ -30,75 +91,110 @@ ToolImpl create_memory_read_tool(MemoryRegistry& registry, {"type", "string"}, {"enum", nlohmann::json::array({"user", "feedback", "project", "reference"})}, {"description", "Filter entries by type when 'name' is absent."} + }}, + {"query", { + {"type", "string"}, + {"description", "Case-insensitive text to search for in names, descriptions and bodies."} }} }}, {"required", nlohmann::json::array()} }); - auto execute = [®istry, max_index_bytes](const std::string& arguments_json, - const ToolContext& /*ctx*/) -> ToolResult { - std::string name, type_str; + auto execute = [memory](const std::string& arguments_json, + const ToolContext& ctx) -> ToolResult { + std::string scope_str, name, type_str, query; try { if (!arguments_json.empty()) { auto args = nlohmann::json::parse(arguments_json); + scope_str = args.value("scope", ""); name = args.value("name", ""); type_str = args.value("type", ""); + query = args.value("query", ""); } } catch (...) { return ToolResult{"[Error] Failed to parse tool arguments.", false}; } - - if (!name.empty()) { - auto found = registry.find(name); - nlohmann::json out; - if (!found) { - out["found"] = false; - out["name"] = name; - out["hint"] = "Use memory_read() with no args to list available entries."; - LOG_DEBUG("[memory_read] entry not found: " + name); - return ToolResult{out.dump(), true}; - } - out["found"] = true; - out["name"] = found->name; - out["description"] = found->description; - out["type"] = memory_type_to_string(found->type); - out["body"] = found->body; - out["path"] = path_to_utf8_generic(found->path); - return ToolResult{out.dump(), true}; + if (!memory || !memory->enabled()) { + return ToolResult{error_json("memory is disabled in settings").dump(), false}; } + std::vector scopes; + if (scope_str.empty() || scope_str == "all") { + scopes = {MemoryScope::Workspace, MemoryScope::Global}; + } else if (auto parsed = parse_memory_scope(scope_str)) { + scopes = {*parsed}; + } else { + return ToolResult{error_json("invalid scope: " + scope_str + + " (allowed: global|workspace|all)").dump(), false}; + } std::optional type_filter; if (!type_str.empty()) { - auto parsed = parse_memory_type(type_str); - if (!parsed) { - nlohmann::json err; - err["success"] = false; - err["error"] = "invalid type: " + type_str + - " (allowed: user|feedback|project|reference)"; - return ToolResult{err.dump(), false}; + type_filter = parse_memory_type(type_str); + if (!type_filter) { + return ToolResult{error_json("invalid type: " + type_str + + " (allowed: user|feedback|project|reference)").dump(), + false}; + } + } + + // 另一个进程(别的工作区的 daemon、TUI)可能刚写过新条目;目录里只有 + // 几个小文件,每次读前重扫的代价可以忽略。 + const MemoryToolScope where = resolve_memory_tool_scope(ctx); + std::vector entries; + for (MemoryScope scope : scopes) { + auto registry = memory->scope(scope, where.project_dir); + if (!registry) continue; + registry->reload(); + for (auto& entry : registry->list(type_filter)) { + entries.push_back({scope, std::move(entry)}); } - type_filter = *parsed; } - auto entries = registry.list(type_filter); nlohmann::json out; - out["success"] = true; - if (!type_filter.has_value()) { - out["index"] = registry.read_index_raw(max_index_bytes); + if (!name.empty()) { + for (const auto& item : entries) { + if (item.entry.name != name) continue; + out = summary_json(item); + out["found"] = true; + out["created_at"] = item.entry.created_at; + out["source"] = item.entry.source; + out["source_sessions"] = item.entry.source_sessions; + out["body"] = item.entry.body; + out["path"] = path_to_utf8_generic(item.entry.path); + return ToolResult{out.dump(), true}; + } + out["found"] = false; + out["name"] = name; + out["hint"] = "Use memory_read() with no args to list available entries."; + LOG_DEBUG("[memory_read] entry not found: " + name); + return ToolResult{out.dump(), true}; } + nlohmann::json arr = nlohmann::json::array(); - for (const auto& e : entries) { - nlohmann::json item; - item["name"] = e.name; - item["description"] = e.description; - item["type"] = memory_type_to_string(e.type); - arr.push_back(item); + const std::string needle = ascii_lower(query); + for (const auto& item : entries) { + if (needle.empty()) { + arr.push_back(summary_json(item)); + continue; + } + const std::string fields[] = {item.entry.name, item.entry.description, item.entry.body}; + for (const auto& field : fields) { + const std::size_t pos = ascii_lower(field).find(needle); + if (pos == std::string::npos) continue; + auto hit = summary_json(item); + hit["snippet"] = snippet_around(field, pos, needle.size()); + arr.push_back(std::move(hit)); + break; + } } - out["entries"] = arr; - out["count"] = entries.size(); - if (entries.empty()) { - out["message"] = type_filter.has_value() - ? ("No memory entries of type '" + type_str + "'.") + out["success"] = true; + out["workspace_available"] = !where.project_dir.empty(); + if (!query.empty()) out["query"] = query; + out["count"] = arr.size(); + out["entries"] = std::move(arr); + if (out["count"].get() == 0) { + out["message"] = !query.empty() ? "No memory entries match '" + query + "'." + : type_filter ? "No memory entries of type '" + type_str + "'." : "No memory entries yet. Use memory_write to create one."; } return ToolResult{out.dump(), true}; diff --git a/src/adapters/tool/memory_read_tool.hpp b/src/adapters/tool/memory_read_tool.hpp index ca4607eb7..77ba5afab 100644 --- a/src/adapters/tool/memory_read_tool.hpp +++ b/src/adapters/tool/memory_read_tool.hpp @@ -2,18 +2,21 @@ #include "tool_executor.hpp" +#include + namespace acecode { -class MemoryRegistry; +class MemoryService; -// `memory_read` — tier-1 progressive disclosure for the user's persistent -// memory. Read-only. Args are all optional; see create_memory_read_tool for -// semantics: -// {} → full MEMORY.md index + list of {name,description,type} -// {type} → entries of that type as {name,description} -// {name} → single entry with full body -// {name, type} → same as {name} (type ignored when name given) -ToolImpl create_memory_read_tool(MemoryRegistry& registry, - std::size_t max_index_bytes); +// `memory_read` — read persistent memory from the global scope and the +// session's workspace scope. Read-only; every call rescans disk so entries +// written by another ACECode process are visible. Args are all optional: +// {} → entries of both scopes (scope, name, description, type, updated_at) +// {scope} → only that scope (global | workspace | all) +// {type} → filter by user | feedback | project | reference +// {name} → one entry with full body; workspace first, then global +// {query} → case-insensitive substring match with a snippet +// Missing entries return {found:false} rather than an error. +ToolImpl create_memory_read_tool(std::shared_ptr memory); } // namespace acecode diff --git a/src/adapters/tool/memory_tool_scope.cpp b/src/adapters/tool/memory_tool_scope.cpp new file mode 100644 index 000000000..763298363 --- /dev/null +++ b/src/adapters/tool/memory_tool_scope.cpp @@ -0,0 +1,22 @@ +#include "memory_tool_scope.hpp" + +#include "session/session_manager.hpp" +#include "session/session_storage.hpp" + +namespace acecode { + +MemoryToolScope resolve_memory_tool_scope(const ToolContext& ctx) { + MemoryToolScope scope; + if (ctx.session_manager) { + scope.session_id = ctx.session_manager->current_session_id(); + if (!ctx.session_manager->is_no_workspace()) { + scope.project_dir = ctx.session_manager->current_project_dir(); + } + } else if (!ctx.cwd.empty()) { + scope.project_dir = SessionStorage::get_project_dir(ctx.cwd); + } + if (scope.session_id.empty()) scope.session_id = ctx.session_id; + return scope; +} + +} // namespace acecode diff --git a/src/adapters/tool/memory_tool_scope.hpp b/src/adapters/tool/memory_tool_scope.hpp new file mode 100644 index 000000000..b55bdd0ee --- /dev/null +++ b/src/adapters/tool/memory_tool_scope.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include "tool_executor.hpp" + +#include + +namespace acecode { + +// 记忆工具按「会话」解析作用域(D2):工作区作用域 = 会话项目目录下的 memory/, +// 不看进程 cwd —— daemon 一个进程服务多个工作区。子会话与父会话的项目目录相同, +// 因此自然共享父会话的工作区作用域。 +struct MemoryToolScope { + std::string project_dir; // 空 = 没有工作区(只有全局作用域) + std::string session_id; +}; + +MemoryToolScope resolve_memory_tool_scope(const ToolContext& ctx); + +} // namespace acecode diff --git a/src/adapters/tool/memory_write_tool.cpp b/src/adapters/tool/memory_write_tool.cpp index 1641bf3c0..c15e3a52b 100644 --- a/src/adapters/tool/memory_write_tool.cpp +++ b/src/adapters/tool/memory_write_tool.cpp @@ -1,7 +1,9 @@ #include "memory_write_tool.hpp" +#include "memory_tool_scope.hpp" + #include "memory/memory_paths.hpp" -#include "memory/memory_registry.hpp" +#include "memory/memory_service.hpp" #include "memory/memory_types.hpp" #include "utils/logger.hpp" #include "utils/utf8_path.hpp" @@ -18,18 +20,28 @@ MemoryWriteMode parse_mode(const std::string& s) { return MemoryWriteMode::Upsert; } +ToolResult fail(const std::string& message) { + nlohmann::json err; + err["success"] = false; + err["error"] = message; + return ToolResult{err.dump(), false}; +} + } // namespace -ToolImpl create_memory_write_tool(MemoryRegistry& registry) { +ToolImpl create_memory_write_tool(std::shared_ptr memory) { ToolDef def; def.name = "memory_write"; def.description = - "Persist a memory entry under ~/.acecode/memory/.md. Writes are " - "atomic and the MEMORY.md index is updated automatically. 'name' is " - "sanitized to [A-Za-z0-9_-] (1-64 chars). 'type' must be one of " - "user|feedback|project|reference. 'mode' defaults to 'upsert' (also " - "'create' or 'update'). The tool always writes inside " - "~/.acecode/memory/ — other paths are rejected."; + "Persist a memory entry. Two scopes exist: 'global' for the user's cross-project " + "preferences and 'workspace' for knowledge about the current workspace. Without " + "'scope', user/feedback entries go to global and project/reference entries go to " + "the workspace (sessions without a workspace always write global). Writes are " + "atomic, the scope's MEMORY.md index is updated automatically, and secrets such as " + "API keys, tokens and passwords are replaced with [REDACTED] before saving. 'name' " + "must match [A-Za-z0-9_-] (1-64 chars). 'type' must be one of " + "user|feedback|project|reference. 'mode' defaults to 'upsert' (also 'create' or " + "'update')."; def.parameters = nlohmann::json({ {"type", "object"}, {"properties", { @@ -44,7 +56,7 @@ ToolImpl create_memory_write_tool(MemoryRegistry& registry) { }}, {"description", { {"type", "string"}, - {"description", "Short one-line description used in MEMORY.md index."} + {"description", "One-line description shown in the memory index; put the actionable rule here."} }}, {"body", { {"type", "string"}, @@ -54,14 +66,19 @@ ToolImpl create_memory_write_tool(MemoryRegistry& registry) { {"type", "string"}, {"enum", nlohmann::json::array({"create", "update", "upsert"})}, {"description", "Write semantics. Default 'upsert' (create-or-replace)."} + }}, + {"scope", { + {"type", "string"}, + {"enum", nlohmann::json::array({"global", "workspace"})}, + {"description", "Target scope. Default: global for user/feedback, workspace for project/reference."} }} }}, {"required", nlohmann::json::array({"name", "type", "description", "body"})} }); - auto execute = [®istry](const std::string& arguments_json, - const ToolContext& /*ctx*/) -> ToolResult { - std::string name, type_str, description, body, mode_str; + auto execute = [memory](const std::string& arguments_json, + const ToolContext& ctx) -> ToolResult { + std::string name, type_str, description, body, mode_str, scope_str; try { if (arguments_json.empty()) { return ToolResult{"[Error] memory_write requires arguments.", false}; @@ -72,48 +89,65 @@ ToolImpl create_memory_write_tool(MemoryRegistry& registry) { description = args.value("description", ""); body = args.value("body", ""); mode_str = args.value("mode", "upsert"); + scope_str = args.value("scope", ""); } catch (...) { return ToolResult{"[Error] Failed to parse tool arguments.", false}; } + if (!memory || !memory->enabled()) return fail("memory is disabled in settings"); std::string name_err = validate_memory_name(name); - if (!name_err.empty()) { - nlohmann::json err; - err["success"] = false; - err["error"] = name_err; - return ToolResult{err.dump(), false}; - } + if (!name_err.empty()) return fail(name_err); auto parsed_type = parse_memory_type(type_str); if (!parsed_type.has_value()) { - nlohmann::json err; - err["success"] = false; - err["error"] = "invalid type: " + type_str + - " (allowed: user|feedback|project|reference)"; - return ToolResult{err.dump(), false}; + return fail("invalid type: " + type_str + " (allowed: user|feedback|project|reference)"); } - if (description.empty()) { - nlohmann::json err; - err["success"] = false; - err["error"] = "description must not be empty"; - return ToolResult{err.dump(), false}; + if (description.empty()) return fail("description must not be empty"); + + MemoryScope scope = default_memory_scope_for_type(*parsed_type); + if (!scope_str.empty()) { + auto parsed_scope = parse_memory_scope(scope_str); + if (!parsed_scope) return fail("invalid scope: " + scope_str + " (allowed: global|workspace)"); + scope = *parsed_scope; + } + const MemoryToolScope where = resolve_memory_tool_scope(ctx); + std::string notice; + if (scope == MemoryScope::Workspace && where.project_dir.empty()) { + scope = MemoryScope::Global; + notice = "This session has no workspace, so the entry was saved to the global scope."; } + auto registry = memory->scope(scope, where.project_dir); + if (!registry) return fail("memory scope is unavailable"); + + MemoryWriteRequest request; + request.name = name; + request.type = *parsed_type; + request.description = description; + request.body = body; + request.mode = parse_mode(mode_str); + request.source = kMemorySourceManual; + if (!where.session_id.empty()) request.source_sessions.push_back(where.session_id); std::string err_msg; - auto written = registry.upsert(name, *parsed_type, description, body, - parse_mode(mode_str), err_msg); - if (!written) { - nlohmann::json err; - err["success"] = false; - err["error"] = err_msg; - return ToolResult{err.dump(), false}; - } + MemoryWriteOutcome outcome; + auto written = registry->upsert(request, err_msg, &outcome); + if (!written) return fail(err_msg); nlohmann::json out; out["success"] = true; + out["scope"] = memory_scope_to_string(scope); out["name"] = written->name; out["description"] = written->description; out["type"] = memory_type_to_string(written->type); + out["created"] = outcome.created; out["path"] = path_to_utf8_generic(written->path); + if (outcome.redactions > 0) { + out["redactions"] = outcome.redactions; + const std::string redaction_notice = + std::to_string(outcome.redactions) + + " secret value(s) were replaced with [REDACTED] before saving."; + notice = notice.empty() ? redaction_notice : notice + " " + redaction_notice; + } + if (!notice.empty()) out["notice"] = notice; LOG_INFO("[memory_write] persisted " + path_to_utf8_generic(written->path)); return ToolResult{out.dump(), true}; }; diff --git a/src/adapters/tool/memory_write_tool.hpp b/src/adapters/tool/memory_write_tool.hpp index cea78ffaa..b93dddca2 100644 --- a/src/adapters/tool/memory_write_tool.hpp +++ b/src/adapters/tool/memory_write_tool.hpp @@ -2,14 +2,19 @@ #include "tool_executor.hpp" +#include + namespace acecode { -class MemoryRegistry; +class MemoryService; -// `memory_write` — create / update / upsert a memory entry. Writes to -// ~/.acecode/memory/.md atomically and updates MEMORY.md. NOT read-only. -// Auto-approved in permission flow because the write path is locked to the -// memory directory — see permissions.hpp. -ToolImpl create_memory_write_tool(MemoryRegistry& registry); +// `memory_write` — create / update / upsert a memory entry in the global or the +// session's workspace scope. Writes are atomic, redact secrets first, record +// provenance (source: manual, the current session, timestamps) and update the +// scope's MEMORY.md under the cross-process write lock. NOT read-only, but +// auto-approved in non-Yolo modes because the target path is derived from a +// sanitized name inside a scope directory and anything resolving outside it +// (including through a symlink) is rejected — see permissions.hpp. +ToolImpl create_memory_write_tool(std::shared_ptr memory); } // namespace acecode diff --git a/src/adapters/tool/mtime_tracker.cpp b/src/adapters/tool/mtime_tracker.cpp index 986070b02..5e16eef0a 100644 --- a/src/adapters/tool/mtime_tracker.cpp +++ b/src/adapters/tool/mtime_tracker.cpp @@ -33,9 +33,11 @@ MtimeTracker::ReadObservationKey make_read_observation_key( int end_line, bool byte_mode, uint64_t byte_offset, - size_t max_bytes + size_t max_bytes, + const std::string& scope ) { return MtimeTracker::ReadObservationKey{ + scope, normalize_tracker_path_key(path), start_line, end_line, @@ -137,10 +139,12 @@ bool MtimeTracker::has_unchanged_read_observation( int end_line, bool byte_mode, uint64_t byte_offset, - size_t max_bytes + size_t max_bytes, + const std::string& scope ) const { return unchanged_read_observation( - path, start_line, end_line, byte_mode, byte_offset, max_bytes).has_value(); + path, start_line, end_line, byte_mode, byte_offset, max_bytes, scope) + .has_value(); } std::optional MtimeTracker::unchanged_read_observation( @@ -149,10 +153,11 @@ std::optional MtimeTracker::unchanged_read_observ int end_line, bool byte_mode, uint64_t byte_offset, - size_t max_bytes + size_t max_bytes, + const std::string& scope ) const { const auto key = make_read_observation_key( - path, start_line, end_line, byte_mode, byte_offset, max_bytes); + path, start_line, end_line, byte_mode, byte_offset, max_bytes, scope); std::lock_guard lk(mu_); auto it = read_observations_.find(key); if (it == read_observations_.end()) return std::nullopt; @@ -171,10 +176,11 @@ void MtimeTracker::record_read_observation(const std::string& path, int end_line, bool byte_mode, uint64_t byte_offset, - size_t max_bytes) { + size_t max_bytes, + const std::string& scope) { try { auto key = make_read_observation_key( - path, start_line, end_line, byte_mode, byte_offset, max_bytes); + path, start_line, end_line, byte_mode, byte_offset, max_bytes, scope); auto mtime = std::filesystem::last_write_time(path_from_utf8(key.path)); std::lock_guard lk(mu_); read_observations_[key] = ReadObservation{mtime, {}, {}}; @@ -192,11 +198,12 @@ void MtimeTracker::record_read_observation_result( const std::string& persisted_output_path, bool byte_mode, uint64_t byte_offset, - size_t max_bytes + size_t max_bytes, + const std::string& scope ) { if (tool_call_id.empty() && persisted_output_path.empty()) return; const auto key = make_read_observation_key( - path, start_line, end_line, byte_mode, byte_offset, max_bytes); + path, start_line, end_line, byte_mode, byte_offset, max_bytes, scope); std::lock_guard lk(mu_); auto it = read_observations_.find(key); if (it == read_observations_.end()) return; diff --git a/src/adapters/tool/mtime_tracker.hpp b/src/adapters/tool/mtime_tracker.hpp index 4e04df3d8..7efc3c9a5 100644 --- a/src/adapters/tool/mtime_tracker.hpp +++ b/src/adapters/tool/mtime_tracker.hpp @@ -54,7 +54,11 @@ class MtimeTracker { bool content_unchanged_after_mtime_change = false; }; + // scope = 会话 id。daemon 一个进程服务多个会话(含子代理),「上次读过、 + // 没变」只对同一会话成立:别的会话从没见过那份内容,给它 unchanged 占位 + // 等于让它什么都读不到。空 scope 是不区分会话的旧行为(单测与无会话调用)。 struct ReadObservationKey { + std::string scope; std::string path; int start_line = 0; int end_line = 0; @@ -63,13 +67,14 @@ class MtimeTracker { size_t max_bytes = 0; bool operator<(const ReadObservationKey& other) const { - return std::tie(path, start_line, end_line, byte_mode, + return std::tie(scope, path, start_line, end_line, byte_mode, byte_offset, max_bytes) < - std::tie(other.path, other.start_line, other.end_line, - other.byte_mode, other.byte_offset, other.max_bytes); + std::tie(other.scope, other.path, other.start_line, + other.end_line, other.byte_mode, other.byte_offset, + other.max_bytes); } bool operator==(const ReadObservationKey& other) const { - return path == other.path && + return scope == other.scope && path == other.path && start_line == other.start_line && end_line == other.end_line && byte_mode == other.byte_mode && @@ -111,7 +116,8 @@ class MtimeTracker { int end_line, bool byte_mode = false, uint64_t byte_offset = 0, - size_t max_bytes = 0 + size_t max_bytes = 0, + const std::string& scope = {} ) const; std::optional unchanged_read_observation( const std::string& path, @@ -119,14 +125,16 @@ class MtimeTracker { int end_line, bool byte_mode = false, uint64_t byte_offset = 0, - size_t max_bytes = 0 + size_t max_bytes = 0, + const std::string& scope = {} ) const; void record_read_observation(const std::string& path, int start_line, int end_line, bool byte_mode = false, uint64_t byte_offset = 0, - size_t max_bytes = 0); + size_t max_bytes = 0, + const std::string& scope = {}); void record_read_observation_result(const std::string& path, int start_line, int end_line, @@ -134,7 +142,8 @@ class MtimeTracker { const std::string& persisted_output_path, bool byte_mode = false, uint64_t byte_offset = 0, - size_t max_bytes = 0); + size_t max_bytes = 0, + const std::string& scope = {}); void invalidate_read_observations(const std::string& path); void clear_read_observations(); diff --git a/src/adapters/upgrade/executable_version.cpp b/src/adapters/upgrade/executable_version.cpp index d1bb9da0d..1888c38fe 100644 --- a/src/adapters/upgrade/executable_version.cpp +++ b/src/adapters/upgrade/executable_version.cpp @@ -36,6 +36,18 @@ bool append_output(std::string& output, const char* bytes, std::size_t size, return true; } +// 超时文案带上等了多久和最常见的原因:升级日志与界面只看得到这一句, +// 原来的「probe timed out」分不清是包坏了还是被安全软件扫描拖住。 +std::string probe_timeout_error(std::chrono::milliseconds timeout) { + const auto seconds = + std::chrono::duration_cast(timeout).count(); + return "executable version probe timed out after " + + (seconds > 0 ? std::to_string(seconds) + "s" + : std::to_string(timeout.count()) + "ms") + + " (the new executable did not exit; security software may still be " + "scanning it)"; +} + #ifdef _WIN32 bool capture_version(const std::filesystem::path& executable, std::chrono::milliseconds timeout, @@ -121,7 +133,7 @@ bool capture_version(const std::filesystem::path& executable, break; } if (std::chrono::steady_clock::now() >= deadline) { - error = "executable version probe timed out"; + error = probe_timeout_error(timeout); break; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); @@ -191,7 +203,7 @@ bool capture_version(const std::filesystem::path& executable, break; } if (std::chrono::steady_clock::now() >= deadline) { - error = "executable version probe timed out"; + error = probe_timeout_error(timeout); break; } std::this_thread::sleep_for(std::chrono::milliseconds(10)); diff --git a/src/adapters/upgrade/executable_version.hpp b/src/adapters/upgrade/executable_version.hpp index d90175cd2..468d44fa8 100644 --- a/src/adapters/upgrade/executable_version.hpp +++ b/src/adapters/upgrade/executable_version.hpp @@ -9,10 +9,18 @@ namespace acecode::upgrade { std::optional parse_executable_version_output(const std::string& output); +// 探测只为拦住「起不来 / 卡死」的坏包,不是性能门槛:正常情况下子进程一退出就 +// 立刻返回,超时值只在真卡住时才会被用满。企业机器的杀软会在新 exe 第一次执行 +// 时整包扫描(Defender 的云端首见拦截可以把执行挂起到 60 秒),曾经的 5 秒在 +// 这类机器上稳定超时 —— 反馈 ZHAOZEYIN831:0.9.26 → 0.9.27 下载、校验、解压都 +// 成功,最后因为 `acecode.exe --version` 5 秒内没返回而整体回滚。 +inline constexpr std::chrono::milliseconds kExecutableVersionProbeTimeout = + std::chrono::seconds(120); + // Direct child process, bounded stdout and wall time, no command shell. bool verify_executable_version( const std::filesystem::path& executable, const std::string& expected_version, std::string* error, - std::chrono::milliseconds timeout = std::chrono::seconds(5)); + std::chrono::milliseconds timeout = kExecutableVersionProbeTimeout); } // namespace acecode::upgrade diff --git a/src/apps/daemon/daemon_memory.cpp b/src/apps/daemon/daemon_memory.cpp new file mode 100644 index 000000000..0ea3cbd73 --- /dev/null +++ b/src/apps/daemon/daemon_memory.cpp @@ -0,0 +1,50 @@ +#include "daemon_memory.hpp" + +#include "config/config.hpp" +#include "session/session_storage.hpp" +#include "session/system_notice.hpp" +#include "session_host/session_registry.hpp" + +#include +#include + +namespace acecode::daemon { + +MemorySchedulerHost make_memory_scheduler_host(SessionRegistry& registry, + const std::string& cwd, + const AppConfig& config, + std::shared_mutex& config_mutex, + const std::string& config_path) { + MemorySchedulerHost host; + SessionRegistry* reg = ®istry; + host.project_dirs = [reg, cwd]() { + std::vector dirs = {SessionStorage::get_project_dir(cwd)}; + for (const auto& info : reg->list_active()) { + if (info.no_workspace || info.cwd.empty()) continue; + const std::string dir = SessionStorage::get_project_dir(info.cwd); + if (std::find(dirs.begin(), dirs.end(), dir) == dirs.end()) dirs.push_back(dir); + } + return dirs; + }; + host.session_busy = [reg](const std::string& session_id) { + const auto busy = reg->busy_session_ids(); + return std::find(busy.begin(), busy.end(), session_id) != busy.end(); + }; + const AppConfig* cfg = &config; + std::shared_mutex* mu = &config_mutex; + host.app_config = [cfg, mu]() { + std::shared_lock lock(*mu); + return *cfg; + }; + host.config_path = config_path; + host.notify = [reg](const std::string& session_id, const std::string& text) { + auto entry = reg->acquire(session_id); + if (entry && entry->loop) { + // 异步完成的报告落进会话记录(只给界面看,不进模型上下文),用户切走再回来也看得到。 + entry->loop->emit_transcript_system_message(text, make_system_notice_metadata("memory_flush_done")); + } + }; + return host; +} + +} // namespace acecode::daemon diff --git a/src/apps/daemon/daemon_memory.hpp b/src/apps/daemon/daemon_memory.hpp new file mode 100644 index 000000000..37194c795 --- /dev/null +++ b/src/apps/daemon/daemon_memory.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "session_host/memory_scheduler.hpp" + +#include +#include + +namespace acecode { +class SessionRegistry; +struct AppConfig; +} + +namespace acecode::daemon { + +// daemon 的记忆摘要宿主回调:扫描进程 cwd 与活跃会话所在的工作区;忙碌判定走 +// 注册表;配置快照在共享锁下复制;/memory flush 完成后经会话 system message 通知。 +// 回调只按值持有指针,调用方保证 registry / config 活得比调度器久(停机时先停调度器)。 +MemorySchedulerHost make_memory_scheduler_host(SessionRegistry& registry, + const std::string& cwd, + const AppConfig& config, + std::shared_mutex& config_mutex, + const std::string& config_path); + +} // namespace acecode::daemon diff --git a/src/apps/daemon/worker.cpp b/src/apps/daemon/worker.cpp index e167bb195..9256e2e7e 100644 --- a/src/apps/daemon/worker.cpp +++ b/src/apps/daemon/worker.cpp @@ -57,6 +57,8 @@ #include "tool/grep_tool.hpp" #include "tool/glob_tool.hpp" #include "tool/goal_tool.hpp" +#include "daemon_memory.hpp" +#include "session_host/memory_runtime.hpp" #include "tool/skill_view_tool.hpp" #include "tool/skills_tool.hpp" #include "tool/task_complete_tool.hpp" @@ -473,6 +475,11 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { // 安全审计存储(openspec add-security-center):失败只记日志,record 退化为 no-op。 acecode::security::audit_log().configure(acecode::get_acecode_dir()); + // 记忆:三入口共用 create_memory_runtime(openspec unify-memory-system), + // 先于 ToolExecutor 构造 —— 工具持有它的服务,析构顺序要反过来。 + auto memory_runtime = acecode::create_memory_runtime( + cfg_mut, acecode::get_acecode_dir(), acecode::MemorySurface::Daemon); + acecode::ToolExecutor tools; acecode::register_session_builtin_tools(tools, cfg_mut); // daemon 用 async 版本(走 ToolContext::ask_user_questions → AskUserQuestionPrompter @@ -485,6 +492,7 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { // 这两个 tool 自己取)。 tools.register_tool(acecode::create_skills_list_tool(skill_registry, &cfg_mut)); tools.register_tool(acecode::create_skill_view_tool(skill_registry, &cfg_mut)); + memory_runtime->register_tools(tools); // spawn_subagent / wait_subagent:daemon 专属(TUI 无 SessionRegistry 不注册)。 // ToolExecutor 先于 SessionRegistry 构造,deps 用 shared_ptr 延迟回填 —— 回填 @@ -516,8 +524,7 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { reg_deps.config_mutex = &app_config_mu; reg_deps.skill_registry = &skill_registry; reg_deps.expert_registry = &expert_registry; - reg_deps.memory_registry = nullptr; - reg_deps.memory_cfg = nullptr; + reg_deps.memory = memory_runtime; reg_deps.project_instructions_cfg = &cfg_mut.project_instructions; reg_deps.custom_instructions_cfg = &cfg_mut.custom_instructions; reg_deps.hook_manager = &hook_manager; @@ -606,6 +613,7 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { web_deps.session_client = &client; web_deps.session_registry = ®istry; web_deps.task_suggestions = task_suggestions; + web_deps.memory = memory_runtime; web_deps.before_data_dir_copy = [&] { loop_scheduler.stop(); }; web_deps.on_data_dir_copy_failure = [&] { if (loop_store_ready) loop_scheduler.start(); }; web_deps.expert_registry = &expert_registry; @@ -777,7 +785,10 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { desktop_owner_monitor.request_stop(); desktop_owner_monitor.join(); break; - case DaemonShutdownStep::LoopScheduler: loop_scheduler.stop(); break; + case DaemonShutdownStep::LoopScheduler: + loop_scheduler.stop(); + memory_runtime->stop_summary_scheduler(); // 调度线程引用注册表,先于会话停 + break; case DaemonShutdownStep::TaskSuggestions: task_suggestions->shutdown(); break; case DaemonShutdownStep::Sessions: registry.shutdown_all(); break; case DaemonShutdownStep::SpawnListener: subagent_deps->on_spawn = {}; break; @@ -790,6 +801,9 @@ int run_worker(const WorkerOptions& opts, const AppConfig& cfg) { }; // Synchronous cleanup while all referenced stack resources are still alive. ScopeExit shutdown_on_exit([&] { shutdown.run(shutdown_step); }); + // 记忆摘要调度器引用注册表:在停机守卫建立之后再启动,任何退出路径都先停它。 + memory_runtime->start_summary_scheduler(acecode::daemon::make_memory_scheduler_host( + registry, cwd, cfg_mut, app_config_mu, config_path)); const auto first_start_auth = acecode::plan_connector_first_start_auth(cfg_mut.connectors); if (!first_start_auth.persisted) { diff --git a/src/apps/headless/headless_runner.cpp b/src/apps/headless/headless_runner.cpp index b25d2c4f0..61ec1cb60 100644 --- a/src/apps/headless/headless_runner.cpp +++ b/src/apps/headless/headless_runner.cpp @@ -22,6 +22,10 @@ #include "session_host/local_session_client.hpp" #include "session_host/session_registry.hpp" #include "session_host/thread_service.hpp" +#include "session_host/memory_runtime.hpp" +#include "memory/memory_paths.hpp" +#include "tool/memory_read_tool.hpp" +#include "tool/memory_write_tool.hpp" #include "skills/skill_init.hpp" #include "skills/skill_registry.hpp" #include "tool/ask_user_question_tool.hpp" @@ -200,8 +204,14 @@ void register_headless_tools( SkillRegistry* skill_registry, const std::shared_ptr& subagent_deps, const std::shared_ptr& thread_deps, - const std::shared_ptr& workspace_deps) { + const std::shared_ptr& workspace_deps, + const std::shared_ptr& memory) { register_session_builtin_tools(tools, cfg); + // 记忆工具与 TUI / daemon 同一份;可被 --disable-tools 移除(openspec unify-memory-system)。 + if (memory) { + tools.register_tool(create_memory_read_tool(memory)); + tools.register_tool(create_memory_write_tool(memory)); + } tools.register_tool(create_ask_user_question_tool_async( cfg.ask.max_questions, cfg.ask.max_options)); if (skill_registry && cfg.skills.allowed && @@ -250,9 +260,11 @@ int print_available_capabilities(const HeadlessCliOptions& opts) { auto subagent_deps = std::make_shared(); auto thread_deps = std::make_shared(); auto workspace_deps = std::make_shared(); + // 只为列出名字构造记忆服务:不建目录、不打开状态库,发现分支保持无副作用。 register_headless_tools( tools, cfg, nullptr, subagent_deps, thread_deps, - workspace_deps); + workspace_deps, std::make_shared( + get_memory_dir(), get_memory_state_db_path(), cfg.memory)); for (const auto& def : tools.get_tool_definitions_by_source(ToolSource::Builtin)) { entries.push_back({def.name, {}}); @@ -470,6 +482,9 @@ int run_print_mode(const HeadlessCliOptions& opts) { acecode::SkillRegistry skill_registry; acecode::initialize_skill_registry(skill_registry, cfg, cwd); + // 记忆:注入与工具和 TUI / daemon 一致,但 headless 不跑记忆摘要调度器。 + auto memory_runtime = acecode::create_memory_runtime( + cfg, acecode::get_acecode_dir(), acecode::MemorySurface::Headless); acecode::ToolExecutor tools; auto subagent_deps = std::make_shared(); auto thread_deps = std::make_shared(); @@ -480,7 +495,7 @@ int run_print_mode(const HeadlessCliOptions& opts) { // 不会真的走到 prompter。仍注册它是为了让模型看到与 daemon 一致的工具面。 register_headless_tools( tools, cfg, &skill_registry, subagent_deps, thread_deps, - workspace_deps); + workspace_deps, memory_runtime->service()); acecode::daemon::DaemonMcpRuntime mcp_runtime; @@ -531,8 +546,8 @@ int run_print_mode(const HeadlessCliOptions& opts) { reg_deps.mcp_manager = &mcp_runtime.manager(); reg_deps.load_project_mcp = false; reg_deps.skill_registry = &skill_registry; - reg_deps.memory_registry = nullptr; - reg_deps.memory_cfg = nullptr; + reg_deps.memory = memory_runtime; + reg_deps.session_surface = "headless"; reg_deps.project_instructions_cfg = &cfg.project_instructions; reg_deps.custom_instructions_cfg = &cfg.custom_instructions; reg_deps.hook_manager = &hook_manager; diff --git a/src/apps/tui/app/tui_app.cpp b/src/apps/tui/app/tui_app.cpp index 0fd4e542a..c20d3c8be 100644 --- a/src/apps/tui/app/tui_app.cpp +++ b/src/apps/tui/app/tui_app.cpp @@ -1,5 +1,6 @@ #include "tui/app/tui_app.hpp" #include "tui/app/tui_services.hpp" +#include "session_host/memory_runtime.hpp" #include "tui/app/tui_screen_host.hpp" #include "tui/app/tui_submitter.hpp" #include "tui/app/tui_overlay_gate.hpp" @@ -45,6 +46,12 @@ std::shared_ptr TuiApp::provider_snapshot() { void TuiApp::publish_configuration() { auto config = std::make_shared(services_->config); std::atomic_store(&published_config_, std::move(config)); + if (services_->memory) { + // 设置中心改了记忆开关 / 记忆摘要:立即对本进程的新请求与调度生效。 + MemoryConfig memory = services_->config.memory; + if (!memory_runtime_available_) memory.enabled = false; + services_->memory->update_config(memory); + } if (agent_loop_) { auto skills = services_->skills->snapshot(); agent_loop_->enqueue_control([ref = lifetime_.ref(*this), skills = std::move(skills)] { @@ -79,7 +86,7 @@ bool TuiApp::init_stage(TuiInitStage stage) { case TuiInitStage::Services: services_ = std::make_unique(); if (!services_->initialize(options_.cli, environment_.working_dir, options_.argv0_dir)) return false; - memory_runtime_available_ = !services_->config.memory.enabled || services_->runtime_memory_config.enabled; + memory_runtime_available_ = !services_->config.memory.enabled || services_->memory->service()->enabled(); publish_configuration(); provider_accessor_ = bind(&TuiApp::provider_snapshot); break; @@ -140,6 +147,7 @@ bool TuiApp::init_stage(TuiInitStage stage) { session_manager_, *agent_loop_, turn_lifecycle_->title_applied_callback()); callbacks_.on_turn_finished = turn_lifecycle_->title_finished_callback(); agent_loop_->set_callbacks(callbacks_); + start_memory_scheduler(); break; case TuiInitStage::Subagents: initialize_subagents(); diff --git a/src/apps/tui/app/tui_app.hpp b/src/apps/tui/app/tui_app.hpp index c05e6d47b..6d49eb05c 100644 --- a/src/apps/tui/app/tui_app.hpp +++ b/src/apps/tui/app/tui_app.hpp @@ -47,6 +47,11 @@ class TuiApp final : private ITuiApplicationLifecycle { void create_components(); std::shared_ptr provider_snapshot(); void publish_configuration(); + // 记忆摘要调度器的宿主回调(tui_app_memory.cpp);经 bind() 走 LifetimeRef。 + void start_memory_scheduler(); + std::vector memory_project_dirs(); + bool memory_session_busy(const std::string& session_id); + void memory_notice(const std::string& session_id, const std::string& text); AppConfig config_snapshot(); SessionPromptConfig prompt_config_snapshot(); nlohmann::json ask_questions(const nlohmann::json& payload, diff --git a/src/apps/tui/app/tui_app_memory.cpp b/src/apps/tui/app/tui_app_memory.cpp new file mode 100644 index 000000000..761fe6c9d --- /dev/null +++ b/src/apps/tui/app/tui_app_memory.cpp @@ -0,0 +1,46 @@ +// TUI 的记忆摘要宿主:扫描启动工作区、按主会话忙碌状态避让、/memory flush 完成后 +// 在对话里插一行系统消息。回调都经 bind() 走 LifetimeRef,TuiApp 析构后自动失效。 +#include "tui/app/tui_app.hpp" +#include "tui/app/tui_services.hpp" +#include "tui/app/tui_screen_host.hpp" + +#include "agent/agent_loop.hpp" +#include "session/session_storage.hpp" +#include "session_host/memory_runtime.hpp" +#include "session_host/memory_scheduler.hpp" +#include "utils/paths.hpp" +#include "utils/utf8_path.hpp" + +namespace acecode::tui { + +void TuiApp::start_memory_scheduler() { + if (!services_ || !services_->memory) return; + MemorySchedulerHost host; + host.project_dirs = bind(&TuiApp::memory_project_dirs); + host.session_busy = bind(&TuiApp::memory_session_busy); + host.app_config = bind(&TuiApp::config_snapshot); + host.notify = bind(&TuiApp::memory_notice); + host.config_path = path_to_utf8(path_from_utf8(get_acecode_dir()) / "config.json"); + services_->memory->start_summary_scheduler(std::move(host)); +} + +std::vector TuiApp::memory_project_dirs() { + return {SessionStorage::get_project_dir(environment_.working_dir)}; +} + +bool TuiApp::memory_session_busy(const std::string& session_id) { + return agent_loop_ && agent_loop_->is_busy() && + session_manager_.current_session_id() == session_id; +} + +void TuiApp::memory_notice(const std::string& session_id, const std::string& text) { + if (session_manager_.current_session_id() != session_id) return; + { + std::lock_guard lock(state_.mu); + state_.conversation.push_back({"system", text, false}); + state_.chat_follow_tail = true; + } + if (screen_host_) screen_host_->post_event(ftxui::Event::Custom); +} + +} // namespace acecode::tui diff --git a/src/apps/tui/app/tui_app_session.cpp b/src/apps/tui/app/tui_app_session.cpp index 0a77362d6..a82f59c72 100644 --- a/src/apps/tui/app/tui_app_session.cpp +++ b/src/apps/tui/app/tui_app_session.cpp @@ -22,7 +22,7 @@ #include "tool/mcp_manager.hpp" #include "skills/skill_registry.hpp" #include "skills/skill_usage_store.hpp" -#include "memory/memory_registry.hpp" +#include "session_host/memory_runtime.hpp" #include "hooks/hook_manager.hpp" #include "provider/model_resolver.hpp" #include "platform/power_inhibitor.hpp" @@ -47,7 +47,7 @@ void TuiApp::initialize_agent() { loop_services.session = &session_manager_; loop_services.hooks = services_->hooks.get(); loop_services.skill_usage = services_->skill_usage.get(); - loop_services.memory = services_->memory.get(); + loop_services.memory = services_->memory->service(); loop_services.skills = services_->skills->snapshot(); loop_services.prompt_config = bind(&TuiApp::prompt_config_snapshot); AgentLoopOptions loop_options; @@ -107,9 +107,7 @@ void TuiApp::initialize_subagents() { auto& config = services_->config; auto& tools = *services_->tools; auto& skill_registry = *services_->skills; - auto& memory_registry = *services_->memory; auto& mcp_manager = *services_->mcp; - auto& runtime_memory_cfg = services_->runtime_memory_config; auto& hook_manager = *services_->hooks; auto& permissions = *permissions_; auto& working_dir = environment_.working_dir; @@ -124,8 +122,7 @@ void TuiApp::initialize_subagents() { rd.prompt_config = bind(&TuiApp::prompt_config_snapshot); rd.mcp_manager = &mcp_manager; rd.skill_registry = &skill_registry; - rd.memory_registry = &memory_registry; - rd.memory_cfg = &runtime_memory_cfg; + rd.memory = services_->memory; rd.project_instructions_cfg = &config.project_instructions; rd.custom_instructions_cfg = &config.custom_instructions; rd.hook_manager = &hook_manager; diff --git a/src/apps/tui/app/tui_app_shutdown.cpp b/src/apps/tui/app/tui_app_shutdown.cpp index 90619d3e7..e6945020a 100644 --- a/src/apps/tui/app/tui_app_shutdown.cpp +++ b/src/apps/tui/app/tui_app_shutdown.cpp @@ -1,5 +1,6 @@ #include "tui/app/tui_app.hpp" #include "tui/app/tui_services.hpp" +#include "session_host/memory_runtime.hpp" #include "tui/app/tui_screen_host.hpp" #include "tui/app/model_pool_monitor_subscription.hpp" #include "tui/app/tui_notification_binding.hpp" @@ -28,6 +29,7 @@ void TuiApp::shutdown_step(TuiShutdownStep step) { break; case TuiShutdownStep::AutoTitle: if (auto_title_runner_) auto_title_runner_->stop(); + if (services_ && services_->memory) services_->memory->stop_summary_scheduler(); break; case TuiShutdownStep::Notifications: if (notifications_) notifications_->shutdown(); diff --git a/src/apps/tui/app/tui_command_context_factory.cpp b/src/apps/tui/app/tui_command_context_factory.cpp index 3b2506266..d53d05b60 100644 --- a/src/apps/tui/app/tui_command_context_factory.cpp +++ b/src/apps/tui/app/tui_command_context_factory.cpp @@ -6,7 +6,7 @@ namespace acecode::tui { TuiCommandContextFactory::TuiCommandContextFactory(TuiState& state, AgentLoop& agent, SessionModelBinding& binding, AppConfig& config, TokenTracker& tracker, PermissionManager& permissions, IScreenPort& screen, SessionManager& session, - McpManager& mcp, ToolExecutor& tools, SkillRegistry& skills, MemoryRegistry& memory, + McpManager& mcp, ToolExecutor& tools, SkillRegistry& skills, MemoryRuntime& memory, CommandRegistry& commands, const std::string& cwd, ITurnSubmitter& submitter, SubagentHost* subagents, const std::unique_ptr& surfaces, std::function publish_config) : state_(state), agent_(agent), binding_(binding), config_(config), tracker_(tracker), diff --git a/src/apps/tui/app/tui_command_context_factory.hpp b/src/apps/tui/app/tui_command_context_factory.hpp index e2ae92913..206ae97b8 100644 --- a/src/apps/tui/app/tui_command_context_factory.hpp +++ b/src/apps/tui/app/tui_command_context_factory.hpp @@ -8,7 +8,7 @@ class TuiCommandContextFactory final : public ICommandContextFactory { TuiCommandContextFactory(TuiState& state, AgentLoop& agent, SessionModelBinding& binding, AppConfig& config, TokenTracker& tracker, PermissionManager& permissions, IScreenPort& screen, SessionManager& session, McpManager& mcp, ToolExecutor& tools, - SkillRegistry& skills, MemoryRegistry& memory, CommandRegistry& commands, + SkillRegistry& skills, MemoryRuntime& memory, CommandRegistry& commands, const std::string& cwd, ITurnSubmitter& submitter, SubagentHost* subagents, const std::unique_ptr& surfaces, std::function publish_config = {}); @@ -26,7 +26,7 @@ class TuiCommandContextFactory final : public ICommandContextFactory { McpManager& mcp_; ToolExecutor& tools_; SkillRegistry& skills_; - MemoryRegistry& memory_; + MemoryRuntime& memory_; CommandRegistry& commands_; const std::string& cwd_; ITurnSubmitter& submitter_; diff --git a/src/apps/tui/app/tui_runtime_init.cpp b/src/apps/tui/app/tui_runtime_init.cpp index 9f45ab18b..9a2fef9f5 100644 --- a/src/apps/tui/app/tui_runtime_init.cpp +++ b/src/apps/tui/app/tui_runtime_init.cpp @@ -17,8 +17,7 @@ #include "skills/default_skill_startup.hpp" #include "skills/skill_init.hpp" #include "skills/skill_registry.hpp" -#include "memory/memory_registry.hpp" -#include "memory/memory_paths.hpp" +#include "session_host/memory_runtime.hpp" #include "lsp/lsp_service.hpp" #include "tool/web_search/runtime.hpp" #include "tool/web_search/backend_router.hpp" @@ -26,8 +25,6 @@ #include "tool/builtin_tool_registry.hpp" #include "tool/skills_tool.hpp" #include "tool/skill_view_tool.hpp" -#include "tool/memory_read_tool.hpp" -#include "tool/memory_write_tool.hpp" #include "tool/mcp_manager.hpp" #include "tool/tool_executor.hpp" #include "utils/logger.hpp" @@ -88,23 +85,6 @@ static void initialize_web_search_runtime(const AppConfig& config) { } } -static MemoryConfig initialize_memory_registry(MemoryRegistry& memory_registry, - const AppConfig& config) { - // Auto-create ~/.acecode/memory/ if missing; failure disables the memory - // system for this session without rewriting the user's config.json. - MemoryConfig runtime_memory_cfg = config.memory; - std::error_code mkec; - std::filesystem::create_directories(get_memory_dir(), mkec); - if (mkec) { - LOG_ERROR("[memory] failed to create " + get_memory_dir().generic_string() + - ": " + mkec.message() + " — memory will be disabled this session"); - runtime_memory_cfg.enabled = false; - } else if (runtime_memory_cfg.enabled) { - memory_registry.scan(); - } - return runtime_memory_cfg; -} - static void initialize_mcp_servers(McpManager& mcp_manager, const AppConfig& config) { const size_t configured = config.mcp_servers.size(); @@ -200,10 +180,10 @@ ModelProfile initialize_tui_provider_runtime( return effective_entry; } -MemoryConfig initialize_tui_tools_and_registries( +void initialize_tui_tools_and_registries( ToolExecutor& tools, SkillRegistry& skill_registry, - MemoryRegistry& memory_registry, + MemoryRuntime& memory, McpManager& mcp_manager, const AppConfig& config, const std::string& working_dir) { @@ -216,15 +196,11 @@ MemoryConfig initialize_tui_tools_and_registries( tools.register_tool(create_skills_list_tool(skill_registry, &config)); tools.register_tool(create_skill_view_tool(skill_registry, &config)); - MemoryConfig runtime_memory_cfg = - initialize_memory_registry(memory_registry, config); - tools.register_tool(create_memory_read_tool(memory_registry, - runtime_memory_cfg.max_index_bytes)); - tools.register_tool(create_memory_write_tool(memory_registry)); + // 记忆工具与 daemon / headless 同一份注册(create_memory_runtime 已建好目录)。 + memory.register_tools(tools); initialize_mcp_servers(mcp_manager, config); mcp_manager.reconcile_scope(working_dir, load_project_mcp_config(working_dir), tools); - return runtime_memory_cfg; } acecode::tui::ScreenRenderMode initialize_tui_render_mode( diff --git a/src/apps/tui/app/tui_runtime_init.hpp b/src/apps/tui/app/tui_runtime_init.hpp index 93c3069ef..22f7746bc 100644 --- a/src/apps/tui/app/tui_runtime_init.hpp +++ b/src/apps/tui/app/tui_runtime_init.hpp @@ -8,7 +8,7 @@ class HookManager; class SessionModelBinding; class ToolExecutor; class SkillRegistry; -class MemoryRegistry; +class MemoryRuntime; class McpManager; } namespace acecode::tui { @@ -18,8 +18,8 @@ AppConfig load_tui_config_and_runtime(HookManager& hooks, const std::string& wor ModelProfile initialize_tui_provider_runtime(AppConfig& config, const std::string& working_dir, const std::optional& cwd_override, SessionModelBinding& binding, HookManager& hooks); -MemoryConfig initialize_tui_tools_and_registries(ToolExecutor& tools, SkillRegistry& skills, - MemoryRegistry& memory, McpManager& mcp, const AppConfig& config, +void initialize_tui_tools_and_registries(ToolExecutor& tools, SkillRegistry& skills, + MemoryRuntime& memory, McpManager& mcp, const AppConfig& config, const std::string& working_dir); ScreenRenderMode initialize_tui_render_mode(AppConfig& config, bool force_alt_screen, TerminalCapabilities& capabilities, bool& conhost_compat_layout); diff --git a/src/apps/tui/app/tui_services.cpp b/src/apps/tui/app/tui_services.cpp index bc538e454..595f744af 100644 --- a/src/apps/tui/app/tui_services.cpp +++ b/src/apps/tui/app/tui_services.cpp @@ -12,7 +12,7 @@ #include "security/audit_log.hpp" #include "skills/skill_registry.hpp" #include "skills/skill_usage_store.hpp" -#include "memory/memory_registry.hpp" +#include "session_host/memory_runtime.hpp" #include "workspace/workspace_registry.hpp" #include "utils/paths.hpp" #include "utils/utf8_path.hpp" @@ -42,10 +42,9 @@ bool TuiServices::initialize(const InteractiveCliOptions& cli, const std::string security::audit_log().configure(get_acecode_dir()); tools = std::make_unique(); skills = std::make_unique(); - memory = std::make_unique(); + memory = create_memory_runtime(config, get_acecode_dir(), MemorySurface::Tui); mcp = std::make_unique(); - runtime_memory_config = initialize_tui_tools_and_registries( - *tools, *skills, *memory, *mcp, config, cwd); + initialize_tui_tools_and_registries(*tools, *skills, *memory, *mcp, config, cwd); const auto projects = path_to_utf8(path_from_utf8(get_acecode_dir()) / "projects"); workspaces = std::make_unique(); workspaces->scan(projects); diff --git a/src/apps/tui/app/tui_services.hpp b/src/apps/tui/app/tui_services.hpp index 0de34d8f1..e2af1574e 100644 --- a/src/apps/tui/app/tui_services.hpp +++ b/src/apps/tui/app/tui_services.hpp @@ -6,7 +6,7 @@ #include namespace acecode { struct InteractiveCliOptions; -class HookManager; class ToolExecutor; class SkillRegistry; class MemoryRegistry; +class HookManager; class ToolExecutor; class SkillRegistry; class MemoryRuntime; class McpManager; class SkillUsageStore; struct WorkspaceToolDeps; namespace desktop { class WorkspaceRegistry; } } @@ -24,14 +24,13 @@ class TuiServices { SessionModelBinding model_binding; std::unique_ptr tools; std::unique_ptr skills; - std::unique_ptr memory; + std::shared_ptr memory; std::unique_ptr mcp; std::unique_ptr workspaces; // Shared by workspace tool closures and this service owner. std::shared_ptr workspace_tool_deps; // Shared with UI state and session skill callbacks, persisted at one store. std::shared_ptr skill_usage; - MemoryConfig runtime_memory_config; std::optional cwd_model_override; ModelProfile initial_model; }; diff --git a/src/apps/tui/commands/command_registry.hpp b/src/apps/tui/commands/command_registry.hpp index eba1e30cd..8d7743748 100644 --- a/src/apps/tui/commands/command_registry.hpp +++ b/src/apps/tui/commands/command_registry.hpp @@ -21,7 +21,7 @@ namespace acecode { class McpManager; class ToolExecutor; class SkillRegistry; -class MemoryRegistry; +class MemoryRuntime; class CommandRegistry; namespace tui { class SubagentHost; } @@ -40,7 +40,7 @@ struct CommandContext { McpManager* mcp_manager = nullptr; // runtime MCP control surface (optional) ToolExecutor* tools = nullptr; // tool registry for /mcp enable/disable SkillRegistry* skills = nullptr; // skill registry for /skills and / commands - MemoryRegistry* memory = nullptr; // memory registry for /memory commands + MemoryRuntime* memory = nullptr; // memory runtime for /memory commands CommandRegistry* command_registry = nullptr; // self-reference for /skills reload std::string cwd; // working directory for cwd-scoped operations tui::SubagentHost* subagent_host = nullptr; // /tasks 的子代理宿主(仅斜杠 dispatch 路径注入) diff --git a/src/apps/tui/commands/memory_command.cpp b/src/apps/tui/commands/memory_command.cpp index fbcf1925b..84e7e048e 100644 --- a/src/apps/tui/commands/memory_command.cpp +++ b/src/apps/tui/commands/memory_command.cpp @@ -1,132 +1,31 @@ #include "memory_command.hpp" -#include "memory/memory_paths.hpp" -#include "memory/memory_registry.hpp" -#include "memory/memory_types.hpp" +#include "session_host/memory_command.hpp" +#include "session_host/memory_runtime.hpp" #include "utils/logger.hpp" #include "utils/utf8_path.hpp" -#include #include -#include #include -#include - -#ifdef _WIN32 -# include -#else -# include -#endif - -namespace fs = std::filesystem; namespace acecode { namespace { -struct ParsedCommand { - std::string sub; - std::string rest; - std::string type_filter; // for list --type= -}; - -// Trim leading/trailing whitespace. -std::string strip(const std::string& s) { - size_t a = 0, b = s.size(); - while (a < b && std::isspace(static_cast(s[a]))) ++a; - while (b > a && std::isspace(static_cast(s[b - 1]))) --b; - return s.substr(a, b - a); -} - -ParsedCommand parse_args(const std::string& args) { - ParsedCommand out; - std::string trimmed = strip(args); - if (trimmed.empty()) { - out.sub = "list"; - return out; - } - std::size_t sp = trimmed.find_first_of(" \t"); - if (sp == std::string::npos) { - out.sub = trimmed; - } else { - out.sub = trimmed.substr(0, sp); - out.rest = strip(trimmed.substr(sp + 1)); - } - - // Pull out --type= when present in rest. - const std::string tag = "--type="; - std::size_t tpos = out.rest.find(tag); - if (tpos != std::string::npos) { - std::size_t end = out.rest.find_first_of(" \t", tpos + tag.size()); - out.type_filter = out.rest.substr(tpos + tag.size(), - end == std::string::npos - ? std::string::npos - : end - (tpos + tag.size())); - // Drop the flag from rest so positional lookups still work. - std::string before = strip(out.rest.substr(0, tpos)); - std::string after = end == std::string::npos ? std::string{} : strip(out.rest.substr(end + 1)); - if (!before.empty() && !after.empty()) out.rest = before + " " + after; - else out.rest = before + after; - } - return out; -} - void emit(CommandContext& ctx, const std::string& msg) { std::lock_guard lk(ctx.state.mu); ctx.state.conversation.push_back({"system", msg, false}); ctx.state.chat_follow_tail = true; } -void handle_list(CommandContext& ctx, const ParsedCommand& pc) { - if (!ctx.memory) { emit(ctx, "Memory is not available in this session."); return; } - - std::optional filter; - if (!pc.type_filter.empty()) { - filter = parse_memory_type(pc.type_filter); - if (!filter) { emit(ctx, "Invalid type filter: " + pc.type_filter); return; } - } - - auto entries = ctx.memory->list(filter); - std::ostringstream oss; - oss << "Memory directory: " << path_to_utf8_generic(get_memory_dir()) << "\n"; - if (entries.empty()) { - oss << "No memory entries" - << (filter.has_value() ? " of type " + pc.type_filter : std::string{}) - << " yet."; - } else { - oss << entries.size() << " entr" << (entries.size() == 1 ? "y" : "ies") << ":"; - for (const auto& e : entries) { - oss << "\n [" << memory_type_to_string(e.type) << "] " - << e.name << " — " << e.description; - } - } - emit(ctx, oss.str()); -} - -void handle_view(CommandContext& ctx, const ParsedCommand& pc) { - if (!ctx.memory) { emit(ctx, "Memory is not available in this session."); return; } - std::string name = strip(pc.rest); - if (name.empty()) { emit(ctx, "Usage: /memory view "); return; } - auto found = ctx.memory->find(name); - if (!found) { emit(ctx, "No memory entry named '" + name + "'."); return; } - - std::ostringstream oss; - oss << "[" << memory_type_to_string(found->type) << "] " << found->name << "\n" - << "Description: " << found->description << "\n" - << "Path: " << path_to_utf8_generic(found->path) << "\n\n" - << found->body; - emit(ctx, oss.str()); -} - -int run_editor(const fs::path& target) { - std::string editor; - editor = getenv_utf8("EDITOR"); +int run_editor(const std::string& target) { + std::string editor = getenv_utf8("EDITOR"); #ifdef _WIN32 if (editor.empty()) editor = "notepad"; #else if (editor.empty()) editor = "vim"; #endif - std::string cmd = editor + " \"" + path_to_utf8(target) + "\""; + const std::string cmd = editor + " \"" + target + "\""; #ifdef _WIN32 return _wsystem(utf8_to_wide(cmd).c_str()); #else @@ -134,67 +33,28 @@ int run_editor(const fs::path& target) { #endif } -void handle_edit(CommandContext& ctx, const ParsedCommand& pc) { - if (!ctx.memory) { emit(ctx, "Memory is not available in this session."); return; } - std::string name = strip(pc.rest); - if (name.empty()) { emit(ctx, "Usage: /memory edit "); return; } - std::string err = validate_memory_name(name); - if (!err.empty()) { emit(ctx, err); return; } - - fs::path target = resolve_memory_entry_path(name); - if (!fs::exists(target)) { - emit(ctx, "No memory entry named '" + name + "' (looked for " + - path_to_utf8_generic(target) + ")."); - return; - } - - int rc = run_editor(target); - (void)rc; // rc varies per editor; reload regardless and report path. - ctx.memory->reload(); - emit(ctx, "Reloaded memory from disk (edited " + path_to_utf8_generic(target) + ")."); -} - -void handle_forget(CommandContext& ctx, const ParsedCommand& pc) { - if (!ctx.memory) { emit(ctx, "Memory is not available in this session."); return; } - std::string name = strip(pc.rest); - if (name.empty()) { emit(ctx, "Usage: /memory forget "); return; } - std::string err; - if (!ctx.memory->remove(name, err)) { - emit(ctx, "Failed to forget '" + name + "': " + err); +void cmd_memory(CommandContext& ctx, const std::string& args) { + MemoryCommandContext memory_ctx; + memory_ctx.runtime = ctx.memory; + memory_ctx.session = ctx.session_manager; + const MemoryCommandResult result = dispatch_memory_command(args, memory_ctx); + if (result.edit_path.empty()) { + emit(ctx, result.text); return; } - emit(ctx, "Forgot '" + name + "'."); -} - -void handle_reload(CommandContext& ctx) { - if (!ctx.memory) { emit(ctx, "Memory is not available in this session."); return; } - ctx.memory->reload(); - emit(ctx, "Reloaded " + std::to_string(ctx.memory->size()) + " memory entr" + - (ctx.memory->size() == 1 ? "y." : "ies.")); -} - -void cmd_memory(CommandContext& ctx, const std::string& args) { - ParsedCommand pc = parse_args(args); - if (pc.sub == "list") return handle_list(ctx, pc); - if (pc.sub == "view") return handle_view(ctx, pc); - if (pc.sub == "edit") return handle_edit(ctx, pc); - if (pc.sub == "forget") return handle_forget(ctx, pc); - if (pc.sub == "reload") return handle_reload(ctx); - - emit(ctx, - "Usage:\n" - " /memory list [--type=]\n" - " /memory view \n" - " /memory edit \n" - " /memory forget \n" - " /memory reload"); + // rc 因编辑器而异;无论结果都按磁盘重扫,并告诉用户改的是哪个文件。 + (void)run_editor(result.edit_path); + if (ctx.memory && ctx.memory->service()) ctx.memory->service()->global().reload(); + emit(ctx, "Reloaded memory from disk (edited " + + path_to_utf8_generic(path_from_utf8(result.edit_path)) + ")."); } } // namespace void register_memory_command(CommandRegistry& registry) { registry.register_command( - {"memory", "List, view, edit, forget, or reload persistent user memory", cmd_memory}); + {"memory", "List, view, edit or forget memory; flush memory summarization; turn memory off/on for this session", + cmd_memory}); } } // namespace acecode diff --git a/src/apps/tui/commands/memory_command.hpp b/src/apps/tui/commands/memory_command.hpp index 328f6d300..bc1115f8e 100644 --- a/src/apps/tui/commands/memory_command.hpp +++ b/src/apps/tui/commands/memory_command.hpp @@ -4,12 +4,11 @@ namespace acecode { -// Register the `/memory` slash command with subcommands: -// list [--type=] — show index, optionally filtered -// view — print entry body -// edit — open in $EDITOR then reload -// forget — delete entry file + MEMORY.md line -// reload — rescan ~/.acecode/memory/ +// Register the `/memory` slash command. The text comes from the shared +// dispatch_memory_command (same output as the web/desktop /memory): +// list [--scope=global|workspace] [--type=] / view / edit +// forget / flush / off / on / reload +// Only `edit` is TUI-specific: it opens the entry in $EDITOR. void register_memory_command(CommandRegistry& registry); } // namespace acecode diff --git a/src/apps/tui/settings/memory_settings_section.cpp b/src/apps/tui/settings/memory_settings_section.cpp new file mode 100644 index 000000000..2d1fb1a3b --- /dev/null +++ b/src/apps/tui/settings/memory_settings_section.cpp @@ -0,0 +1,147 @@ +#include "memory_settings_section.hpp" + +#include "config/settings_mutations.hpp" +#include "tui/theme_palette.hpp" + +#include +#include + +#include +#include +#include + +namespace acecode::tui::settings { + +using namespace ftxui; + +struct MemorySettingsSection::State { + AppConfig* config = nullptr; + std::function published; + bool enabled = true; + bool summary_enabled = false; + std::vector model_entries; // [0] = 当前模型,其余为已保存的模型名 + int model_index = 0; + std::string status; + bool status_error = false; + Component use_checkbox; + Component summary_checkbox; + Component model_radio; + Component container; + + void sync() { + if (!config) return; + enabled = config->memory.enabled; + summary_enabled = config->memory.summary.enabled; + model_entries = {"Current model (the model each session last used)"}; + model_index = 0; + for (const auto& profile : config->saved_models) { + model_entries.push_back(profile.name); + if (profile.name == config->memory.summary.model_name) { + model_index = static_cast(model_entries.size()) - 1; + } + } + } + + // 立即保存;失败时控件回到磁盘上的值并显示原因。 + void save() { + if (!config) return; + MemoryConfig next = config->memory; + next.enabled = enabled; + next.summary.enabled = summary_enabled; + next.summary.model_name = + model_index > 0 && model_index < static_cast(model_entries.size()) + ? model_entries[static_cast(model_index)] + : std::string{}; + SettingsMutationOptions options; + options.live_config = config; + options.on_live_config_published = published; + options.restart_required_without_live_apply = false; + const auto result = set_memory_settings(next, options); + status_error = !result.ok; + status = result.ok ? (result.changed ? "Memory settings saved." : "No changes to save.") + : result.error; + if (!result.ok) sync(); + } +}; + +namespace { + +Element radio_entry(const EntryState& entry) { + Element line = text((entry.state ? "(*) " : "( ) ") + entry.label); + return entry.focused ? line | bgcolor(theme().ui.selection_bg) | color(theme().ui.selection_fg) + : line | color(theme().ui.text_muted); +} + +} // namespace + +MemorySettingsSection::MemorySettingsSection(AppConfig* config, std::function published) + : state_(std::make_shared()) { + state_->config = config; + state_->published = std::move(published); + state_->sync(); + + CheckboxOption use_option; + use_option.label = "Use memory (inject saved memory into new sessions and provide memory tools)"; + use_option.checked = &state_->enabled; + use_option.on_change = [weak = std::weak_ptr(state_)] { + if (auto state = weak.lock()) state->save(); + }; + state_->use_checkbox = Checkbox(use_option); + + CheckboxOption summary_option; + summary_option.label = + "Memory summarization (extract memories from idle sessions; makes extra model calls)"; + summary_option.checked = &state_->summary_enabled; + summary_option.on_change = [weak = std::weak_ptr(state_)] { + if (auto state = weak.lock()) state->save(); + }; + state_->summary_checkbox = Checkbox(summary_option); + + RadioboxOption model_option; + model_option.entries = &state_->model_entries; + model_option.selected = &state_->model_index; + model_option.on_change = [weak = std::weak_ptr(state_)] { + if (auto state = weak.lock()) state->save(); + }; + model_option.transform = radio_entry; + state_->model_radio = Radiobox(model_option); + + state_->container = Container::Vertical({ + state_->use_checkbox, + state_->summary_checkbox, + state_->model_radio, + }); +} + +MemorySettingsSection::~MemorySettingsSection() = default; + +Component MemorySettingsSection::component() const { return state_->container; } + +Element MemorySettingsSection::render() const { + const auto& s = *state_; + Elements rows = { + separator() | color(theme().ui.text_dim), + text("Memory") | bold | color(theme().ui.text_primary), + paragraph("Global memory holds your preferences across projects; workspace memory " + "belongs to this workspace. Use /memory to list, view or forget entries.") | + color(theme().ui.text_secondary), + s.use_checkbox->Render(), + s.summary_checkbox->Render(), + text("Summary model") | bold | color(theme().ui.text_primary), + s.model_radio->Render(), + }; + if (!s.enabled) { + rows.push_back(paragraph("Memory is off: new sessions get no memory context or memory " + "tools, and summarization does not run. Entries stay on disk.") | + color(theme().semantic.warning)); + } + if (!s.status.empty()) { + rows.push_back(text(s.status) | + color(s.status_error ? theme().semantic.error : theme().semantic.success)); + } + return vbox(std::move(rows)); +} + +void MemorySettingsSection::sync_from_config() { state_->sync(); } + +} // namespace acecode::tui::settings diff --git a/src/apps/tui/settings/memory_settings_section.hpp b/src/apps/tui/settings/memory_settings_section.hpp new file mode 100644 index 000000000..fb086bd70 --- /dev/null +++ b/src/apps/tui/settings/memory_settings_section.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "config/config.hpp" + +#include +#include + +#include +#include + +namespace acecode::tui::settings { + +// 设置中心「个性化」页的记忆区(openspec unify-memory-system 7.4):使用记忆、记忆摘要 +// 开关与摘要模型(「当前模型」+ 已保存的模型)。每次改动立即经 set_memory_settings +// 写入 config.json —— 与网页 /api/config/memory 同一份配置 —— 再通过 published +// 回调让 TUI 把新值推给本进程的记忆运行时。条目浏览与删除在 TUI 里用 /memory。 +class MemorySettingsSection { +public: + MemorySettingsSection(AppConfig* config, std::function published); + ~MemorySettingsSection(); + MemorySettingsSection(const MemorySettingsSection&) = delete; + MemorySettingsSection& operator=(const MemorySettingsSection&) = delete; + + // 可聚焦的控件容器(挂进页面的 Container 里)。 + ftxui::Component component() const; + // 当前状态的渲染(标题、说明、控件、保存结果)。 + ftxui::Element render() const; + // 设置中心打开时按 config 刷新控件。 + void sync_from_config(); + + struct State; + +private: + std::shared_ptr state_; +}; + +} // namespace acecode::tui::settings diff --git a/src/apps/tui/settings/settings_center.cpp b/src/apps/tui/settings/settings_center.cpp index 79ca806dc..f2fd19a9c 100644 --- a/src/apps/tui/settings/settings_center.cpp +++ b/src/apps/tui/settings/settings_center.cpp @@ -5,6 +5,7 @@ #endif #include "settings_center.hpp" +#include "memory_settings_section.hpp" #include "permissions/permissions.hpp" #include "config/request_headers.hpp" #include "config/settings_mutations.hpp" @@ -528,6 +529,7 @@ struct SettingsCenter::Impl { std::string upgrade_url; std::string custom_instructions; + std::unique_ptr memory_section; // 个性化页的记忆区 std::string model_filter; std::vector model_entries; @@ -688,8 +690,8 @@ struct SettingsCenter::Impl { deps.config->tui.theme == "dark" ? 1 : deps.config->tui.theme == "light" ? 2 : 0; upgrade_url = deps.config->upgrade.base_url; - custom_instructions = - deps.config->custom_instructions.text_snapshot(); + custom_instructions = deps.config->custom_instructions.text_snapshot(); + if (memory_section) memory_section->sync_from_config(); rebuild_model_entries(); } @@ -743,13 +745,9 @@ struct SettingsCenter::Impl { set_status(result.error, true); return; } - if (value == "auto") { - set_status( - "Auto saved. Terminal background detection runs at next launch; " - "the current palette stays active."); - } else { - set_status("Theme switched to " + value + " and saved."); - } + set_status(value == "auto" ? std::string("Auto saved. Terminal background detection runs at " + "next launch; the current palette stays active.") + : "Theme switched to " + value + " and saved."); } bool save_current_editor() { @@ -1905,10 +1903,9 @@ struct SettingsCenter::Impl { " Save ", [this]() { save_current_editor(); }, ButtonOption::Animated()); + memory_section = std::make_unique(deps.config, deps.config_published); auto personalization_container = Container::Vertical({ - instructions_input, - instructions_save_button, - }); + instructions_input, instructions_save_button, memory_section->component()}); personalization_page = Renderer( personalization_container, [this]() { const std::size_t bytes = custom_instructions.size(); @@ -1933,6 +1930,7 @@ struct SettingsCenter::Impl { filler(), instructions_save_button->Render(), }), + memory_section->render(), }) | yframe | vscroll_indicator | flex; }); diff --git a/src/apps/web/handlers/builtin_command_handler.cpp b/src/apps/web/handlers/builtin_command_handler.cpp index ac87a740f..f0e118e59 100644 --- a/src/apps/web/handlers/builtin_command_handler.cpp +++ b/src/apps/web/handlers/builtin_command_handler.cpp @@ -78,7 +78,8 @@ bool is_supported_builtin_command(const std::string& name) { // command handler 由 daemon 宿主(worker.cpp 注册的 SessionChannelBinder) // 承接;这里只负责放行 HTTP 网关。 return name == "init" || name == "compact" || name == "goal" || name == "plan" || - name == "lsp" || name == "sandbox" || name == "rc" || name == "remote-control"; + name == "lsp" || name == "sandbox" || name == "memory" || name == "rc" || + name == "remote-control"; } BuiltinCommandHttpParseResult parse_builtin_command_request(const std::string& body) { diff --git a/src/apps/web/handlers/commands_handler.cpp b/src/apps/web/handlers/commands_handler.cpp index ade452288..a2f657f4d 100644 --- a/src/apps/web/handlers/commands_handler.cpp +++ b/src/apps/web/handlers/commands_handler.cpp @@ -79,6 +79,10 @@ nlohmann::json build_commands_payload(const SkillRegistry& global_skills, {"name", "sandbox"}, {"description", "Show the bash sandbox status, or /sandbox off|on for this session"}, }); + builtins.push_back({ + {"name", "memory"}, + {"description", "List, view, edit or forget memory; flush memory summarization; turn memory off/on for this session"}, + }); builtins.push_back({ {"name", "rc"}, {"description", "Alias for /remote-control"}, diff --git a/src/apps/web/handlers/memory_handler.cpp b/src/apps/web/handlers/memory_handler.cpp new file mode 100644 index 000000000..a25af5639 --- /dev/null +++ b/src/apps/web/handlers/memory_handler.cpp @@ -0,0 +1,151 @@ +#include "web/handlers/memory_handler.hpp" + +#include "utils/utf8_path.hpp" + +namespace acecode::web { + +using nlohmann::json; + +json memory_settings_json(const MemoryConfig& cfg, bool summary_available) { + return { + {"enabled", cfg.enabled}, + {"max_index_bytes", cfg.max_index_bytes}, + {"summary", { + {"enabled", cfg.summary.enabled}, + {"model_name", cfg.summary.model_name}, + {"idle_minutes", cfg.summary.idle_minutes}, + {"max_session_age_days", cfg.summary.max_session_age_days}, + }}, + {"summary_available", summary_available}, + }; +} + +bool parse_memory_settings_request(const json& body, const MemoryConfig& current, + MemoryConfig& out, std::string& error) { + if (!body.is_object()) { + error = "request body must be a JSON object"; + return false; + } + MemoryConfig next = current; + if (body.contains("enabled")) { + if (!body["enabled"].is_boolean()) { + error = "enabled must be a boolean"; + return false; + } + next.enabled = body["enabled"].get(); + } + if (body.contains("max_index_bytes")) { + if (!body["max_index_bytes"].is_number_integer() || body["max_index_bytes"].get() <= 0) { + error = "max_index_bytes must be a positive integer"; + return false; + } + next.max_index_bytes = static_cast(body["max_index_bytes"].get()); + } + if (body.contains("summary")) { + const auto& summary = body["summary"]; + if (!summary.is_object()) { + error = "summary must be an object"; + return false; + } + if (summary.contains("enabled")) { + if (!summary["enabled"].is_boolean()) { + error = "summary.enabled must be a boolean"; + return false; + } + next.summary.enabled = summary["enabled"].get(); + } + if (summary.contains("model_name")) { + if (!summary["model_name"].is_string()) { + error = "summary.model_name must be a string"; + return false; + } + next.summary.model_name = summary["model_name"].get(); + } + for (const char* key : {"idle_minutes", "max_session_age_days"}) { + if (!summary.contains(key)) continue; + if (!summary[key].is_number_integer()) { + error = std::string("summary.") + key + " must be an integer"; + return false; + } + (std::string(key) == "idle_minutes" ? next.summary.idle_minutes + : next.summary.max_session_age_days) = + summary[key].get(); + } + } + out = next; + return true; +} + +json memory_entry_json(MemoryScope scope, const MemoryEntry& entry, bool include_body) { + json out = { + {"scope", memory_scope_to_string(scope)}, + {"name", entry.name}, + {"description", entry.description}, + {"type", memory_type_to_string(entry.type)}, + {"created_at", entry.created_at}, + {"updated_at", entry.updated_at}, + {"source", entry.source.empty() ? std::string(kMemorySourceManual) : entry.source}, + {"source_sessions", entry.source_sessions}, + }; + if (include_body) { + out["body"] = entry.body; + out["path"] = path_to_utf8_generic(entry.path); + } + return out; +} + +json memory_overview_json(MemoryService& memory, const std::string& project_dir, + const MemorySummaryStatus& status) { + json scopes = json::object(); + const MemoryScope order[] = {MemoryScope::Global, MemoryScope::Workspace}; + for (MemoryScope scope : order) { + json item = {{"available", false}, {"dir", ""}, {"entries", json::array()}}; + if (auto registry = memory.scope(scope, project_dir)) { + registry->reload(); + item["available"] = true; + item["dir"] = path_to_utf8_generic(registry->dir()); + for (const auto& entry : registry->list()) { + item["entries"].push_back(memory_entry_json(scope, entry, false)); + } + } + scopes[memory_scope_to_string(scope)] = std::move(item); + } + return { + {"enabled", memory.enabled()}, + {"scopes", std::move(scopes)}, + {"status", { + {"summary_enabled", status.enabled}, + {"global_inbox", status.global_inbox}, + {"workspace_inbox", status.workspace_inbox}, + {"last_extraction_ms", status.last_extraction_ms}, + {"last_consolidation_ms", status.last_consolidation_ms}, + {"last_error", status.last_error}, + {"last_error_ms", status.last_error_ms}, + }}, + }; +} + +bool parse_memory_entry_edit(const json& body, MemoryEntryEdit& out, std::string& error) { + if (!body.is_object() || !body.contains("description") || !body["description"].is_string() || + !body.contains("body") || !body["body"].is_string()) { + error = "description and body must be strings"; + return false; + } + MemoryEntryEdit edit; + edit.description = body["description"].get(); + edit.body = body["body"].get(); + if (edit.description.find_first_not_of(" \t\r\n") == std::string::npos) { + error = "description must not be empty"; + return false; + } + if (body.contains("type")) { + if (!body["type"].is_string() || !(edit.type = parse_memory_type(body["type"].get()))) { + error = "type must be one of user|feedback|project|reference"; + return false; + } + } + out = std::move(edit); + return true; +} + +} // namespace acecode::web diff --git a/src/apps/web/handlers/memory_handler.hpp b/src/apps/web/handlers/memory_handler.hpp new file mode 100644 index 000000000..85590c368 --- /dev/null +++ b/src/apps/web/handlers/memory_handler.hpp @@ -0,0 +1,46 @@ +#pragma once + +// 记忆设置与条目管理(设置 > 个性化 > 记忆,openspec unify-memory-system)的 REST +// 纯函数层:请求解析与响应体构造。IO / 锁 / 下发在 routes_memory.cpp。 + +#include "config/config.hpp" +#include "memory/memory_service.hpp" +#include "memory/memory_types.hpp" +#include "session_host/memory_scheduler.hpp" + +#include + +#include +#include + +namespace acecode::web { + +// GET /api/config/memory 的响应体: +// {enabled, max_index_bytes, summary:{enabled, model_name, idle_minutes, max_session_age_days}, +// summary_available} +nlohmann::json memory_settings_json(const MemoryConfig& cfg, bool summary_available); + +// 解析 PUT body(patch 语义:缺省键沿用 current)。类型不对返回 false 并写 error; +// 取值范围与模型名由 settings_mutations::set_memory_settings 统一校验。 +bool parse_memory_settings_request(const nlohmann::json& body, const MemoryConfig& current, + MemoryConfig& out, std::string& error); + +// 单条条目:{scope, name, description, type, created_at, updated_at, source, +// source_sessions[, body, path]} +nlohmann::json memory_entry_json(MemoryScope scope, const MemoryEntry& entry, bool include_body); + +// GET /api/memory:两个作用域的条目(读前重扫磁盘)与记忆摘要状态。 +// project_dir 为空 = 没有选中工作区,workspace.available=false。 +nlohmann::json memory_overview_json(MemoryService& memory, const std::string& project_dir, + const MemorySummaryStatus& status); + +struct MemoryEntryEdit { + std::string description; + std::string body; + std::optional type; +}; + +// PUT /api/memory// body {description, body, type?}。 +bool parse_memory_entry_edit(const nlohmann::json& body, MemoryEntryEdit& out, std::string& error); + +} // namespace acecode::web diff --git a/src/apps/web/routes/routes_memory.cpp b/src/apps/web/routes/routes_memory.cpp new file mode 100644 index 000000000..67357d4ac --- /dev/null +++ b/src/apps/web/routes/routes_memory.cpp @@ -0,0 +1,167 @@ +#include "web/server_impl.hpp" +#include "web/handlers/memory_handler.hpp" + +#include "config/settings_mutations.hpp" +#include "session/session_storage.hpp" +#include "session_host/memory_runtime.hpp" +#include "session_host/memory_scheduler.hpp" + +namespace acecode::web { +using nlohmann::json; + +// 设置 > 个性化 > 记忆(openspec unify-memory-system)。 +// GET/PUT /api/config/memory 使用记忆 / 记忆摘要设置(config.json) +// GET /api/memory?workspace= 全局与该工作区的条目 + 记忆摘要状态 +// GET/PUT/DELETE /api/memory//?workspace= +// POST /api/memory/reset {scope, workspace} +// 工作区只能经已登记的 workspace hash 选择,客户端不能借此读写任意目录。编辑走 +// 存储层的脱敏与写锁并记为手写(source: manual);删除记墓碑。 +void WebServer::Impl::register_memory() { + const auto respond = [this](const crow::request& req, int status, const json& body) { + crow::response response(status); + response.add_header("Content-Type", "application/json"); + response.add_header("Cache-Control", "no-store"); + response.body = body.dump(); + return with_cors(req, std::move(response)); + }; + // 返回 nullopt = 给了 hash 但不是已登记的工作区;空串 = 没有选工作区。 + const auto project_dir_of = [this](const std::string& hash) -> std::optional { + if (hash.empty()) return std::string{}; + const auto workspace = resolve_workspace(hash); + if (!workspace) return std::nullopt; + return SessionStorage::get_project_dir(workspace->cwd); + }; + const auto query = [](const crow::request& req, const char* key) { + const char* value = req.url_params.get(key); + return std::string(value ? value : ""); + }; + + CROW_ROUTE(app, "/api/config/memory").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/config/memory").methods(crow::HTTPMethod::GET) + ([this, respond](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + std::shared_lock lock(app_config_mu); + return respond(req, 200, memory_settings_json(deps.app_config->memory, deps.memory != nullptr)); + }); + + CROW_ROUTE(app, "/api/config/memory").methods(crow::HTTPMethod::PUT) + ([this, respond](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.app_config) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + const auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) return respond(req, 400, {{"error", "BAD_JSON"}}); + std::lock_guard lock(app_config_mu); + MemoryConfig next; + std::string error; + if (!parse_memory_settings_request(body, deps.app_config->memory, next, error)) { + return respond(req, 400, {{"error", "BAD_REQUEST"}, {"message", error}}); + } + SettingsMutationOptions options; + options.config_path = deps.config_path; + options.live_config = deps.app_config; + const auto result = set_memory_settings(next, options); + if (!result.ok) { + const bool validation = result.error_kind == SettingsMutationErrorKind::Validation; + return respond(req, validation ? 400 : 500, + {{"error", validation ? "BAD_REQUEST" : "PERSIST_FAILED"}, + {"message", result.error}}); + } + // 本进程立即生效;其他 ACECode 进程由调度器按 config.json 修改时间重读。 + if (deps.memory) deps.memory->update_config(deps.app_config->memory); + return respond(req, 200, memory_settings_json(deps.app_config->memory, deps.memory != nullptr)); + }); + + CROW_ROUTE(app, "/api/memory").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/memory").methods(crow::HTTPMethod::GET) + ([this, respond, project_dir_of, query](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.memory) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + const auto project_dir = project_dir_of(query(req, "workspace")); + if (!project_dir) return respond(req, 404, {{"error", "UNKNOWN_WORKSPACE"}}); + auto& memory = *deps.memory->service(); + return respond(req, 200, memory_overview_json( + memory, *project_dir, read_memory_summary_status(memory, *project_dir))); + }); + + CROW_ROUTE(app, "/api/memory/reset").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return cors_preflight(req); }); + + CROW_ROUTE(app, "/api/memory/reset").methods(crow::HTTPMethod::POST) + ([this, respond, project_dir_of](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.memory) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + const auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded() || !body.is_object()) return respond(req, 400, {{"error", "BAD_JSON"}}); + const auto scope = parse_memory_scope(body.value("scope", std::string{})); + if (!scope) return respond(req, 400, {{"error", "BAD_REQUEST"}, {"message", "unknown scope"}}); + const auto project_dir = project_dir_of(body.value("workspace", std::string{})); + if (!project_dir) return respond(req, 404, {{"error", "UNKNOWN_WORKSPACE"}}); + std::string error; + if (!deps.memory->service()->reset_scope(*scope, *project_dir, error)) { + return respond(req, 400, {{"error", "RESET_FAILED"}, {"message", error}}); + } + return respond(req, 200, {{"ok", true}}); + }); + + CROW_ROUTE(app, "/api/memory//").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req, const std::string&, const std::string&) { + return cors_preflight(req); + }); + + CROW_ROUTE(app, "/api/memory//") + .methods(crow::HTTPMethod::GET, crow::HTTPMethod::PUT, crow::HTTPMethod::DELETE) + ([this, respond, project_dir_of, query](const crow::request& req, const std::string& scope_name, + const std::string& name) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + if (!deps.memory) return respond(req, 503, {{"error", "UNAVAILABLE"}}); + const auto scope = parse_memory_scope(scope_name); + if (!scope) return respond(req, 400, {{"error", "BAD_REQUEST"}, {"message", "unknown scope"}}); + const auto project_dir = project_dir_of(query(req, "workspace")); + if (!project_dir) return respond(req, 404, {{"error", "UNKNOWN_WORKSPACE"}}); + auto& memory = *deps.memory->service(); + auto registry = memory.scope(*scope, *project_dir); + if (!registry) return respond(req, 400, {{"error", "NO_WORKSPACE"}}); + registry->reload(); + + if (req.method == crow::HTTPMethod::DELETE) { + std::string error; + if (!memory.delete_entry(*scope, *project_dir, name, error)) { + return respond(req, 404, {{"error", "NOT_FOUND"}, {"message", error}}); + } + return respond(req, 200, {{"ok", true}}); + } + const auto existing = registry->find(name); + if (!existing) return respond(req, 404, {{"error", "NOT_FOUND"}}); + if (req.method == crow::HTTPMethod::GET) { + return respond(req, 200, memory_entry_json(*scope, *existing, true)); + } + + const auto body = json::parse(req.body, nullptr, false); + if (body.is_discarded()) return respond(req, 400, {{"error", "BAD_JSON"}}); + MemoryEntryEdit edit; + std::string error; + if (!parse_memory_entry_edit(body, edit, error)) { + return respond(req, 400, {{"error", "BAD_REQUEST"}, {"message", error}}); + } + MemoryWriteRequest request; + request.name = name; + request.type = edit.type.value_or(existing->type); + request.description = edit.description; + request.body = edit.body; + request.mode = MemoryWriteMode::Update; + request.source = kMemorySourceManual; // 用户亲手改过的条目,整合不得再动 + MemoryWriteOutcome outcome; + const auto written = registry->upsert(request, error, &outcome); + if (!written) return respond(req, 400, {{"error", "WRITE_FAILED"}, {"message", error}}); + json out = memory_entry_json(*scope, *written, true); + out["redactions"] = outcome.redactions; + return respond(req, 200, out); + }); +} + +} // namespace acecode::web diff --git a/src/apps/web/server.cpp b/src/apps/web/server.cpp index ba20fc843..322464a7f 100644 --- a/src/apps/web/server.cpp +++ b/src/apps/web/server.cpp @@ -142,6 +142,7 @@ void WebServer::Impl::register_routes() { register_computer_use(); register_summary_generation(); register_tool_rewrites(); + register_memory(); register_tool_preamble(); register_security(); register_experts(); diff --git a/src/apps/web/server.hpp b/src/apps/web/server.hpp index 116188d80..f39feed61 100644 --- a/src/apps/web/server.hpp +++ b/src/apps/web/server.hpp @@ -37,6 +37,7 @@ class PtySessionRegistry; class SessionClient; class SessionRegistry; class TaskSuggestionService; +class MemoryRuntime; class SkillRegistry; class SkillUsageStore; class ExpertRegistry; @@ -87,6 +88,8 @@ struct WebServerDeps { SessionClient* session_client = nullptr; SessionRegistry* session_registry = nullptr; std::shared_ptr task_suggestions; + // 记忆运行时(/api/config/memory、/api/memory*);为空时这些路由返回 503。 + std::shared_ptr memory; ExpertRegistry* expert_registry = nullptr; HookManager* hook_manager = nullptr; ToolExecutor* tools = nullptr; diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index 10c850ea0..849a9bf7e 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -604,6 +604,7 @@ struct WebServer::Impl { void register_computer_use(); void register_summary_generation(); void register_tool_rewrites(); + void register_memory(); void register_tool_preamble(); void register_security(); diff --git a/src/base/config/config.cpp b/src/base/config/config.cpp index b19f827e6..222b08a19 100644 --- a/src/base/config/config.cpp +++ b/src/base/config/config.cpp @@ -943,15 +943,7 @@ static AppConfig load_config_from_path_once( cfg.skills.idle_days = sj["idle_days"].get(); } } - if (j.contains("memory") && j["memory"].is_object()) { - const auto& mj = j["memory"]; - if (mj.contains("enabled") && mj["enabled"].is_boolean()) - cfg.memory.enabled = mj["enabled"].get(); - if (mj.contains("max_index_bytes") && mj["max_index_bytes"].is_number_integer()) { - long long v = mj["max_index_bytes"].get(); - if (v > 0) cfg.memory.max_index_bytes = static_cast(v); - } - } + if (j.contains("memory")) load_memory_config_json(j["memory"], cfg.memory); if (j.contains("project_instructions") && j["project_instructions"].is_object()) { const auto& pj = j["project_instructions"]; if (pj.contains("enabled") && pj["enabled"].is_boolean()) @@ -2326,13 +2318,7 @@ nlohmann::json build_config_json(const AppConfig& cfg) { web_uij["message_auto_collapse"] = cfg.web_ui.message_auto_collapse; if (!web_uij.empty()) j["web_ui"] = std::move(web_uij); - MemoryConfig mem_d; - nlohmann::json memj = nlohmann::json::object(); - if (cfg.memory.enabled != mem_d.enabled) - memj["enabled"] = cfg.memory.enabled; - if (cfg.memory.max_index_bytes != mem_d.max_index_bytes) - memj["max_index_bytes"] = cfg.memory.max_index_bytes; - if (!memj.empty()) j["memory"] = memj; + if (auto memj = memory_config_to_json(cfg.memory); !memj.empty()) j["memory"] = std::move(memj); ProjectInstructionsConfig pi_d; nlohmann::json pij = nlohmann::json::object(); diff --git a/src/base/config/config.hpp b/src/base/config/config.hpp index b2742e324..0f02882a2 100644 --- a/src/base/config/config.hpp +++ b/src/base/config/config.hpp @@ -82,13 +82,28 @@ struct SkillsConfig { std::optional> allowed; }; +// 记忆摘要(openspec unify-memory-system,默认关闭):会话闲置后用摘要模型 +// 提炼观察进收件箱,攒批整合成正式条目。 +struct MemorySummaryConfig { + bool enabled = false; + // 摘要模型(saved_models 名);空 = 「当前模型」(被提炼会话最后使用的模型)。 + std::string model_name; + int idle_minutes = 30; // 会话闲置多久后提炼,[5, 1440] + int max_session_age_days = 7; // 只处理这段时间内活动过的会话,[1, 90] +}; + struct MemoryConfig { bool enabled = true; - // Hard cap on MEMORY.md size for system-prompt injection. Oversized indexes - // are truncated in-memory with a marker; the on-disk file is untouched. - std::size_t max_index_bytes = 32 * 1024; + // Per-scope cap on the injected memory index (global and workspace each). + // Entries past the budget are omitted with a note; files are untouched. + std::size_t max_index_bytes = 8 * 1024; + MemorySummaryConfig summary; }; +// config.json 的 memory 段读写(memory_config.cpp)。写出时省略默认值。 +void load_memory_config_json(const nlohmann::json& j, MemoryConfig& out); +nlohmann::json memory_config_to_json(const MemoryConfig& cfg); + struct ProjectInstructionsConfig { bool enabled = true; int max_depth = 8; // max dirs walked from cwd towards HOME diff --git a/src/base/config/memory_config.cpp b/src/base/config/memory_config.cpp new file mode 100644 index 000000000..0eb903bfd --- /dev/null +++ b/src/base/config/memory_config.cpp @@ -0,0 +1,55 @@ +#include "config.hpp" + +#include + +namespace acecode { + +namespace { + +int clamp_int(int value, int lo, int hi) { + return std::max(lo, std::min(hi, value)); +} + +} // namespace + +void load_memory_config_json(const nlohmann::json& j, MemoryConfig& out) { + if (!j.is_object()) return; + if (j.contains("enabled") && j["enabled"].is_boolean()) out.enabled = j["enabled"].get(); + if (j.contains("max_index_bytes") && j["max_index_bytes"].is_number_integer()) { + const long long v = j["max_index_bytes"].get(); + if (v > 0) out.max_index_bytes = static_cast(v); + } + if (!j.contains("summary") || !j["summary"].is_object()) return; + const auto& sj = j["summary"]; + auto& summary = out.summary; + if (sj.contains("enabled") && sj["enabled"].is_boolean()) summary.enabled = sj["enabled"].get(); + if (sj.contains("model_name") && sj["model_name"].is_string()) { + summary.model_name = sj["model_name"].get(); + } + if (sj.contains("idle_minutes") && sj["idle_minutes"].is_number_integer()) { + summary.idle_minutes = clamp_int(sj["idle_minutes"].get(), 5, 1440); + } + if (sj.contains("max_session_age_days") && sj["max_session_age_days"].is_number_integer()) { + summary.max_session_age_days = clamp_int(sj["max_session_age_days"].get(), 1, 90); + } +} + +nlohmann::json memory_config_to_json(const MemoryConfig& cfg) { + const MemoryConfig d; + nlohmann::json out = nlohmann::json::object(); + if (cfg.enabled != d.enabled) out["enabled"] = cfg.enabled; + if (cfg.max_index_bytes != d.max_index_bytes) out["max_index_bytes"] = cfg.max_index_bytes; + nlohmann::json summary = nlohmann::json::object(); + if (cfg.summary.enabled != d.summary.enabled) summary["enabled"] = cfg.summary.enabled; + if (cfg.summary.model_name != d.summary.model_name) summary["model_name"] = cfg.summary.model_name; + if (cfg.summary.idle_minutes != d.summary.idle_minutes) { + summary["idle_minutes"] = cfg.summary.idle_minutes; + } + if (cfg.summary.max_session_age_days != d.summary.max_session_age_days) { + summary["max_session_age_days"] = cfg.summary.max_session_age_days; + } + if (!summary.empty()) out["summary"] = std::move(summary); + return out; +} + +} // namespace acecode diff --git a/src/base/config/settings_mutations.cpp b/src/base/config/settings_mutations.cpp index 052175b58..26b4a851c 100644 --- a/src/base/config/settings_mutations.cpp +++ b/src/base/config/settings_mutations.cpp @@ -246,6 +246,46 @@ SettingsMutationResult set_custom_instructions( options); } +SettingsMutationResult set_memory_settings( + const MemoryConfig& memory, + const SettingsMutationOptions& options) { + return run_mutation( + [memory](AppConfig& cfg, std::string& error) { + const auto& summary = memory.summary; + if (memory.max_index_bytes == 0 || memory.max_index_bytes > 1024 * 1024) { + error = "memory.max_index_bytes must be between 1 and 1048576"; + return false; + } + if (summary.idle_minutes < 5 || summary.idle_minutes > 1440) { + error = "memory.summary.idle_minutes must be between 5 and 1440"; + return false; + } + if (summary.max_session_age_days < 1 || summary.max_session_age_days > 90) { + error = "memory.summary.max_session_age_days must be between 1 and 90"; + return false; + } + if (!summary.model_name.empty() && + std::none_of(cfg.saved_models.begin(), cfg.saved_models.end(), + [&summary](const ModelProfile& profile) { + return profile.name == summary.model_name; + })) { + error = "unknown saved model: " + summary.model_name; + return false; + } + const auto& cur = cfg.memory; + if (cur.enabled == memory.enabled && cur.max_index_bytes == memory.max_index_bytes && + cur.summary.enabled == summary.enabled && + cur.summary.model_name == summary.model_name && + cur.summary.idle_minutes == summary.idle_minutes && + cur.summary.max_session_age_days == summary.max_session_age_days) { + return false; + } + cfg.memory = memory; + return true; + }, + options); +} + SettingsMutationResult add_saved_model_setting( const SavedModelDraft& draft, const SettingsMutationOptions& options) { diff --git a/src/base/config/settings_mutations.hpp b/src/base/config/settings_mutations.hpp index f8021025d..474ae2886 100644 --- a/src/base/config/settings_mutations.hpp +++ b/src/base/config/settings_mutations.hpp @@ -68,6 +68,12 @@ SettingsMutationResult set_custom_instructions( const std::string& text, const SettingsMutationOptions& options = {}); +// 记忆设置(个性化 > 记忆):网页 /api/config/memory 与 TUI 设置中心共用。 +// summary.model_name 非空时必须是已保存的模型名;数值字段越界即校验失败。 +SettingsMutationResult set_memory_settings( + const MemoryConfig& memory, + const SettingsMutationOptions& options = {}); + SettingsMutationResult add_saved_model_setting( const SavedModelDraft& draft, const SettingsMutationOptions& options = {}); diff --git a/src/domain/memory/memory_forget.cpp b/src/domain/memory/memory_forget.cpp new file mode 100644 index 000000000..d80bc2c98 --- /dev/null +++ b/src/domain/memory/memory_forget.cpp @@ -0,0 +1,59 @@ +#include "memory_forget.hpp" + +#include "memory_inbox.hpp" +#include "memory_service.hpp" + +#include "utils/logger.hpp" + +#include + +namespace acecode { + +MemoryForgetResult forget_memory_session(MemoryService& memory, const std::string& session_id, + const std::string& project_dir) { + MemoryForgetResult result; + if (session_id.empty()) return result; + const std::shared_ptr scopes[] = { + memory.scope(MemoryScope::Global, project_dir), + memory.scope(MemoryScope::Workspace, project_dir), + }; + for (const auto& registry : scopes) { + if (!registry) continue; + result.observations_removed += forget_memory_session_observations(registry->dir(), session_id); + registry->reload(); + for (const auto& entry : registry->list()) { + if (entry.source != kMemorySourceSummary) continue; // 手写条目不受影响 + auto sessions = entry.source_sessions; + const auto it = std::find(sessions.begin(), sessions.end(), session_id); + if (it == sessions.end()) continue; + sessions.erase(it); + std::string error; + if (sessions.empty()) { + if (registry->remove(entry.name, error)) { + memory.state().add_tombstone(registry->scope_key(), entry.name, + entry.description, memory_now_ms()); + ++result.entries_deleted; + } + } else { + MemoryWriteRequest request; + request.name = entry.name; + request.type = entry.type; + request.description = entry.description; + request.body = entry.body; + request.mode = MemoryWriteMode::Update; + request.source = kMemorySourceSummary; + request.source_sessions = sessions; + request.replace_source_sessions = true; + request.now_iso = entry.updated_at; // 只改来源,不算内容更新 + if (registry->upsert(request, error)) ++result.entries_updated; + } + if (!error.empty()) { + LOG_WARN("[memory] forget " + session_id + " failed on " + entry.name + ": " + error); + } + } + } + memory.state().forget_extraction(session_id); + return result; +} + +} // namespace acecode diff --git a/src/domain/memory/memory_forget.hpp b/src/domain/memory/memory_forget.hpp new file mode 100644 index 000000000..467735de7 --- /dev/null +++ b/src/domain/memory/memory_forget.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace acecode { + +class MemoryService; + +struct MemoryForgetResult { + int observations_removed = 0; // inbox / archive 中删除的观察条数 + int entries_updated = 0; // 只移除了该会话来源的摘要条目 + int entries_deleted = 0; // 失去全部来源而删除(并记墓碑)的摘要条目 +}; + +// 会话被永久删除后撤回它对记忆的贡献(openspec unify-memory-system D14): +// 删除该会话在全局与该工作区 inbox / archive 中的观察;source: summary 的条目 +// 从 source_sessions 里移除该会话,没有来源了就删除并记墓碑;手写条目不动; +// 清掉该会话的提炼进度。project_dir 为空时只处理全局作用域。 +MemoryForgetResult forget_memory_session(MemoryService& memory, + const std::string& session_id, + const std::string& project_dir); + +} // namespace acecode diff --git a/src/domain/memory/memory_frontmatter.cpp b/src/domain/memory/memory_frontmatter.cpp index fb151bccb..8993d1a5b 100644 --- a/src/domain/memory/memory_frontmatter.cpp +++ b/src/domain/memory/memory_frontmatter.cpp @@ -5,8 +5,12 @@ #include "utils/logger.hpp" #include "utils/utf8_path.hpp" +#include +#include +#include #include #include +#include namespace fs = std::filesystem; @@ -22,43 +26,196 @@ std::string read_file_to_string(const fs::path& path) { return ensure_utf8(oss.str()); } -// Escape a scalar string for the rendered frontmatter. Quotes are minimal: -// always emit double-quoted values so round-tripping strings with colons or -// leading/trailing whitespace stays unambiguous. +// Escape a scalar string for the rendered frontmatter. Always double-quoted so +// values with colons or leading/trailing whitespace stay unambiguous; the +// reader below undoes exactly these escapes. std::string render_scalar(const std::string& s) { std::string out; out.reserve(s.size() + 2); out.push_back('"'); for (char c : s) { - if (c == '\\' || c == '"') out.push_back('\\'); - out.push_back(c); + if (c == '\\' || c == '"') { + out.push_back('\\'); + out.push_back(c); + } else if (c == '\n') { + out += "\\n"; + } else if (c == '\r') { + continue; + } else { + out.push_back(c); + } } out.push_back('"'); return out; } +std::string strip(const std::string& s) { + std::size_t a = 0; + std::size_t b = s.size(); + while (a < b && (s[a] == ' ' || s[a] == '\t' || s[a] == '\r')) ++a; + while (b > a && (s[b - 1] == ' ' || s[b - 1] == '\t' || s[b - 1] == '\r')) --b; + return s.substr(a, b - a); +} + +// 读回 render_scalar 写出的双引号值;单引号按 YAML 规则把 '' 还原为 '。 +// 块标量(| / >)等复杂形态返回 nullopt,交给通用 frontmatter 解析器。 +std::optional parse_raw_scalar(const std::string& raw) { + const std::string value = strip(raw); + if (value.empty()) return std::string{}; + if (value[0] == '|' || value[0] == '>' || value[0] == '[') return std::nullopt; + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + std::string out; + for (std::size_t i = 1; i + 1 < value.size(); ++i) { + const char c = value[i]; + if (c == '\\' && i + 2 < value.size()) { + const char next = value[++i]; + if (next == 'n') out.push_back('\n'); + else if (next == 't') out.push_back('\t'); + else out.push_back(next); + continue; + } + out.push_back(c); + } + return out; + } + if (value.size() >= 2 && value.front() == '\'' && value.back() == '\'') { + std::string out; + for (std::size_t i = 1; i + 1 < value.size(); ++i) { + out.push_back(value[i]); + if (value[i] == '\'' && i + 2 < value.size() && value[i + 1] == '\'') ++i; + } + return out; + } + return value; +} + +bool is_known_key(const std::string& key) { + return key == "name" || key == "description" || key == "type" || + key == "created_at" || key == "updated_at" || key == "source" || + key == "source_sessions"; +} + +// 顶层 `key:` 行(无缩进)的键名;不是映射行返回空串。 +std::string top_level_key(const std::string& line) { + if (line.empty() || line[0] == ' ' || line[0] == '\t' || line[0] == '#' || line[0] == '-') { + return {}; + } + const std::size_t colon = line.find(':'); + if (colon == std::string::npos || colon == 0) return {}; + for (std::size_t i = 0; i < colon; ++i) { + const auto c = static_cast(line[i]); + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (!ok) return {}; + } + return line.substr(0, colon); +} + +struct RawFrontmatter { + std::vector> known; // 已知键 → 首行的原始值 + std::string extra; // 未知键的原始行 +}; + +// 按顶层键切分 frontmatter 原文:已知键记下首行值,未知键连同其缩进续行 / +// 列表项原样收进 extra。frontmatter 不闭合时返回 nullopt。 +std::optional split_raw_frontmatter(const std::string& content) { + if (content.compare(0, 3, "---") != 0) return std::nullopt; + std::size_t pos = content.find('\n'); + if (pos == std::string::npos) return std::nullopt; + ++pos; + RawFrontmatter out; + bool in_extra = false; + while (pos < content.size()) { + std::size_t end = content.find('\n', pos); + if (end == std::string::npos) end = content.size(); + std::string line = content.substr(pos, end - pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + pos = end + 1; + if (line == "---") return out; + const std::string key = top_level_key(line); + if (!key.empty()) { + in_extra = !is_known_key(key); + if (!in_extra) { + out.known.emplace_back(key, line.substr(key.size() + 1)); + continue; + } + } + if (in_extra) out.extra += line + "\n"; + } + return std::nullopt; +} + +std::string format_iso8601(std::time_t t) { + std::tm tm{}; +#ifdef _WIN32 + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buf; +} + } // namespace -std::optional parse_memory_entry_file(const fs::path& path) { - std::error_code ec; - if (!fs::is_regular_file(path, ec) || ec) { +std::string memory_now_iso8601() { + return format_iso8601(std::chrono::system_clock::to_time_t(std::chrono::system_clock::now())); +} + +std::optional parse_memory_iso8601(const std::string& text) { + int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0; + if (std::sscanf(text.c_str(), "%4d-%2d-%2dT%2d:%2d:%2d", &y, &mo, &d, &h, &mi, &s) != 6) { return std::nullopt; } + if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) return std::nullopt; + // days_from_civil(Howard Hinnant):与时区无关地把 UTC 日期换算成天数。 + const int yy = y - (mo <= 2 ? 1 : 0); + const int era = (yy >= 0 ? yy : yy - 399) / 400; + const unsigned yoe = static_cast(yy - era * 400); + const unsigned doy = (153u * static_cast(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + + static_cast(d) - 1u; + const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; + const long long days = static_cast(era) * 146097LL + static_cast(doe) - 719468LL; + return days * 86400LL + h * 3600LL + mi * 60LL + s; +} + +std::string memory_file_mtime_iso8601(const fs::path& path) { + std::error_code ec; + const auto ftime = fs::last_write_time(path, ec); + if (ec) return {}; + const auto system_time = std::chrono::time_point_cast( + ftime - fs::file_time_type::clock::now() + std::chrono::system_clock::now()); + return format_iso8601(std::chrono::system_clock::to_time_t(system_time)); +} - std::string content = read_file_to_string(path); +std::optional parse_memory_entry_text(const std::string& content, + const fs::path& path) { if (content.empty()) { LOG_WARN("[memory] entry file is empty or unreadable: " + path_to_utf8(path)); return std::nullopt; } - auto [fm, body] = parse_frontmatter(content); + auto parsed_fm = parse_frontmatter(content); + const Frontmatter& fm = parsed_fm.first; + std::string body = std::move(parsed_fm.second); if (fm.empty()) { LOG_WARN("[memory] entry missing frontmatter: " + path_to_utf8(path)); return std::nullopt; } + const auto raw = split_raw_frontmatter(content); + auto scalar = [&](const char* key) { + if (raw) { + for (const auto& [k, v] : raw->known) { + if (k != key) continue; + if (auto parsed = parse_raw_scalar(v)) return *parsed; + } + } + return get_string(fm, key, ""); + }; - std::string desc = get_string(fm, "description", ""); - std::string type_s = get_string(fm, "type", ""); + std::string desc = scalar("description"); + std::string type_s = scalar("type"); if (desc.empty() || type_s.empty()) { LOG_WARN("[memory] entry missing required field (description/type): " + path_to_utf8(path)); @@ -79,17 +236,47 @@ std::optional parse_memory_entry_file(const fs::path& path) { entry.description = desc; entry.type = *parsed_type; entry.path = path; + // render_memory_entry 在 frontmatter 与正文之间固定写一个空行;读回时去掉, + // 否则每次系统改写(补来源字段、整合 update)都会在正文前多攒一个空行。 + if (body.compare(0, 2, "\r\n") == 0) body.erase(0, 2); + else if (!body.empty() && body.front() == '\n') body.erase(0, 1); entry.body = body; + entry.created_at = scalar("created_at"); + entry.updated_at = scalar("updated_at"); + entry.source = scalar("source"); + entry.source_sessions = get_list(fm, "source_sessions"); + if (raw) entry.extra_frontmatter = raw->extra; return entry; } +std::optional parse_memory_entry_file(const fs::path& path) { + std::error_code ec; + if (!fs::is_regular_file(path, ec) || ec) { + return std::nullopt; + } + return parse_memory_entry_text(read_file_to_string(path), path); +} + std::string render_memory_entry(const MemoryEntry& entry) { std::ostringstream oss; oss << "---\n" << "name: " << render_scalar(entry.name) << "\n" << "description: " << render_scalar(entry.description) << "\n" - << "type: " << memory_type_to_string(entry.type) << "\n" - << "---\n\n" + << "type: " << memory_type_to_string(entry.type) << "\n"; + if (!entry.created_at.empty()) oss << "created_at: " << entry.created_at << "\n"; + if (!entry.updated_at.empty()) oss << "updated_at: " << entry.updated_at << "\n"; + if (!entry.source.empty()) oss << "source: " << entry.source << "\n"; + if (!entry.source_sessions.empty()) { + oss << "source_sessions: ["; + for (std::size_t i = 0; i < entry.source_sessions.size(); ++i) { + if (i) oss << ", "; + oss << render_scalar(entry.source_sessions[i]); + } + oss << "]\n"; + } + oss << entry.extra_frontmatter; + if (!entry.extra_frontmatter.empty() && entry.extra_frontmatter.back() != '\n') oss << "\n"; + oss << "---\n\n" << entry.body; // Ensure a trailing newline so tools like `git diff` stay sane. if (entry.body.empty() || entry.body.back() != '\n') { diff --git a/src/domain/memory/memory_frontmatter.hpp b/src/domain/memory/memory_frontmatter.hpp index a6a154a0b..555343351 100644 --- a/src/domain/memory/memory_frontmatter.hpp +++ b/src/domain/memory/memory_frontmatter.hpp @@ -8,14 +8,31 @@ namespace acecode { -// Parse a memory entry file (MEMORY_.md or similar). Returns nullopt -// when frontmatter is unusable, a required field is missing, or `type` is -// unknown. The returned MemoryEntry.path is set to `path`, name is derived -// from filename (not frontmatter) so on-disk and in-memory names stay in sync. +// Parse a memory entry file (.md). Returns nullopt when frontmatter is +// unusable, a required field is missing, or `type` is unknown. The returned +// MemoryEntry.path is set to `path`, name is derived from filename (not +// frontmatter) so on-disk and in-memory names stay in sync. Provenance fields +// (created_at / updated_at / source / source_sessions) are optional; unknown +// top-level fields are captured verbatim in `extra_frontmatter`. std::optional parse_memory_entry_file(const std::filesystem::path& path); -// Render a memory entry back to disk format: frontmatter + two-line separator -// + body. Used by memory_write for atomic rewrite. +// Same as parse_memory_entry_file but over in-memory text (path only names the +// entry). Used by tests and by the consolidation rollback snapshot. +std::optional parse_memory_entry_text(const std::string& content, + const std::filesystem::path& path); + +// Render a memory entry back to disk format: frontmatter + blank line + body. +// Provenance fields are written only when non-empty; `extra_frontmatter` is +// appended unchanged so fields added by users or other tools survive. std::string render_memory_entry(const MemoryEntry& entry); +// 当前 UTC 时间的 ISO-8601 文本(秒精度,`Z` 结尾)。 +std::string memory_now_iso8601(); + +// 把 ISO-8601(memory_now_iso8601 的格式)解析为距 epoch 的秒;失败返回 nullopt。 +std::optional parse_memory_iso8601(const std::string& text); + +// 文件最后修改时间的 ISO-8601 文本;取不到时返回空串。旧条目补 created_at 用。 +std::string memory_file_mtime_iso8601(const std::filesystem::path& path); + } // namespace acecode diff --git a/src/domain/memory/memory_inbox.cpp b/src/domain/memory/memory_inbox.cpp new file mode 100644 index 000000000..594d70c1c --- /dev/null +++ b/src/domain/memory/memory_inbox.cpp @@ -0,0 +1,223 @@ +#include "memory_inbox.hpp" + +#include "memory_frontmatter.hpp" +#include "memory_paths.hpp" + +#include "utils/encoding.hpp" +#include "utils/logger.hpp" +#include "utils/utf8_path.hpp" + +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace acecode { + +namespace { + +std::string read_text(const fs::path& path) { + std::ifstream ifs(path, std::ios::binary); + if (!ifs.is_open()) return {}; + std::ostringstream oss; + oss << ifs.rdbuf(); + return ensure_utf8(oss.str()); +} + +bool write_atomic(const fs::path& target, const std::string& content, std::string* error) { + std::error_code ec; + fs::create_directories(target.parent_path(), ec); + if (ec) { + if (error) *error = "cannot create " + path_to_utf8(target.parent_path()) + ": " + ec.message(); + return false; + } + std::random_device rd; + fs::path tmp = target; + tmp += ".tmp-" + std::to_string(std::mt19937_64(rd())()); + { + std::ofstream ofs(tmp, std::ios::binary | std::ios::trunc); + ofs.write(content.data(), static_cast(content.size())); + if (!ofs) { + if (error) *error = "cannot write " + path_to_utf8(tmp); + return false; + } + } + fs::rename(tmp, target, ec); + if (ec) { + fs::remove(tmp, ec); + if (error) *error = "cannot rename observation file: " + ec.message(); + return false; + } + return true; +} + +std::optional read_observation_file(const fs::path& path) { + try { + const auto j = nlohmann::json::parse(read_text(path)); + MemoryObservationFile file; + file.path = path; + file.session_id = j.value("session_id", std::string{}); + file.from = j.value("from", static_cast(0)); + file.to = j.value("to", static_cast(0)); + file.created_at = j.value("created_at", std::string{}); + file.model = j.value("model", std::string{}); + if (j.contains("observations") && j["observations"].is_array()) { + for (const auto& item : j["observations"]) { + MemoryObservation obs; + obs.id = item.value("id", std::string{}); + obs.type = parse_memory_type(item.value("type", std::string{})).value_or(MemoryType::User); + obs.title = item.value("title", std::string{}); + obs.statement = item.value("statement", std::string{}); + file.observations.push_back(std::move(obs)); + } + } + return file; + } catch (const std::exception& e) { + LOG_WARN("[memory] unreadable observation file " + path_to_utf8(path) + ": " + e.what()); + return std::nullopt; + } +} + +std::vector json_files_in(const fs::path& dir) { + std::vector out; + std::error_code ec; + if (!fs::is_directory(dir, ec)) return out; + for (auto it = fs::directory_iterator(dir, ec); !ec && it != fs::directory_iterator(); + it.increment(ec)) { + std::error_code type_ec; + if (fs::is_regular_file(it->path(), type_ec) && it->path().extension() == ".json") { + out.push_back(it->path()); + } + } + return out; +} + +} // namespace + +std::string memory_observation_file_stem(const std::string& session_id, + std::int64_t from, std::int64_t to) { + return session_id + "-" + std::to_string(from) + "-" + std::to_string(to); +} + +bool write_memory_observation_file(const fs::path& scope_dir, MemoryObservationFile& file, + std::string* error) { + const std::string stem = memory_observation_file_stem(file.session_id, file.from, file.to); + file.path = memory_inbox_dir(scope_dir) / (stem + ".json"); + if (file.created_at.empty()) file.created_at = memory_now_iso8601(); + nlohmann::json observations = nlohmann::json::array(); + for (std::size_t i = 0; i < file.observations.size(); ++i) { + auto& obs = file.observations[i]; + obs.id = stem + "#" + std::to_string(i); + observations.push_back({ + {"id", obs.id}, + {"type", memory_type_to_string(obs.type)}, + {"title", obs.title}, + {"statement", obs.statement}, + }); + } + const nlohmann::json j = { + {"version", 1}, + {"session_id", file.session_id}, + {"from", file.from}, + {"to", file.to}, + {"created_at", file.created_at}, + {"model", file.model}, + {"observations", std::move(observations)}, + }; + return write_atomic(file.path, j.dump(2), error); +} + +void remove_memory_observation_file(const fs::path& scope_dir, const std::string& session_id, + std::int64_t from, std::int64_t to) { + std::error_code ec; + fs::remove(memory_inbox_dir(scope_dir) / + (memory_observation_file_stem(session_id, from, to) + ".json"), ec); +} + +std::vector list_memory_inbox(const fs::path& scope_dir) { + std::vector out; + for (const auto& path : json_files_in(memory_inbox_dir(scope_dir))) { + if (auto file = read_observation_file(path)) out.push_back(std::move(*file)); + } + std::sort(out.begin(), out.end(), [](const MemoryObservationFile& a, const MemoryObservationFile& b) { + if (a.created_at != b.created_at) return a.created_at < b.created_at; + return a.path.filename() < b.path.filename(); + }); + return out; +} + +std::size_t count_memory_inbox_observations(const fs::path& scope_dir) { + std::size_t total = 0; + for (const auto& file : list_memory_inbox(scope_dir)) total += file.observations.size(); + return total; +} + +bool archive_memory_observation_files(const fs::path& scope_dir, + const std::vector& files, + const std::string& date, std::string* error) { + const fs::path target_dir = memory_archive_dir(scope_dir) / date; + std::error_code ec; + fs::create_directories(target_dir, ec); + if (ec) { + if (error) *error = "cannot create archive directory: " + ec.message(); + return false; + } + for (const auto& file : files) { + std::error_code exists_ec; + if (!fs::exists(file, exists_ec)) continue; // 已归档(重放) + fs::rename(file, target_dir / file.filename(), ec); + if (ec) { + if (error) *error = "cannot archive " + path_to_utf8(file) + ": " + ec.message(); + return false; + } + } + return true; +} + +int forget_memory_session_observations(const fs::path& scope_dir, const std::string& session_id) { + int removed = 0; + std::vector candidates = json_files_in(memory_inbox_dir(scope_dir)); + std::error_code ec; + const fs::path archive = memory_archive_dir(scope_dir); + if (fs::is_directory(archive, ec)) { + for (auto it = fs::directory_iterator(archive, ec); !ec && it != fs::directory_iterator(); + it.increment(ec)) { + for (const auto& path : json_files_in(it->path())) candidates.push_back(path); + } + } + for (const auto& path : candidates) { + const auto file = read_observation_file(path); + if (!file || file->session_id != session_id) continue; + std::error_code remove_ec; + if (fs::remove(path, remove_ec)) removed += static_cast(file->observations.size()); + } + return removed; +} + +int cleanup_memory_archive(const fs::path& scope_dir, std::int64_t now_seconds, int keep_days) { + int removed = 0; + std::error_code ec; + const fs::path archive = memory_archive_dir(scope_dir); + if (!fs::is_directory(archive, ec)) return 0; + std::vector expired; + for (auto it = fs::directory_iterator(archive, ec); !ec && it != fs::directory_iterator(); + it.increment(ec)) { + const auto day = parse_memory_iso8601(path_to_utf8(it->path().filename()) + "T00:00:00Z"); + if (!day) continue; + if (now_seconds - *day > static_cast(keep_days) * 86400) { + expired.push_back(it->path()); + } + } + for (const auto& dir : expired) { + std::error_code remove_ec; + fs::remove_all(dir, remove_ec); + if (!remove_ec) ++removed; + } + return removed; +} + +} // namespace acecode diff --git a/src/domain/memory/memory_inbox.hpp b/src/domain/memory/memory_inbox.hpp new file mode 100644 index 000000000..8c42ccdef --- /dev/null +++ b/src/domain/memory/memory_inbox.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "memory_types.hpp" + +#include +#include +#include +#include +#include + +namespace acecode { + +// 记忆摘要的「观察」:提炼阶段从一段会话里得到的一条候选记忆,先进作用域的 +// inbox/,攒批整合后整体移进 archive/<日期>/。 +struct MemoryObservation { + std::string id; // <文件名>#<序号>,整合计划用它引用依据 + MemoryType type = MemoryType::User; + std::string title; + std::string statement; +}; + +// 一次提炼在一个作用域里产出的观察,存成一个文件: +// inbox/--.json。同一范围重放会覆盖同名文件,天然幂等。 +struct MemoryObservationFile { + std::filesystem::path path; + std::string session_id; + std::int64_t from = 0; // 消息范围 [from, to) + std::int64_t to = 0; + std::string created_at; // UTC ISO-8601 + std::string model; // 提炼用的模型(只作展示) + std::vector observations; +}; + +std::string memory_observation_file_stem(const std::string& session_id, + std::int64_t from, std::int64_t to); + +// 原子写入 inbox/.json;观察 id 按文件名与序号重新编号。 +bool write_memory_observation_file(const std::filesystem::path& scope_dir, + MemoryObservationFile& file, + std::string* error = nullptr); + +// 删除某会话某范围的观察文件(重放前清掉上次可能残留的另一作用域文件)。 +void remove_memory_observation_file(const std::filesystem::path& scope_dir, + const std::string& session_id, + std::int64_t from, std::int64_t to); + +// inbox 里的全部观察文件,按 created_at、文件名升序(最早的在前)。 +std::vector list_memory_inbox(const std::filesystem::path& scope_dir); + +std::size_t count_memory_inbox_observations(const std::filesystem::path& scope_dir); + +// 把一批观察文件移进 archive//(date 形如 2026-10-01)。已不在 inbox 的 +// 文件视为已归档(崩溃后重放幂等)。 +bool archive_memory_observation_files(const std::filesystem::path& scope_dir, + const std::vector& files, + const std::string& date, + std::string* error = nullptr); + +// 删除某会话在 inbox 与 archive 中的全部观察,返回删除的观察条数。 +int forget_memory_session_observations(const std::filesystem::path& scope_dir, + const std::string& session_id); + +// 删除 archive/ 下早于 keep_days 天的日期目录,返回删除的目录数。 +int cleanup_memory_archive(const std::filesystem::path& scope_dir, + std::int64_t now_seconds, int keep_days = 30); + +} // namespace acecode diff --git a/src/domain/memory/memory_paths.cpp b/src/domain/memory/memory_paths.cpp index a3b09345f..a7e4225c4 100644 --- a/src/domain/memory/memory_paths.cpp +++ b/src/domain/memory/memory_paths.cpp @@ -17,6 +17,23 @@ fs::path get_memory_index_path() { return get_memory_dir() / "MEMORY.md"; } +fs::path get_memory_state_db_path() { + return get_memory_dir() / "state.sqlite3"; +} + +fs::path workspace_memory_dir(const std::string& project_dir_utf8) { + if (project_dir_utf8.empty()) return {}; + return path_from_utf8(project_dir_utf8) / "memory"; +} + +fs::path memory_inbox_dir(const fs::path& scope_dir) { + return scope_dir / "inbox"; +} + +fs::path memory_archive_dir(const fs::path& scope_dir) { + return scope_dir / "archive"; +} + std::string validate_memory_name(const std::string& name) { if (name.empty()) return "memory name is empty"; if (name.size() > 64) return "memory name exceeds 64 bytes: " + name; @@ -43,24 +60,29 @@ std::string validate_memory_name(const std::string& name) { } fs::path resolve_memory_entry_path(const std::string& name) { - if (!validate_memory_name(name).empty()) return {}; - return get_memory_dir() / (name + ".md"); + return resolve_memory_entry_path_in(get_memory_dir(), name); } -bool is_within_memory_dir(const fs::path& path) { +fs::path resolve_memory_entry_path_in(const fs::path& dir, const std::string& name) { + if (dir.empty() || !validate_memory_name(name).empty()) return {}; + return dir / (name + ".md"); +} + +bool is_within_directory(const fs::path& path, const fs::path& dir) { + if (path.empty() || dir.empty()) return false; std::error_code ec; fs::path canonical_target = fs::weakly_canonical(path, ec); if (ec) canonical_target = path; - fs::path canonical_memory = fs::weakly_canonical(get_memory_dir(), ec); - if (ec) canonical_memory = get_memory_dir(); + fs::path canonical_dir = fs::weakly_canonical(dir, ec); + if (ec) canonical_dir = dir; // Compare as generic (forward-slash) strings case-insensitively on Windows, // case-sensitively elsewhere. A simple lexical prefix check is enough here // because weakly_canonical has already resolved .. and symlinks. std::string target_s = path_to_utf8_generic(canonical_target); - std::string memory_s = path_to_utf8_generic(canonical_memory); - while (!memory_s.empty() && memory_s.back() == '/') memory_s.pop_back(); + std::string dir_s = path_to_utf8_generic(canonical_dir); + while (!dir_s.empty() && dir_s.back() == '/') dir_s.pop_back(); #ifdef _WIN32 auto to_lower = [](std::string s) { @@ -68,13 +90,17 @@ bool is_within_memory_dir(const fs::path& path) { return s; }; target_s = to_lower(target_s); - memory_s = to_lower(memory_s); + dir_s = to_lower(dir_s); #endif - if (target_s.size() <= memory_s.size()) return false; - if (target_s.compare(0, memory_s.size(), memory_s) != 0) return false; + if (target_s.size() <= dir_s.size()) return false; + if (target_s.compare(0, dir_s.size(), dir_s) != 0) return false; // Guard against prefix matches like `memory_foo` vs `memory`. - return target_s[memory_s.size()] == '/'; + return target_s[dir_s.size()] == '/'; +} + +bool is_within_memory_dir(const fs::path& path) { + return is_within_directory(path, get_memory_dir()); } } // namespace acecode diff --git a/src/domain/memory/memory_paths.hpp b/src/domain/memory/memory_paths.hpp index 1d35ccd15..876e05add 100644 --- a/src/domain/memory/memory_paths.hpp +++ b/src/domain/memory/memory_paths.hpp @@ -5,24 +5,42 @@ namespace acecode { -// Absolute path to ~/.acecode/memory/ (computed from get_acecode_dir()). -// Does NOT create the directory. +// Absolute path to the global scope directory /memory/ +// (computed from get_acecode_dir()). Does NOT create the directory. std::filesystem::path get_memory_dir(); -// Absolute path to ~/.acecode/memory/MEMORY.md (index file). +// Absolute path to the global scope index /memory/MEMORY.md. std::filesystem::path get_memory_index_path(); +// 所有进程共用的记忆状态库 /memory/state.sqlite3。 +std::filesystem::path get_memory_state_db_path(); + +// 工作区作用域目录 = 会话项目目录(/projects/)下的 memory/。 +// project_dir 是 UTF-8 字符串(与 SessionManager::current_project_dir 同源)。 +std::filesystem::path workspace_memory_dir(const std::string& project_dir_utf8); + +// 作用域目录下的保留子目录:待整合观察与已整合观察。 +std::filesystem::path memory_inbox_dir(const std::filesystem::path& scope_dir); +std::filesystem::path memory_archive_dir(const std::filesystem::path& scope_dir); + // Reject names that would escape the memory directory or include separators. // Returns empty string on success; otherwise a rejection reason. std::string validate_memory_name(const std::string& name); -// Resolve .md inside the memory directory. Returns the absolute path -// on success. On failure (bad name or path escapes memory dir after symlink -// resolution) returns empty path; callers should treat as validation error. +// Resolve .md inside the global memory directory. Returns the absolute +// path on success; empty path for an invalid name. std::filesystem::path resolve_memory_entry_path(const std::string& name); +// Resolve .md inside an arbitrary scope directory. +std::filesystem::path resolve_memory_entry_path_in(const std::filesystem::path& dir, + const std::string& name); + +// True iff `path` resolves (after weakly_canonical, so symlinks and `..` are +// followed) to an entry strictly inside `dir`. +bool is_within_directory(const std::filesystem::path& path, + const std::filesystem::path& dir); + // True iff `path` resolves to a file strictly inside get_memory_dir(). -// Used by memory_write as a final guard after name sanitization. bool is_within_memory_dir(const std::filesystem::path& path); } // namespace acecode diff --git a/src/domain/memory/memory_registry.cpp b/src/domain/memory/memory_registry.cpp index 3319f8bd1..084e5145f 100644 --- a/src/domain/memory/memory_registry.cpp +++ b/src/domain/memory/memory_registry.cpp @@ -3,6 +3,8 @@ #include "memory_frontmatter.hpp" #include "memory_index.hpp" #include "memory_paths.hpp" +#include "memory_state_store.hpp" +#include "secret_redaction.hpp" #include "utils/encoding.hpp" #include "utils/logger.hpp" @@ -56,44 +58,61 @@ std::string atomic_write(const fs::path& target, const std::string& content) { return {}; } +bool is_index_stem(const fs::path& p) { + std::string stem = path_to_utf8(p.stem()); + std::transform(stem.begin(), stem.end(), stem.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return stem == "memory"; +} + +void sort_entries(std::vector& entries) { + std::sort(entries.begin(), entries.end(), + [](const MemoryEntry& a, const MemoryEntry& b) { return a.name < b.name; }); +} + } // namespace +MemoryRegistry::MemoryRegistry() : MemoryRegistry(get_memory_dir()) {} + +MemoryRegistry::MemoryRegistry(fs::path dir, std::string scope_key, MemoryStateStore* state) + : dir_(std::move(dir)), scope_key_(std::move(scope_key)), state_(state) {} + void MemoryRegistry::scan() { std::lock_guard lock(mu_); + scan_locked(); +} + +void MemoryRegistry::scan_locked() { entries_.clear(); - fs::path dir = get_memory_dir(); std::error_code ec; - if (!fs::exists(dir, ec)) { - // Empty directory is not an error — first launch. + if (dir_.empty() || !fs::exists(dir_, ec)) { + // Empty directory is not an error — first launch / first write pending. return; } - if (!fs::is_directory(dir, ec)) { - LOG_WARN("[memory] " + path_to_utf8(dir) + " is not a directory; ignoring"); + if (!fs::is_directory(dir_, ec)) { + LOG_WARN("[memory] " + path_to_utf8(dir_) + " is not a directory; ignoring"); return; } - for (auto it = fs::directory_iterator(dir, ec); + // 只看顶层 *.md:inbox/、archive/ 归记忆摘要,其余子目录一律忽略。 + for (auto it = fs::directory_iterator(dir_, ec); !ec && it != fs::directory_iterator(); it.increment(ec)) { if (ec) break; const fs::path& p = it->path(); - if (!fs::is_regular_file(p)) continue; + std::error_code type_ec; + if (!fs::is_regular_file(p, type_ec)) continue; if (p.extension() != ".md") continue; // Skip the index itself; entries live in sibling files. - std::string stem = path_to_utf8(p.stem()); - std::string stem_lower = stem; - std::transform(stem_lower.begin(), stem_lower.end(), stem_lower.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (stem_lower == "memory") continue; + if (is_index_stem(p)) continue; auto parsed = parse_memory_entry_file(p); if (!parsed) continue; // parse_memory_entry_file logs the reason. entries_.push_back(std::move(*parsed)); } - std::sort(entries_.begin(), entries_.end(), - [](const MemoryEntry& a, const MemoryEntry& b) { return a.name < b.name; }); + sort_entries(entries_); } std::vector MemoryRegistry::list(std::optional type_filter) const { @@ -117,7 +136,7 @@ std::optional MemoryRegistry::find(const std::string& name) const { std::string MemoryRegistry::read_index_raw(std::size_t max_bytes) const { std::lock_guard lock(mu_); - fs::path idx = get_memory_index_path(); + fs::path idx = dir_ / "MEMORY.md"; std::error_code ec; if (!fs::is_regular_file(idx, ec) || ec) return {}; @@ -126,8 +145,6 @@ std::string MemoryRegistry::read_index_raw(std::size_t max_bytes) const { std::size_t dropped = content.size() - max_bytes; content.resize(max_bytes); content += "\n[... truncated " + std::to_string(dropped) + " bytes]\n"; - LOG_WARN("[memory] MEMORY.md exceeded max_index_bytes; truncated in-memory by " + - std::to_string(dropped) + " bytes"); } return content; } @@ -138,13 +155,14 @@ std::vector::iterator MemoryRegistry::find_locked(const std::string } void MemoryRegistry::rewrite_index_locked() { - fs::path idx = get_memory_index_path(); + fs::path idx = dir_ / "MEMORY.md"; std::string existing; std::error_code ec; if (fs::is_regular_file(idx, ec) && !ec) { existing = read_file_to_string(idx); } std::string rendered = render_memory_index(entries_, existing); + if (entries_.empty() && !fs::exists(idx, ec)) return; // 不为空作用域凭空建索引 std::string err = atomic_write(idx, rendered); if (!err.empty()) { LOG_ERROR("[memory] failed to rewrite MEMORY.md: " + err); @@ -157,42 +175,90 @@ std::optional MemoryRegistry::upsert(const std::string& name, const std::string& body, MemoryWriteMode mode, std::string& error_out) { + MemoryWriteRequest request; + request.name = name; + request.type = type; + request.description = description; + request.body = body; + request.mode = mode; + return upsert(request, error_out); +} + +std::optional MemoryRegistry::upsert(const MemoryWriteRequest& request, + std::string& error_out, + MemoryWriteOutcome* outcome) { + // 锁序固定为「跨进程写锁 → 进程内 mu_」,与整合应用保持一致,避免互等。 + MemoryWriteLock write_lock(state_); + std::lock_guard lock(mu_); + auto written = upsert_locked(request, error_out, outcome); + if (written) write_lock.commit(); + return written; +} + +std::optional MemoryRegistry::upsert_locked(const MemoryWriteRequest& request, + std::string& error_out, + MemoryWriteOutcome* outcome) { error_out.clear(); - std::string name_err = validate_memory_name(name); + std::string name_err = validate_memory_name(request.name); if (!name_err.empty()) { error_out = name_err; return std::nullopt; } - if (description.empty()) { + + const SecretRedactionResult description = redact_secrets(request.description); + const SecretRedactionResult body = redact_secrets(request.body); + if (description.text.empty()) { error_out = "description must not be empty"; return std::nullopt; } - fs::path target = resolve_memory_entry_path(name); - if (target.empty() || !is_within_memory_dir(target)) { - error_out = "resolved path escapes ~/.acecode/memory/: " + name; + fs::path target = resolve_memory_entry_path_in(dir_, request.name); + if (target.empty() || !is_within_directory(target, dir_)) { + error_out = "resolved path escapes the memory directory: " + request.name; return std::nullopt; } - std::lock_guard lock(mu_); + // 写前按磁盘重扫:TUI 与每个工作区的 daemon 各持一份缓存,拿启动时的旧缓存 + // 重写 MEMORY.md 会把别的进程后来写的条目当成「已删除」从索引里丢掉。 + scan_locked(); - auto existing = find_locked(name); + auto existing = find_locked(request.name); bool file_exists = existing != entries_.end(); - if (mode == MemoryWriteMode::Create && file_exists) { - error_out = "memory entry already exists: " + name; + if (request.mode == MemoryWriteMode::Create && file_exists) { + error_out = "memory entry already exists: " + request.name; return std::nullopt; } - if (mode == MemoryWriteMode::Update && !file_exists) { - error_out = "memory entry does not exist: " + name; + if (request.mode == MemoryWriteMode::Update && !file_exists) { + error_out = "memory entry does not exist: " + request.name; return std::nullopt; } + const std::string now = request.now_iso.empty() ? memory_now_iso8601() : request.now_iso; MemoryEntry entry; - entry.name = name; - entry.description = description; - entry.type = type; + entry.name = request.name; + entry.description = description.text; + entry.type = request.type; entry.path = target; - entry.body = body; + entry.body = body.text; + entry.updated_at = now; + entry.source = request.source.empty() ? kMemorySourceManual : request.source; + if (file_exists) { + // 旧条目没有 created_at 时取文件原有的修改时间(必须在覆盖之前读)。 + entry.created_at = existing->created_at.empty() + ? memory_file_mtime_iso8601(target) + : existing->created_at; + entry.extra_frontmatter = existing->extra_frontmatter; + if (!request.replace_source_sessions) entry.source_sessions = existing->source_sessions; + } + if (entry.created_at.empty()) entry.created_at = now; + if (request.replace_source_sessions) entry.source_sessions.clear(); + for (const auto& session : request.source_sessions) { + if (session.empty()) continue; + if (std::find(entry.source_sessions.begin(), entry.source_sessions.end(), session) == + entry.source_sessions.end()) { + entry.source_sessions.push_back(session); + } + } std::string rendered = render_memory_entry(entry); std::string write_err = atomic_write(target, rendered); @@ -205,8 +271,11 @@ std::optional MemoryRegistry::upsert(const std::string& name, *existing = entry; } else { entries_.push_back(entry); - std::sort(entries_.begin(), entries_.end(), - [](const MemoryEntry& a, const MemoryEntry& b) { return a.name < b.name; }); + sort_entries(entries_); + } + if (outcome) { + outcome->created = !file_exists; + outcome->redactions = description.replacements + body.replacements; } rewrite_index_locked(); @@ -214,6 +283,14 @@ std::optional MemoryRegistry::upsert(const std::string& name, } bool MemoryRegistry::remove(const std::string& name, std::string& error_out) { + MemoryWriteLock write_lock(state_); + std::lock_guard lock(mu_); + const bool removed = remove_locked(name, error_out); + if (removed) write_lock.commit(); + return removed; +} + +bool MemoryRegistry::remove_locked(const std::string& name, std::string& error_out) { error_out.clear(); std::string name_err = validate_memory_name(name); if (!name_err.empty()) { @@ -221,13 +298,13 @@ bool MemoryRegistry::remove(const std::string& name, std::string& error_out) { return false; } - fs::path target = resolve_memory_entry_path(name); - if (target.empty() || !is_within_memory_dir(target)) { - error_out = "resolved path escapes ~/.acecode/memory/: " + name; + fs::path target = resolve_memory_entry_path_in(dir_, name); + if (target.empty() || !is_within_directory(target, dir_)) { + error_out = "resolved path escapes the memory directory: " + name; return false; } - std::lock_guard lock(mu_); + scan_locked(); auto it = find_locked(name); if (it == entries_.end()) { @@ -245,7 +322,7 @@ bool MemoryRegistry::remove(const std::string& name, std::string& error_out) { entries_.erase(it); // Update MEMORY.md: drop the matching line and keep everything else. - fs::path idx = get_memory_index_path(); + fs::path idx = dir_ / "MEMORY.md"; if (fs::is_regular_file(idx, ec) && !ec) { std::string existing = read_file_to_string(idx); std::string updated = remove_memory_index_line(existing, name); @@ -259,6 +336,43 @@ bool MemoryRegistry::remove(const std::string& name, std::string& error_out) { return true; } +bool MemoryRegistry::reset(std::string& error_out) { + error_out.clear(); + MemoryWriteLock write_lock(state_); + std::lock_guard lock(mu_); + std::error_code ec; + if (!dir_.empty() && fs::is_directory(dir_, ec)) { + std::vector victims; + for (auto it = fs::directory_iterator(dir_, ec); + !ec && it != fs::directory_iterator(); it.increment(ec)) { + std::error_code type_ec; + if (fs::is_regular_file(it->path(), type_ec) && it->path().extension() == ".md") { + victims.push_back(it->path()); + } + } + victims.push_back(memory_inbox_dir(dir_)); + victims.push_back(memory_archive_dir(dir_)); + for (const auto& victim : victims) { + std::error_code remove_ec; + fs::remove_all(victim, remove_ec); + if (remove_ec) { + error_out = "failed to remove " + path_to_utf8(victim) + ": " + remove_ec.message(); + } + } + } + entries_.clear(); + if (error_out.empty()) write_lock.commit(); + return error_out.empty(); +} + +void MemoryRegistry::rebuild_index() { + MemoryWriteLock write_lock(state_); + std::lock_guard lock(mu_); + scan_locked(); + rewrite_index_locked(); + write_lock.commit(); +} + std::size_t MemoryRegistry::size() const { std::lock_guard lock(mu_); return entries_.size(); diff --git a/src/domain/memory/memory_registry.hpp b/src/domain/memory/memory_registry.hpp index f991fdc1d..7303cae0f 100644 --- a/src/domain/memory/memory_registry.hpp +++ b/src/domain/memory/memory_registry.hpp @@ -10,6 +10,8 @@ namespace acecode { +class MemoryStateStore; + // Write semantics for upsert(). enum class MemoryWriteMode { Create, // fail if an entry with `name` already exists @@ -17,18 +19,47 @@ enum class MemoryWriteMode { Upsert, // create-or-replace (default) }; -// Thread-safe in-memory cache over ~/.acecode/memory/. Scans the directory -// once on construction (plus whenever reload() is called); all mutating -// operations (upsert / remove) atomically update both the entry file and the -// MEMORY.md index. +// 一次条目写入。source / session 用于来源记录;now_iso 为空时取当前时间。 +struct MemoryWriteRequest { + std::string name; + MemoryType type = MemoryType::User; + std::string description; + std::string body; + MemoryWriteMode mode = MemoryWriteMode::Upsert; + std::string source = kMemorySourceManual; + // 追加到 source_sessions 的会话(去重);replace_source_sessions=true 时整体替换。 + std::vector source_sessions; + bool replace_source_sessions = false; + std::string now_iso; +}; + +struct MemoryWriteOutcome { + bool created = false; // 新建(而不是改写已有条目) + int redactions = 0; // 写入前脱敏替换的处数 +}; + +// Thread-safe cache over one memory scope directory (global /memory/ +// or a workspace /memory/). Only top-level .md files are +// entries; MEMORY.md is the generated index; inbox/ and archive/ belong to +// memory summarization and every other subdirectory is ignored. +// +// Mutations rescan the directory first (another process may have written in +// the meantime) and, when a MemoryStateStore is attached, run inside its +// cross-process write lock so two processes cannot interleave scan + write. class MemoryRegistry { public: - MemoryRegistry() = default; + // Global scope (get_memory_dir()), kept for existing callers and tests. + MemoryRegistry(); + explicit MemoryRegistry(std::filesystem::path dir, + std::string scope_key = "global", + MemoryStateStore* state = nullptr); + + const std::filesystem::path& dir() const { return dir_; } + const std::string& scope_key() const { return scope_key_; } + MemoryStateStore* state_store() const { return state_; } // Scan disk now. Entries with invalid frontmatter / unreadable files are // skipped with LOG_WARN so one bad entry doesn't kill the whole cache. - // Callers should call this once at startup; after that operate via - // upsert/remove/reload. void scan(); // Equivalent to scan(); named for /memory reload clarity and command help. @@ -41,15 +72,11 @@ class MemoryRegistry { // when no entry by that name exists. std::optional find(const std::string& name) const; - // Read MEMORY.md raw (for system-prompt injection). Returns empty string - // when the file doesn't exist or is zero-length. `max_bytes` enforces the - // config.memory.max_index_bytes cap — content beyond that is truncated - // and a marker appended. + // Read MEMORY.md raw. Returns empty string when the file doesn't exist or + // is zero-length. `max_bytes` truncates the returned copy with a marker. std::string read_index_raw(std::size_t max_bytes) const; - // Upsert an entry. Returns the written entry on success (with path set); - // returns nullopt and fills `error_out` on validation/write failure. - // The in-memory cache is updated only when the on-disk write succeeds. + // Legacy upsert: manual source, no session provenance. std::optional upsert(const std::string& name, MemoryType type, const std::string& description, @@ -57,18 +84,41 @@ class MemoryRegistry { MemoryWriteMode mode, std::string& error_out); + // Upsert with provenance. Description and body are redacted before they + // reach disk; `outcome` reports how many secrets were replaced. Legacy + // entries get created_at from the file's previous modification time; unknown + // frontmatter fields are preserved. + std::optional upsert(const MemoryWriteRequest& request, + std::string& error_out, + MemoryWriteOutcome* outcome = nullptr); + // Remove an entry. Returns true when the file existed and was deleted. // Removes the matching MEMORY.md line as a side effect. bool remove(const std::string& name, std::string& error_out); + // Delete every entry, the index, inbox/ and archive/ of this scope. Other + // files (the global scope's state.sqlite3, foreign subdirectories) stay. + bool reset(std::string& error_out); + + // Rebuild MEMORY.md from the entries currently on disk. + void rebuild_index(); + // Entry count, safe for logging / /memory list. std::size_t size() const; private: // Non-locking helpers — callers must already hold mu_. + void scan_locked(); std::vector::iterator find_locked(const std::string& name); void rewrite_index_locked(); - + std::optional upsert_locked(const MemoryWriteRequest& request, + std::string& error_out, + MemoryWriteOutcome* outcome); + bool remove_locked(const std::string& name, std::string& error_out); + + std::filesystem::path dir_; + std::string scope_key_; + MemoryStateStore* state_ = nullptr; // 可空;构造时注入,生命周期长于本对象 mutable std::mutex mu_; std::vector entries_; }; diff --git a/src/domain/memory/memory_service.cpp b/src/domain/memory/memory_service.cpp new file mode 100644 index 000000000..13f3a4206 --- /dev/null +++ b/src/domain/memory/memory_service.cpp @@ -0,0 +1,94 @@ +#include "memory_service.hpp" + +#include "memory_paths.hpp" + +#include "utils/utf8_path.hpp" + +namespace fs = std::filesystem; + +namespace acecode { + +MemoryService::MemoryService(fs::path global_dir, fs::path state_db_path, MemoryConfig config) + : global_dir_(std::move(global_dir)), + state_(std::make_unique(std::move(state_db_path))), + config_(std::move(config)) { + global_ = std::make_shared(global_dir_, "global", state_.get()); +} + +std::shared_ptr MemoryService::create_default(const MemoryConfig& config) { + return std::make_shared(get_memory_dir(), get_memory_state_db_path(), config); +} + +MemoryConfig MemoryService::config() const { + std::lock_guard lock(mu_); + return config_; +} + +void MemoryService::update_config(const MemoryConfig& config) { + std::lock_guard lock(mu_); + config_ = config; +} + +bool MemoryService::enabled() const { + std::lock_guard lock(mu_); + return config_.enabled; +} + +std::shared_ptr MemoryService::workspace(const std::string& project_dir) { + if (project_dir.empty()) return nullptr; + std::lock_guard lock(mu_); + auto found = workspaces_.find(project_dir); + if (found != workspaces_.end()) return found->second; + auto registry = std::make_shared( + workspace_memory_dir(project_dir), workspace_scope_key(project_dir), state_.get()); + workspaces_.emplace(project_dir, registry); + return registry; +} + +std::shared_ptr MemoryService::scope(MemoryScope scope, + const std::string& project_dir) { + if (scope == MemoryScope::Global) return global_; + return workspace(project_dir); +} + +bool MemoryService::delete_entry(MemoryScope scope, const std::string& project_dir, + const std::string& name, std::string& error) { + auto registry = this->scope(scope, project_dir); + if (!registry) { + error = "this session has no workspace memory"; + return false; + } + registry->reload(); + const auto existing = registry->find(name); + if (!registry->remove(name, error)) return false; + state_->add_tombstone(registry->scope_key(), name, + existing ? existing->description : std::string{}, memory_now_ms()); + return true; +} + +bool MemoryService::reset_scope(MemoryScope scope, const std::string& project_dir, + std::string& error) { + auto registry = this->scope(scope, project_dir); + if (!registry) { + error = "this session has no workspace memory"; + return false; + } + if (!registry->reset(error)) return false; + state_->clear_consolidation(registry->scope_key()); + return true; +} + +std::string MemoryService::workspace_scope_key(const std::string& project_dir) { + // 只取最后一段(projects/ 的 hash);手工切分,不经 std::filesystem::path, + // 中文路径按系统代码页隐式转换会抛异常(CLAUDE.md「cwd 一律以 UTF-8 传递」)。 + std::string trimmed = project_dir; + while (!trimmed.empty() && (trimmed.back() == '/' || trimmed.back() == '\\')) trimmed.pop_back(); + const std::size_t cut = trimmed.find_last_of("/\\"); + return "workspace:" + (cut == std::string::npos ? trimmed : trimmed.substr(cut + 1)); +} + +std::string MemoryService::scope_key(MemoryScope scope, const std::string& project_dir) { + return scope == MemoryScope::Global ? std::string("global") : workspace_scope_key(project_dir); +} + +} // namespace acecode diff --git a/src/domain/memory/memory_service.hpp b/src/domain/memory/memory_service.hpp new file mode 100644 index 000000000..b6f6819c6 --- /dev/null +++ b/src/domain/memory/memory_service.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "config/config.hpp" +#include "memory_registry.hpp" +#include "memory_state_store.hpp" +#include "memory_types.hpp" + +#include +#include +#include +#include +#include + +namespace acecode { + +// 记忆存储的进程级门面(openspec unify-memory-system D1/D2):一个全局作用域 +// 存储、按会话项目目录惰性打开的工作区存储、共享状态库与运行时记忆配置。 +// 作用域一律由调用方传入的「会话项目目录」解析,从不读进程 cwd —— daemon 一个 +// 进程服务多个工作区,进程 cwd 会读错目录。 +class MemoryService { +public: + MemoryService(std::filesystem::path global_dir, + std::filesystem::path state_db_path, + MemoryConfig config); + + // /memory/ + /memory/state.sqlite3。 + static std::shared_ptr create_default(const MemoryConfig& config); + + MemoryConfig config() const; + void update_config(const MemoryConfig& config); + bool enabled() const; + + MemoryRegistry& global() { return *global_; } + const std::filesystem::path& global_dir() const { return global_dir_; } + + // project_dir 为空(会话没有所属工作区)时返回 nullptr。同一目录复用同一实例。 + std::shared_ptr workspace(const std::string& project_dir); + + // 按作用域取存储;Workspace 且没有项目目录时返回 nullptr。 + std::shared_ptr scope(MemoryScope scope, const std::string& project_dir); + + MemoryStateStore& state() { return *state_; } + + // 用户删除条目(/memory forget、设置页删除):删文件与索引行并记墓碑(作用域、 + // 名字、描述作标题),墓碑期内记忆摘要整合不得重新创建。 + bool delete_entry(MemoryScope scope, const std::string& project_dir, + const std::string& name, std::string& error); + + // 清空一个作用域的条目、索引、收件箱与归档(设置页「重置」),并清掉该作用域 + // 的整合进度;另一个作用域不受影响。 + bool reset_scope(MemoryScope scope, const std::string& project_dir, std::string& error); + + // 状态库与墓碑使用的作用域键:"global" / "workspace:"。 + static std::string workspace_scope_key(const std::string& project_dir); + static std::string scope_key(MemoryScope scope, const std::string& project_dir); + +private: + std::filesystem::path global_dir_; + std::unique_ptr state_; + std::shared_ptr global_; + mutable std::mutex mu_; + MemoryConfig config_; + std::map> workspaces_; +}; + +} // namespace acecode diff --git a/src/domain/memory/memory_state_store.cpp b/src/domain/memory/memory_state_store.cpp new file mode 100644 index 000000000..ea2d06b4a --- /dev/null +++ b/src/domain/memory/memory_state_store.cpp @@ -0,0 +1,509 @@ +#include "memory_state_store.hpp" + +#include "utils/logger.hpp" +#include "utils/utf8_path.hpp" + +#include + +#include +#include +#include + +#ifdef _WIN32 +# include +#else +# include +#endif + +namespace fs = std::filesystem; + +namespace acecode { + +namespace { + +constexpr const char* kSchemaSql = + "CREATE TABLE IF NOT EXISTS extraction_progress (" + "session_id TEXT PRIMARY KEY," + "project_dir TEXT NOT NULL DEFAULT ''," + "next_message_index INTEGER NOT NULL DEFAULT 0," + "attempts INTEGER NOT NULL DEFAULT 0," + "last_error TEXT NOT NULL DEFAULT ''," + "failed_marker TEXT NOT NULL DEFAULT ''," + "seen_marker TEXT NOT NULL DEFAULT ''," + "lease_owner TEXT NOT NULL DEFAULT ''," + "lease_expires_ms INTEGER NOT NULL DEFAULT 0," + "updated_ms INTEGER NOT NULL DEFAULT 0);" + "CREATE TABLE IF NOT EXISTS consolidation_progress (" + "scope_key TEXT PRIMARY KEY," + "attempts INTEGER NOT NULL DEFAULT 0," + "last_error TEXT NOT NULL DEFAULT ''," + "failed_batch TEXT NOT NULL DEFAULT ''," + "pending_plan TEXT NOT NULL DEFAULT ''," + "pending_plan_hash TEXT NOT NULL DEFAULT ''," + "last_plan_hash TEXT NOT NULL DEFAULT ''," + "lease_owner TEXT NOT NULL DEFAULT ''," + "lease_expires_ms INTEGER NOT NULL DEFAULT 0," + "last_success_ms INTEGER NOT NULL DEFAULT 0," + "updated_ms INTEGER NOT NULL DEFAULT 0);" + "CREATE TABLE IF NOT EXISTS tombstones (" + "scope_key TEXT NOT NULL," + "name TEXT NOT NULL," + "title_norm TEXT NOT NULL DEFAULT ''," + "deleted_ms INTEGER NOT NULL," + "PRIMARY KEY(scope_key, name));" + "CREATE TABLE IF NOT EXISTS status (" + "key TEXT PRIMARY KEY," + "value TEXT NOT NULL DEFAULT ''," + "updated_ms INTEGER NOT NULL DEFAULT 0);"; + +class Statement { +public: + Statement(sqlite3* db, const char* sql) { + if (db && sqlite3_prepare_v2(db, sql, -1, &stmt_, nullptr) != SQLITE_OK) { + LOG_WARN(std::string("[memory] state prepare failed: ") + sqlite3_errmsg(db)); + stmt_ = nullptr; + } + } + ~Statement() { + if (stmt_) sqlite3_finalize(stmt_); + } + Statement(const Statement&) = delete; + Statement& operator=(const Statement&) = delete; + + explicit operator bool() const { return stmt_ != nullptr; } + Statement& text(int index, const std::string& value) { + sqlite3_bind_text(stmt_, index, value.c_str(), static_cast(value.size()), + SQLITE_TRANSIENT); + return *this; + } + Statement& integer(int index, std::int64_t value) { + sqlite3_bind_int64(stmt_, index, static_cast(value)); + return *this; + } + int step() { return sqlite3_step(stmt_); } + bool done() { return step() == SQLITE_DONE; } + std::string column_text(int index) const { + const auto* raw = sqlite3_column_text(stmt_, index); + return raw ? std::string(reinterpret_cast(raw)) : std::string{}; + } + std::int64_t column_int(int index) const { + return static_cast(sqlite3_column_int64(stmt_, index)); + } + +private: + sqlite3_stmt* stmt_ = nullptr; +}; + +const char* lease_table(MemoryLeaseKind kind) { + return kind == MemoryLeaseKind::Extraction ? "extraction_progress" : "consolidation_progress"; +} + +const char* lease_key_column(MemoryLeaseKind kind) { + return kind == MemoryLeaseKind::Extraction ? "session_id" : "scope_key"; +} + +} // namespace + +std::int64_t memory_now_ms() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +std::string normalize_memory_title(const std::string& title) { + std::string out; + bool pending_space = false; + for (unsigned char c : title) { + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + pending_space = !out.empty(); + continue; + } + if (pending_space) out.push_back(' '); + pending_space = false; + out.push_back(static_cast((c >= 'A' && c <= 'Z') ? c - 'A' + 'a' : c)); + } + return out; +} + +std::string make_memory_lease_owner() { + static thread_local std::mt19937_64 gen{std::random_device{}()}; +#ifdef _WIN32 + const long long pid = static_cast(_getpid()); +#else + const long long pid = static_cast(getpid()); +#endif + std::ostringstream oss; + oss << "pid-" << pid << "-" << std::hex << gen(); + return oss.str(); +} + +MemoryStateStore::MemoryStateStore(fs::path db_path) : db_path_(std::move(db_path)) {} + +MemoryStateStore::~MemoryStateStore() = default; + +bool MemoryStateStore::open(std::string* error) { + std::lock_guard lock(mu_); + return open_locked(error); +} + +bool MemoryStateStore::exec_locked(const char* sql, std::string* error) { + char* raw_error = nullptr; + const int rc = sqlite3_exec(db_.get(), sql, nullptr, nullptr, &raw_error); + if (rc == SQLITE_OK) return true; + const std::string message = raw_error ? raw_error : sqlite3_errmsg(db_.get()); + sqlite3_free(raw_error); + if (error) *error = message; + return false; +} + +bool MemoryStateStore::open_locked(std::string* error) { + if (db_.get()) return true; + std::error_code ec; + fs::create_directories(db_path_.parent_path(), ec); + if (ec) { + if (error) *error = "cannot create memory state directory: " + ec.message(); + return false; + } + platform::UniqueSqlite db; + const std::string path = path_to_utf8(db_path_); + if (sqlite3_open_v2(path.c_str(), db.put(), + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nullptr) != SQLITE_OK) { + if (error) { + *error = std::string("cannot open memory state database: ") + + (db.get() ? sqlite3_errmsg(db.get()) : "unknown error"); + } + return false; + } + sqlite3_busy_timeout(db.get(), 5000); + db_ = std::move(db); + (void)exec_locked("PRAGMA journal_mode=WAL;"); + (void)exec_locked("PRAGMA synchronous=NORMAL;"); + std::string schema_error; + if (!exec_locked(kSchemaSql, &schema_error)) { + db_.reset(); + if (error) *error = "cannot create memory state schema: " + schema_error; + return false; + } + return true; +} + +MemoryWriteLock::MemoryWriteLock(MemoryStateStore* store) : store_(store) { + if (!store_) return; + lock_ = std::unique_lock(store_->mu_); + if (!store_->open_locked(&error_)) return; + if (store_->tx_depth_ > 0) { + nested_ = true; + acquired_ = true; + ++store_->tx_depth_; + return; + } + if (!store_->exec_locked("BEGIN IMMEDIATE;", &error_)) return; + store_->tx_depth_ = 1; + acquired_ = true; +} + +MemoryWriteLock::~MemoryWriteLock() { + if (!acquired_ || finished_) return; + finished_ = true; + if (nested_) { + --store_->tx_depth_; + return; + } + store_->tx_depth_ = 0; + (void)store_->exec_locked("ROLLBACK;"); +} + +bool MemoryWriteLock::commit() { + if (!acquired_ || finished_) return false; + finished_ = true; + if (nested_) { + --store_->tx_depth_; + return true; + } + store_->tx_depth_ = 0; + if (store_->exec_locked("COMMIT;", &error_)) return true; + (void)store_->exec_locked("ROLLBACK;"); + return false; +} + +bool MemoryStateStore::try_acquire_lease(MemoryLeaseKind kind, const std::string& key, + const std::string& owner, std::int64_t ttl_ms, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr) || key.empty() || owner.empty()) return false; + const std::string table = lease_table(kind); + const std::string column = lease_key_column(kind); + // 单条 UPSERT 原子完成「空闲 / 已是自己 / 已过期 → 接管」,不给两个进程 + // 「先查后写」之间的窗口。 + const std::string sql = + "INSERT INTO " + table + "(" + column + ", lease_owner, lease_expires_ms, updated_ms) " + "VALUES(?1, ?2, ?3, ?4) ON CONFLICT(" + column + ") DO UPDATE SET " + "lease_owner = excluded.lease_owner, lease_expires_ms = excluded.lease_expires_ms " + "WHERE " + table + ".lease_owner = '' OR " + table + ".lease_owner = excluded.lease_owner " + "OR " + table + ".lease_expires_ms <= ?4;"; + Statement stmt(db_.get(), sql.c_str()); + if (!stmt) return false; + stmt.text(1, key).text(2, owner).integer(3, now_ms + ttl_ms).integer(4, now_ms); + if (!stmt.done()) return false; + return sqlite3_changes(db_.get()) > 0; +} + +bool MemoryStateStore::renew_lease(MemoryLeaseKind kind, const std::string& key, + const std::string& owner, std::int64_t ttl_ms, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + const std::string sql = std::string("UPDATE ") + lease_table(kind) + + " SET lease_expires_ms = ?1 WHERE " + lease_key_column(kind) + + " = ?2 AND lease_owner = ?3;"; + Statement stmt(db_.get(), sql.c_str()); + if (!stmt) return false; + stmt.integer(1, now_ms + ttl_ms).text(2, key).text(3, owner); + return stmt.done() && sqlite3_changes(db_.get()) > 0; +} + +void MemoryStateStore::release_lease(MemoryLeaseKind kind, const std::string& key, + const std::string& owner) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return; + const std::string sql = std::string("UPDATE ") + lease_table(kind) + + " SET lease_owner = '', lease_expires_ms = 0 WHERE " + lease_key_column(kind) + + " = ?1 AND lease_owner = ?2;"; + Statement stmt(db_.get(), sql.c_str()); + if (!stmt) return; + stmt.text(1, key).text(2, owner); + (void)stmt.done(); +} + +std::optional MemoryStateStore::extraction(const std::string& session_id) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return std::nullopt; + Statement stmt(db_.get(), + "SELECT project_dir, next_message_index, attempts, last_error, failed_marker, " + "lease_owner, lease_expires_ms, updated_ms, seen_marker FROM extraction_progress " + "WHERE session_id = ?1;"); + if (!stmt) return std::nullopt; + stmt.text(1, session_id); + if (stmt.step() != SQLITE_ROW) return std::nullopt; + MemoryExtractionProgress out; + out.session_id = session_id; + out.project_dir = stmt.column_text(0); + out.next_message_index = stmt.column_int(1); + out.attempts = static_cast(stmt.column_int(2)); + out.last_error = stmt.column_text(3); + out.failed_marker = stmt.column_text(4); + out.lease_owner = stmt.column_text(5); + out.lease_expires_ms = stmt.column_int(6); + out.updated_ms = stmt.column_int(7); + out.seen_marker = stmt.column_text(8); + return out; +} + +bool MemoryStateStore::commit_extraction(const std::string& session_id, + const std::string& project_dir, + std::int64_t next_message_index, + const std::string& seen_marker, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "INSERT INTO extraction_progress(session_id, project_dir, next_message_index, attempts, " + "last_error, failed_marker, seen_marker, updated_ms) VALUES(?1, ?2, ?3, 0, '', '', ?4, ?5) " + "ON CONFLICT(session_id) DO UPDATE SET project_dir = excluded.project_dir, " + "next_message_index = MAX(extraction_progress.next_message_index, excluded.next_message_index), " + "attempts = 0, last_error = '', failed_marker = '', seen_marker = excluded.seen_marker, " + "updated_ms = excluded.updated_ms;"); + if (!stmt) return false; + stmt.text(1, session_id).text(2, project_dir).integer(3, next_message_index) + .text(4, seen_marker).integer(5, now_ms); + return stmt.done(); +} + +bool MemoryStateStore::record_extraction_failure(const std::string& session_id, + const std::string& project_dir, + const std::string& error, + const std::string& marker, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + // 会话活动标记变了(有了新消息)就从 1 重新计数,否则累加。 + Statement stmt(db_.get(), + "INSERT INTO extraction_progress(session_id, project_dir, attempts, last_error, " + "failed_marker, updated_ms) VALUES(?1, ?2, 1, ?3, ?4, ?5) " + "ON CONFLICT(session_id) DO UPDATE SET project_dir = excluded.project_dir, " + "attempts = CASE WHEN extraction_progress.failed_marker = excluded.failed_marker " + "THEN extraction_progress.attempts + 1 ELSE 1 END, " + "last_error = excluded.last_error, failed_marker = excluded.failed_marker, " + "updated_ms = excluded.updated_ms;"); + if (!stmt) return false; + stmt.text(1, session_id).text(2, project_dir).text(3, error).text(4, marker).integer(5, now_ms); + return stmt.done(); +} + +bool MemoryStateStore::forget_extraction(const std::string& session_id) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), "DELETE FROM extraction_progress WHERE session_id = ?1;"); + if (!stmt) return false; + stmt.text(1, session_id); + return stmt.done(); +} + +std::optional MemoryStateStore::consolidation( + const std::string& scope_key) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return std::nullopt; + Statement stmt(db_.get(), + "SELECT attempts, last_error, failed_batch, pending_plan, pending_plan_hash, " + "last_plan_hash, lease_owner, lease_expires_ms, last_success_ms, updated_ms " + "FROM consolidation_progress WHERE scope_key = ?1;"); + if (!stmt) return std::nullopt; + stmt.text(1, scope_key); + if (stmt.step() != SQLITE_ROW) return std::nullopt; + MemoryConsolidationProgress out; + out.scope_key = scope_key; + out.attempts = static_cast(stmt.column_int(0)); + out.last_error = stmt.column_text(1); + out.failed_batch = stmt.column_text(2); + out.pending_plan = stmt.column_text(3); + out.pending_plan_hash = stmt.column_text(4); + out.last_plan_hash = stmt.column_text(5); + out.lease_owner = stmt.column_text(6); + out.lease_expires_ms = stmt.column_int(7); + out.last_success_ms = stmt.column_int(8); + out.updated_ms = stmt.column_int(9); + return out; +} + +bool MemoryStateStore::set_pending_plan(const std::string& scope_key, const std::string& plan, + const std::string& plan_hash, std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "INSERT INTO consolidation_progress(scope_key, pending_plan, pending_plan_hash, updated_ms) " + "VALUES(?1, ?2, ?3, ?4) ON CONFLICT(scope_key) DO UPDATE SET " + "pending_plan = excluded.pending_plan, pending_plan_hash = excluded.pending_plan_hash, " + "updated_ms = excluded.updated_ms;"); + if (!stmt) return false; + stmt.text(1, scope_key).text(2, plan).text(3, plan_hash).integer(4, now_ms); + return stmt.done(); +} + +bool MemoryStateStore::commit_consolidation(const std::string& scope_key, + const std::string& plan_hash, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "INSERT INTO consolidation_progress(scope_key, last_plan_hash, last_success_ms, updated_ms) " + "VALUES(?1, ?2, ?3, ?3) ON CONFLICT(scope_key) DO UPDATE SET attempts = 0, " + "last_error = '', failed_batch = '', pending_plan = '', pending_plan_hash = '', " + "last_plan_hash = excluded.last_plan_hash, last_success_ms = excluded.last_success_ms, " + "updated_ms = excluded.updated_ms;"); + if (!stmt) return false; + stmt.text(1, scope_key).text(2, plan_hash).integer(3, now_ms); + return stmt.done(); +} + +bool MemoryStateStore::record_consolidation_failure(const std::string& scope_key, + const std::string& error, + const std::string& batch_signature, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "INSERT INTO consolidation_progress(scope_key, attempts, last_error, failed_batch, updated_ms) " + "VALUES(?1, 1, ?2, ?3, ?4) ON CONFLICT(scope_key) DO UPDATE SET " + "attempts = CASE WHEN consolidation_progress.failed_batch = excluded.failed_batch " + "THEN consolidation_progress.attempts + 1 ELSE 1 END, " + "last_error = excluded.last_error, failed_batch = excluded.failed_batch, " + "pending_plan = '', pending_plan_hash = '', updated_ms = excluded.updated_ms;"); + if (!stmt) return false; + stmt.text(1, scope_key).text(2, error).text(3, batch_signature).integer(4, now_ms); + return stmt.done(); +} + +bool MemoryStateStore::clear_consolidation(const std::string& scope_key) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "UPDATE consolidation_progress SET attempts = 0, last_error = '', failed_batch = '', " + "pending_plan = '', pending_plan_hash = '' WHERE scope_key = ?1;"); + if (!stmt) return false; + stmt.text(1, scope_key); + return stmt.done(); +} + +bool MemoryStateStore::add_tombstone(const std::string& scope_key, const std::string& name, + const std::string& title, std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "INSERT INTO tombstones(scope_key, name, title_norm, deleted_ms) VALUES(?1, ?2, ?3, ?4) " + "ON CONFLICT(scope_key, name) DO UPDATE SET title_norm = excluded.title_norm, " + "deleted_ms = excluded.deleted_ms;"); + if (!stmt) return false; + stmt.text(1, scope_key).text(2, name).text(3, normalize_memory_title(title)).integer(4, now_ms); + return stmt.done(); +} + +std::vector MemoryStateStore::tombstones(const std::string& scope_key, + std::int64_t now_ms) { + std::vector out; + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return out; + Statement stmt(db_.get(), + "SELECT name, title_norm, deleted_ms FROM tombstones WHERE scope_key = ?1 " + "AND deleted_ms > ?2 ORDER BY deleted_ms DESC;"); + if (!stmt) return out; + stmt.text(1, scope_key).integer(2, now_ms - kTombstoneTtlMs); + while (stmt.step() == SQLITE_ROW) { + out.push_back({scope_key, stmt.column_text(0), stmt.column_text(1), stmt.column_int(2)}); + } + return out; +} + +bool MemoryStateStore::is_tombstoned(const std::string& scope_key, const std::string& name, + const std::string& title, std::int64_t now_ms) { + const std::string norm = normalize_memory_title(title); + for (const auto& tomb : tombstones(scope_key, now_ms)) { + if (normalize_memory_title(tomb.name) == normalize_memory_title(name)) return true; + if (!norm.empty() && tomb.title_norm == norm) return true; + } + return false; +} + +int MemoryStateStore::purge_expired_tombstones(std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return 0; + Statement stmt(db_.get(), "DELETE FROM tombstones WHERE deleted_ms <= ?1;"); + if (!stmt) return 0; + stmt.integer(1, now_ms - kTombstoneTtlMs); + if (!stmt.done()) return 0; + return sqlite3_changes(db_.get()); +} + +bool MemoryStateStore::set_status(const std::string& key, const std::string& value, + std::int64_t now_ms) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return false; + Statement stmt(db_.get(), + "INSERT INTO status(key, value, updated_ms) VALUES(?1, ?2, ?3) ON CONFLICT(key) " + "DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms;"); + if (!stmt) return false; + stmt.text(1, key).text(2, value).integer(3, now_ms); + return stmt.done(); +} + +std::optional> MemoryStateStore::status( + const std::string& key) { + std::lock_guard lock(mu_); + if (!open_locked(nullptr)) return std::nullopt; + Statement stmt(db_.get(), "SELECT value, updated_ms FROM status WHERE key = ?1;"); + if (!stmt) return std::nullopt; + stmt.text(1, key); + if (stmt.step() != SQLITE_ROW) return std::nullopt; + return std::make_pair(stmt.column_text(0), stmt.column_int(1)); +} + +} // namespace acecode diff --git a/src/domain/memory/memory_state_store.hpp b/src/domain/memory/memory_state_store.hpp new file mode 100644 index 000000000..c1eaaf01c --- /dev/null +++ b/src/domain/memory/memory_state_store.hpp @@ -0,0 +1,154 @@ +#pragma once + +#include "platform/unique_sqlite.hpp" + +#include +#include +#include +#include +#include +#include + +namespace acecode { + +// 后台作业的两类租约:按会话提炼、按作用域整合。 +enum class MemoryLeaseKind { + Extraction, + Consolidation, +}; + +struct MemoryExtractionProgress { + std::string session_id; + std::string project_dir; + std::int64_t next_message_index = 0; // 已提炼到的位置(下次从这条消息开始) + int attempts = 0; // 当前范围连续失败次数 + std::string last_error; + std::string failed_marker; // 失败时的会话活动标记;活动变化后重新计数 + std::string seen_marker; // 上次成功提炼时的会话活动标记 + std::string lease_owner; + std::int64_t lease_expires_ms = 0; + std::int64_t updated_ms = 0; +}; + +struct MemoryConsolidationProgress { + std::string scope_key; + int attempts = 0; + std::string last_error; + std::string failed_batch; // 连续失败的批次签名;出现新观察后重新计数 + std::string pending_plan; // 已校验、尚未完整应用的计划(崩溃后原样重放) + std::string pending_plan_hash; + std::string last_plan_hash; + std::string lease_owner; + std::int64_t lease_expires_ms = 0; + std::int64_t last_success_ms = 0; + std::int64_t updated_ms = 0; +}; + +struct MemoryTombstone { + std::string scope_key; + std::string name; + std::string title_norm; + std::int64_t deleted_ms = 0; +}; + +// 所有进程共用的记忆状态库 /memory/state.sqlite3(D5):提炼进度、 +// 整合进度、墓碑、状态摘要四张表。连接开 WAL 与 busy_timeout;每个进程(测试里 +// 每个实例)各持一个连接,进程内由递归锁串行化。BEGIN IMMEDIATE 事务兼作作用域 +// 写锁(D6):写条目 / 重建索引 / 应用整合都在它里面完成,别的进程同一时刻只能等。 +class MemoryStateStore { +public: + static constexpr std::int64_t kTombstoneTtlMs = 90LL * 24 * 60 * 60 * 1000; + + explicit MemoryStateStore(std::filesystem::path db_path); + ~MemoryStateStore(); + MemoryStateStore(const MemoryStateStore&) = delete; + MemoryStateStore& operator=(const MemoryStateStore&) = delete; + + // 惰性打开;可重复调用。失败时返回 false 并写 error。 + bool open(std::string* error = nullptr); + const std::filesystem::path& path() const { return db_path_; } + + // 租约:持有者令牌 + 到期时间;过期即可被别的持有者接管。 + bool try_acquire_lease(MemoryLeaseKind kind, const std::string& key, + const std::string& owner, std::int64_t ttl_ms, + std::int64_t now_ms); + bool renew_lease(MemoryLeaseKind kind, const std::string& key, + const std::string& owner, std::int64_t ttl_ms, std::int64_t now_ms); + void release_lease(MemoryLeaseKind kind, const std::string& key, const std::string& owner); + + std::optional extraction(const std::string& session_id); + // 提交提炼位置:只前进不后退(崩溃重放同一范围不会把位置拉回去),attempts 清零, + // 记下本次看到的会话活动标记(没有新活动就不再挑中该会话)。 + bool commit_extraction(const std::string& session_id, const std::string& project_dir, + std::int64_t next_message_index, const std::string& seen_marker, + std::int64_t now_ms); + bool record_extraction_failure(const std::string& session_id, + const std::string& project_dir, + const std::string& error, const std::string& marker, + std::int64_t now_ms); + bool forget_extraction(const std::string& session_id); + + std::optional consolidation(const std::string& scope_key); + bool set_pending_plan(const std::string& scope_key, const std::string& plan, + const std::string& plan_hash, std::int64_t now_ms); + bool commit_consolidation(const std::string& scope_key, const std::string& plan_hash, + std::int64_t now_ms); + bool record_consolidation_failure(const std::string& scope_key, const std::string& error, + const std::string& batch_signature, std::int64_t now_ms); + bool clear_consolidation(const std::string& scope_key); + + bool add_tombstone(const std::string& scope_key, const std::string& name, + const std::string& title, std::int64_t now_ms); + std::vector tombstones(const std::string& scope_key, std::int64_t now_ms); + // 名字精确匹配或规范化标题相同(忽略大小写)且未过期即命中。 + bool is_tombstoned(const std::string& scope_key, const std::string& name, + const std::string& title, std::int64_t now_ms); + int purge_expired_tombstones(std::int64_t now_ms); + + bool set_status(const std::string& key, const std::string& value, std::int64_t now_ms); + std::optional> status(const std::string& key); + +private: + friend class MemoryWriteLock; + bool open_locked(std::string* error); + bool exec_locked(const char* sql, std::string* error = nullptr); + + std::filesystem::path db_path_; + std::recursive_mutex mu_; + platform::UniqueSqlite db_; + int tx_depth_ = 0; +}; + +// 跨进程写锁(D6)的 RAII 守卫:构造时在状态库上开 BEGIN IMMEDIATE,commit() +// 提交,未提交就析构则回滚。同一线程嵌套构造复用外层事务。store 为空或库不可用 +// (打不开 / 等锁超时)时 acquired()=false,调用方仍持有进程内锁继续写 —— +// 记忆写入不能因为状态库出问题而失败。 +class MemoryWriteLock { +public: + explicit MemoryWriteLock(MemoryStateStore* store); + ~MemoryWriteLock(); + MemoryWriteLock(const MemoryWriteLock&) = delete; + MemoryWriteLock& operator=(const MemoryWriteLock&) = delete; + + bool acquired() const { return acquired_; } + const std::string& error() const { return error_; } + bool commit(); + +private: + MemoryStateStore* store_ = nullptr; + std::unique_lock lock_; + bool acquired_ = false; + bool nested_ = false; + bool finished_ = false; + std::string error_; +}; + +// 标题规范化:去掉首尾空白、ASCII 转小写、连续空白折成一个空格。墓碑按它比较。 +std::string normalize_memory_title(const std::string& title); + +// 租约持有者令牌「pid--<随机 hex>」,同进程内每次调用不同。 +std::string make_memory_lease_owner(); + +std::int64_t memory_now_ms(); + +} // namespace acecode diff --git a/src/domain/memory/memory_types.cpp b/src/domain/memory/memory_types.cpp index d6ac9049f..874ee654e 100644 --- a/src/domain/memory/memory_types.cpp +++ b/src/domain/memory/memory_types.cpp @@ -20,4 +20,20 @@ std::optional parse_memory_type(const std::string& s) { return std::nullopt; } +std::string memory_scope_to_string(MemoryScope s) { + return s == MemoryScope::Workspace ? "workspace" : "global"; +} + +std::optional parse_memory_scope(const std::string& s) { + if (s == "global") return MemoryScope::Global; + if (s == "workspace") return MemoryScope::Workspace; + return std::nullopt; +} + +MemoryScope default_memory_scope_for_type(MemoryType type) { + return type == MemoryType::Project || type == MemoryType::Reference + ? MemoryScope::Workspace + : MemoryScope::Global; +} + } // namespace acecode diff --git a/src/domain/memory/memory_types.hpp b/src/domain/memory/memory_types.hpp index 68168b1ea..b117ddf2e 100644 --- a/src/domain/memory/memory_types.hpp +++ b/src/domain/memory/memory_types.hpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace acecode { @@ -21,7 +22,25 @@ enum class MemoryType { std::string memory_type_to_string(MemoryType t); std::optional parse_memory_type(const std::string& s); -// One memory entry loaded from a file under ~/.acecode/memory/.md. +// 记忆作用域:全局(跨项目的个人偏好,/memory/)与工作区 +// (/projects//memory/,hash 与该工作区会话存储同源)。 +enum class MemoryScope { + Global = 0, + Workspace = 1, +}; + +std::string memory_scope_to_string(MemoryScope s); +std::optional parse_memory_scope(const std::string& s); + +// 未显式指定作用域时按类型推断:个人偏好与协作反馈跟人走(全局), +// 项目背景与外部指针跟工作区走。没有所属工作区的会话一律落全局。 +MemoryScope default_memory_scope_for_type(MemoryType type); + +// 条目来源:手写(模型显式 memory_write、用户在设置页编辑)或记忆摘要整合生成。 +inline constexpr const char* kMemorySourceManual = "manual"; +inline constexpr const char* kMemorySourceSummary = "summary"; + +// One memory entry loaded from a file under a scope directory (/.md). // `body` is the post-frontmatter markdown; `path` is the absolute on-disk path // so tools/commands can report it to the user. struct MemoryEntry { @@ -30,6 +49,14 @@ struct MemoryEntry { MemoryType type = MemoryType::User; std::filesystem::path path; std::string body; + // 系统维护的来源字段(UTC ISO-8601)。旧条目可能为空,下一次被系统写入时补齐。 + std::string created_at; + std::string updated_at; + std::string source; // manual | summary | 空(旧条目) + std::vector source_sessions; // 来源会话 id + // frontmatter 里未知字段的原始文本(逐行、带换行);写回时原样输出, + // 让用户或其它工具加的字段不会被 ACECode 的写入抹掉。 + std::string extra_frontmatter; }; } // namespace acecode diff --git a/src/domain/memory/secret_redaction.cpp b/src/domain/memory/secret_redaction.cpp new file mode 100644 index 000000000..0a43f9274 --- /dev/null +++ b/src/domain/memory/secret_redaction.cpp @@ -0,0 +1,266 @@ +#include "secret_redaction.hpp" + +#include +#include + +namespace acecode { + +namespace { + +constexpr std::string_view kMarker = "[REDACTED]"; +// 全角冒号「:」(U+FF1A)的 UTF-8 字节。 +constexpr std::string_view kFullWidthColon = "\xEF\xBC\x9A"; +// 「密码」的 UTF-8 字节。 +constexpr std::string_view kChinesePassword = "\xE5\xAF\x86\xE7\xA0\x81"; + +bool is_ascii_alpha(unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +bool is_ascii_digit(unsigned char c) { return c >= '0' && c <= '9'; } + +bool is_ascii_alnum(unsigned char c) { return is_ascii_alpha(c) || is_ascii_digit(c); } + +bool is_token_char(unsigned char c) { return is_ascii_alnum(c) || c == '_' || c == '-'; } + +unsigned char lower(unsigned char c) { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +bool starts_with_icase(std::string_view s, std::size_t i, std::string_view word) { + if (s.size() - i < word.size()) return false; + for (std::size_t k = 0; k < word.size(); ++k) { + if (lower(static_cast(s[i + k])) != + static_cast(word[k])) { + return false; + } + } + return true; +} + +bool starts_with(std::string_view s, std::size_t i, std::string_view word) { + return s.size() - i >= word.size() && s.compare(i, word.size(), word) == 0; +} + +struct Match { + std::size_t consumed = 0; // 从 i 起被替换的字节数 + std::string replacement; // 替换文本 + bool counted = true; // 已是 [REDACTED] 的值不再计数 +}; + +// PEM 私钥块:-----BEGIN