From bbcb39e246bec4b6c1cbdf9d6a1a4a6800190361 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Wed, 30 Sep 2026 23:42:14 +0800 Subject: [PATCH 1/3] Fix upgrade probe timeout and context amnesia from feedback Upgrade (ZHAOZEYIN831, 0.9.26 -> 0.9.27): the staged acecode.exe --version probe had a 5 s limit; endpoint security scanning the new executable on first launch exceeded it and rolled the upgrade back. Raise the default to 120 s and report the wait and likely cause. Context amnesia (LINDANDAN069, aicoder-pro via the PA gateway): - PA overflow rescue and the compaction fallback now clear old tool outputs first, then condense old turns to user messages, summaries and final replies, and only then drop whole turns while keeping the latest compaction summary. The old order deleted the original task, the user's corrections and the summary for little gain. - The recent-output protection counts tool results by position, so a short latest result no longer shields an older large one. - file_read's unchanged-read cache is keyed by session and cleared after thread repairs, so another session or a pruned result can no longer turn a real read into an "unchanged" stub. - The daemon (desktop/web) now wires the memory registry, index injection and memory_read/memory_write like the TUI; registry writes rescan disk first so concurrent daemons do not drop each other's index lines. The system prompt routes "remember this" requests to memory_write instead of ad-hoc notes files. - Compaction asks the summarizer to keep user instructions, lessons learned and verified reusable code, and appends a deterministic list of skills loaded before the checkpoint to reload. --- CLAUDE.md | 10 +- src/adapters/pa/README.md | 16 +- src/adapters/pa/pa_rescue_driver.cpp | 21 ++ src/adapters/tool/file_read_tool.cpp | 7 +- src/adapters/tool/memory_read_tool.cpp | 3 + src/adapters/tool/mtime_tracker.cpp | 25 ++- src/adapters/tool/mtime_tracker.hpp | 25 ++- src/adapters/upgrade/executable_version.cpp | 16 +- src/adapters/upgrade/executable_version.hpp | 10 +- src/apps/daemon/worker.cpp | 32 ++- src/domain/memory/memory_registry.cpp | 8 + src/domain/memory/memory_registry.hpp | 1 + src/domain/session/thread_repair.cpp | 211 +++++++++++++----- src/domain/session/thread_repair.hpp | 22 +- src/engine/agent/compaction/compact.cpp | 64 ++++++ .../agent/compaction/compact_prompt.cpp | 8 +- .../compaction/compaction_controller.cpp | 17 +- .../agent/tool_exec/tool_result_committer.cpp | 8 +- src/engine/prompt/system_prompt.cpp | 21 ++ tests/agent/agent_loop_pa_rescue_test.cpp | 70 ++++++ tests/agent/compact_core_test.cpp | 69 ++++++ tests/memory/memory_registry_test.cpp | 30 +++ tests/prompt/system_prompt_test.cpp | 24 ++ tests/session/thread_repair_test.cpp | 177 +++++++++++++++ tests/tool/file_read_tool_summary_test.cpp | 33 +++ tests/tool/mtime_tracker_test.cpp | 42 ++++ tests/upgrade/executable_version_test.cpp | 17 +- web/src/i18n/catalogs/systemNotice.js | 8 +- web/src/lib/systemNotice.js | 2 +- web/src/lib/systemNotice.test.js | 19 ++ 30 files changed, 913 insertions(+), 103 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ed603f06..e9892a5ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -316,6 +316,10 @@ Proactive skill discovery (`inject-skill-index-into-context`): a compact skill i [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. +记忆在 TUI 与 daemon(桌面版 / Web)两端都接线: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_write` 时带一段 `# Memory` 指引(「记住」一律走 `memory_write`,别另写笔记文件)。headless 仍不注册记忆工具。 + +`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. ## Daemon And Web UI @@ -422,8 +426,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 最初确认拒收的规模,紧急档请求永远不记。 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/tool/file_read_tool.cpp b/src/adapters/tool/file_read_tool.cpp index d70791221..1430fd3d0 100644 --- a/src/adapters/tool/file_read_tool.cpp +++ b/src/adapters/tool/file_read_tool.cpp @@ -735,6 +735,7 @@ ToolResult execute_file_read( return ToolResult{ToolErrors::cannot_open_file(request.file_path), false}; } + // 按会话去重:同一进程里别的会话(含子代理)读过,不代表这个会话见过内容。 auto unchanged_observation = MtimeTracker::instance().unchanged_read_observation( request.file_path, @@ -742,7 +743,8 @@ 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 +965,8 @@ 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..f12a413ad 100644 --- a/src/adapters/tool/memory_read_tool.cpp +++ b/src/adapters/tool/memory_read_tool.cpp @@ -47,6 +47,9 @@ ToolImpl create_memory_read_tool(MemoryRegistry& registry, } catch (...) { return ToolResult{"[Error] Failed to parse tool arguments.", false}; } + // 另一个进程(别的工作区的 daemon、TUI)可能刚写过新条目;目录里只有 + // 几个小文件,每次读前重扫的代价可以忽略。 + registry.reload(); if (!name.empty()) { auto found = registry.find(name); 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/worker.cpp b/src/apps/daemon/worker.cpp index e167bb195..716c3353d 100644 --- a/src/apps/daemon/worker.cpp +++ b/src/apps/daemon/worker.cpp @@ -57,6 +57,10 @@ #include "tool/grep_tool.hpp" #include "tool/glob_tool.hpp" #include "tool/goal_tool.hpp" +#include "memory/memory_paths.hpp" +#include "memory/memory_registry.hpp" +#include "tool/memory_read_tool.hpp" +#include "tool/memory_write_tool.hpp" #include "tool/skill_view_tool.hpp" #include "tool/skills_tool.hpp" #include "tool/task_complete_tool.hpp" @@ -473,6 +477,27 @@ 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()); + // 记忆(~/.acecode/memory):与 TUI(tui_runtime_init.cpp 的 + // initialize_memory_registry)同一套初始化与工具,先于 ToolExecutor 构造, + // 工具闭包引用它、析构顺序要反过来。daemon 曾经整条缺失(memory_registry = + // nullptr):桌面版 / Web 会话既没有记忆索引注入也没有记忆工具,用户让模型 + // 「记住」时它只能把经验写进不会被自动加载的文件(反馈 LINDANDAN069)。 + acecode::MemoryRegistry memory_registry; + acecode::MemoryConfig runtime_memory_cfg = cfg_mut.memory; + { + std::error_code memory_dir_ec; + std::filesystem::create_directories(acecode::get_memory_dir(), memory_dir_ec); + if (memory_dir_ec) { + LOG_ERROR("[memory] failed to create " + + acecode::get_memory_dir().generic_string() + ": " + + memory_dir_ec.message() + + " - memory will be disabled for this daemon"); + runtime_memory_cfg.enabled = false; + } else if (runtime_memory_cfg.enabled) { + memory_registry.scan(); + } + } + acecode::ToolExecutor tools; acecode::register_session_builtin_tools(tools, cfg_mut); // daemon 用 async 版本(走 ToolContext::ask_user_questions → AskUserQuestionPrompter @@ -485,6 +510,9 @@ 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)); + tools.register_tool(acecode::create_memory_read_tool( + memory_registry, runtime_memory_cfg.max_index_bytes)); + tools.register_tool(acecode::create_memory_write_tool(memory_registry)); // spawn_subagent / wait_subagent:daemon 专属(TUI 无 SessionRegistry 不注册)。 // ToolExecutor 先于 SessionRegistry 构造,deps 用 shared_ptr 延迟回填 —— 回填 @@ -516,8 +544,8 @@ 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_registry = &memory_registry; + reg_deps.memory_cfg = &runtime_memory_cfg; reg_deps.project_instructions_cfg = &cfg_mut.project_instructions; reg_deps.custom_instructions_cfg = &cfg_mut.custom_instructions; reg_deps.hook_manager = &hook_manager; diff --git a/src/domain/memory/memory_registry.cpp b/src/domain/memory/memory_registry.cpp index 3319f8bd1..1e0eccfca 100644 --- a/src/domain/memory/memory_registry.cpp +++ b/src/domain/memory/memory_registry.cpp @@ -60,6 +60,10 @@ std::string atomic_write(const fs::path& target, const std::string& content) { void MemoryRegistry::scan() { std::lock_guard lock(mu_); + scan_locked(); +} + +void MemoryRegistry::scan_locked() { entries_.clear(); fs::path dir = get_memory_dir(); @@ -175,6 +179,9 @@ std::optional MemoryRegistry::upsert(const std::string& name, } std::lock_guard lock(mu_); + // 写前按磁盘重扫:TUI 与每个工作区的 daemon 各持一份缓存,拿启动时的旧缓存 + // 重写 MEMORY.md 会把别的进程后来写的条目当成「已删除」从索引里丢掉。 + scan_locked(); auto existing = find_locked(name); bool file_exists = existing != entries_.end(); @@ -228,6 +235,7 @@ bool MemoryRegistry::remove(const std::string& name, std::string& error_out) { } std::lock_guard lock(mu_); + scan_locked(); auto it = find_locked(name); if (it == entries_.end()) { diff --git a/src/domain/memory/memory_registry.hpp b/src/domain/memory/memory_registry.hpp index f991fdc1d..6a2ad035a 100644 --- a/src/domain/memory/memory_registry.hpp +++ b/src/domain/memory/memory_registry.hpp @@ -66,6 +66,7 @@ class MemoryRegistry { 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(); diff --git a/src/domain/session/thread_repair.cpp b/src/domain/session/thread_repair.cpp index bd34a51b1..6d59d4029 100644 --- a/src/domain/session/thread_repair.cpp +++ b/src/domain/session/thread_repair.cpp @@ -25,6 +25,71 @@ int single_message_tokens(const ChatMessage& message) { return estimate_message_tokens(std::vector{message}); } +// 精简老回合时删掉的消息:工具结果、带工具调用的助手消息、没有正文的助手 +// 消息。用户消息(含压缩摘要、内部上下文)和助手的纯文本结论都留下 —— 只留 +// 用户消息不留结论,模型会以为老任务还没做、从头再做一遍。 +bool removed_when_thinning(const ChatMessage& message) { + if (message.role == "tool") return true; + if (message.role != "assistant") return false; + if (message.tool_calls.is_array() && !message.tool_calls.empty()) { + return true; + } + return message.content.find_first_not_of(" \t\r\n") == std::string::npos; +} + +// 从最旧的老回合开始逐组精简,直到规模降到 target_tokens 以下(must_thin_one +// 时至少精简一组)。最后一组(含当前输入)不动。返回被精简的组数。 +int thin_oldest_turns(std::vector& messages, + int target_tokens, + bool must_thin_one) { + const auto starts = real_user_starts(messages); + if (starts.size() < 2) return 0; + std::vector drop(messages.size(), false); + int current = estimate_message_tokens(messages); + int thinned = 0; + for (std::size_t group = 0; group + 1 < starts.size(); ++group) { + const bool over_target = target_tokens > 0 && current > target_tokens; + if (!over_target && !(must_thin_one && thinned == 0)) break; + const std::size_t begin = group == 0 ? 0 : starts[group]; + bool changed = false; + for (std::size_t i = begin; i < starts[group + 1]; ++i) { + if (!removed_when_thinning(messages[i])) continue; + drop[i] = true; + current -= single_message_tokens(messages[i]); + changed = true; + } + if (changed) ++thinned; + } + if (thinned == 0) return 0; + std::vector kept; + kept.reserve(messages.size()); + for (std::size_t i = 0; i < messages.size(); ++i) { + if (!drop[i]) kept.push_back(std::move(messages[i])); + } + messages = std::move(kept); + return thinned; +} + +std::string repair_reason(const ThreadRepairResult& result) { + std::vector parts; + if (result.cleared_tool_outputs > 0) { + parts.push_back("old tool outputs were cleared"); + } + if (result.thinned_groups > 0) { + parts.push_back( + "old turns were thinned to user messages and final replies"); + } + if (result.pruned_groups > 0) { + parts.push_back("old completed user-turn groups were pruned"); + } + if (parts.empty()) return "provider history structure was recovered"; + std::string reason = parts.front(); + for (std::size_t i = 1; i < parts.size(); ++i) { + reason += (i + 1 == parts.size() ? " and " : ", ") + parts[i]; + } + return reason; +} + // 工具调用参数超过这个体积才值得清:小参数换成占位 JSON 省不了几个 token, // 却让模型看不见自己刚才调了什么。 constexpr std::size_t kClearableToolArgumentsBytes = 4 * 1024; @@ -62,34 +127,45 @@ void clear_tool_call_arguments(ChatMessage& message) { } } +// 最近 keep_recent 条满足 matches 的消息之前的位置;这之前的才允许清。 +// 按位置数而不是只数「值得清的」:最近一条工具结果哪怕很短也占一个名额, +// 否则保护会落到更早的一条大输出上,该清的反而清不掉。 +template +std::size_t protected_tail_begin(const std::vector& messages, + int keep_recent, Predicate matches) { + std::size_t remaining = + keep_recent < 0 ? 0 : static_cast(keep_recent); + std::size_t begin = messages.size(); + while (remaining > 0 && begin > 0) { + --begin; + if (matches(messages[begin])) --remaining; + } + return remaining > 0 ? 0 : begin; +} + // 从最旧往最新把工具输出换成占位符,直到历史规模降到 target_tokens 以下。 -// 最近 keep_recent 条不动。第一遍只清工具输出(role == "tool");不够再清 -// 体积大的工具调用参数。返回被清的条数。 +// 最近 keep_recent 条工具结果 / 工具调用不动。第一遍只清工具输出 +// (role == "tool");不够再清体积大的工具调用参数。返回被清的条数。 int clear_oldest_tool_outputs(std::vector& messages, int target_tokens, int keep_recent) { int current = estimate_message_tokens(messages); if (current <= target_tokens) return 0; - const std::size_t protect = - keep_recent < 0 ? 0 : static_cast(keep_recent); int cleared = 0; // 比占位符还短的输出不值得清:换上去反而更长。 const std::size_t placeholder_bytes = std::char_traits::length(kClearedToolOutputPlaceholder); - std::vector outputs; - for (std::size_t i = 0; i < messages.size(); ++i) { - if (messages[i].role == "tool" && - messages[i].content != kClearedToolOutputPlaceholder && - messages[i].content.size() > placeholder_bytes) { - outputs.push_back(i); + const std::size_t outputs_end = protected_tail_begin( + messages, keep_recent, + [](const ChatMessage& message) { return message.role == "tool"; }); + for (std::size_t i = 0; i < outputs_end && current > target_tokens; ++i) { + ChatMessage& message = messages[i]; + if (message.role != "tool" || + message.content == kClearedToolOutputPlaceholder || + message.content.size() <= placeholder_bytes) { + continue; } - } - const std::size_t clearable_outputs = - outputs.size() > protect ? outputs.size() - protect : 0; - for (std::size_t k = 0; k < clearable_outputs && current > target_tokens; - ++k) { - ChatMessage& message = messages[outputs[k]]; const int before = single_message_tokens(message); message.content = kClearedToolOutputPlaceholder; message.content_parts = nlohmann::json(); @@ -98,15 +174,15 @@ int clear_oldest_tool_outputs(std::vector& messages, } if (current <= target_tokens) return cleared; - std::vector calls; - for (std::size_t i = 0; i < messages.size(); ++i) { - if (tool_call_arguments_clearable(messages[i])) calls.push_back(i); - } - const std::size_t clearable_calls = - calls.size() > protect ? calls.size() - protect : 0; - for (std::size_t k = 0; k < clearable_calls && current > target_tokens; - ++k) { - ChatMessage& message = messages[calls[k]]; + const std::size_t calls_end = protected_tail_begin( + messages, keep_recent, [](const ChatMessage& message) { + return message.role == "assistant" && + message.tool_calls.is_array() && + !message.tool_calls.empty(); + }); + for (std::size_t i = 0; i < calls_end && current > target_tokens; ++i) { + ChatMessage& message = messages[i]; + if (!tool_call_arguments_clearable(message)) continue; const int before = single_message_tokens(message); clear_tool_call_arguments(message); current -= before - single_message_tokens(message); @@ -140,36 +216,69 @@ ThreadRepairResult plan_thread_repair( result.pre_tokens = estimate_message_tokens(recovered.messages); result.replacement_history = recovered.messages; - const auto starts = real_user_starts(recovered.messages); - std::size_t keep_from = 0; const bool target_requires_prune = options.target_tokens > 0 && result.pre_tokens > options.target_tokens; + std::vector working = recovered.messages; + + // 第一步:清工具输出(与调用参数)。工具输出最占地方又能重新获取,先清它, + // 老回合里的任务说明与纠正才留得住。 + if (options.clear_tool_outputs && options.target_tokens > 0) { + result.cleared_tool_outputs = clear_oldest_tool_outputs( + working, options.target_tokens, options.keep_recent_tool_outputs); + } + // force_prune_one_group 保证「修复即有进展」:前面已经腾出空间就不再强制丢组。 + bool must_make_progress = + options.force_prune_one_group && result.cleared_tool_outputs == 0; + + // 第二步:精简老回合,只删工具往返,保留用户消息、摘要与纯文本结论。 + if (options.thin_old_turns_first) { + result.thinned_groups = thin_oldest_turns( + working, options.target_tokens, must_make_progress); + if (result.thinned_groups > 0) must_make_progress = false; + } + + // 第三步:整组丢弃最旧的回合。精简模式下保留被丢部分里最新的一份压缩 + // 摘要 —— 它是之前所有回合的唯一记忆。 + const auto starts = real_user_starts(working); + std::size_t keep_from = 0; + auto retained_view = [&](std::size_t from) { + std::vector retained; + const bool retained_has_summary = std::any_of( + working.begin() + static_cast(from), working.end(), + [](const ChatMessage& message) { + return is_compact_summary_message(message); + }); + if (options.thin_old_turns_first && !retained_has_summary) { + for (std::size_t i = from; i > 0; --i) { + if (is_compact_summary_message(working[i - 1])) { + retained.push_back(working[i - 1]); + break; + } + } + } + retained.insert(retained.end(), + working.begin() + static_cast(from), + working.end()); + return retained; + }; + int current_tokens = estimate_message_tokens(working); while (result.pruned_groups + 1 < static_cast(starts.size())) { - const bool force_first = options.force_prune_one_group && - result.pruned_groups == 0; - const int current_tokens = keep_from == 0 - ? result.pre_tokens - : estimate_message_tokens(std::vector( - recovered.messages.begin() + - static_cast(keep_from), - recovered.messages.end())); + const bool force_first = must_make_progress && result.pruned_groups == 0; const bool over_target = options.target_tokens > 0 && current_tokens > options.target_tokens; if (!force_first && !over_target) break; ++result.pruned_groups; keep_from = starts[static_cast(result.pruned_groups)]; + current_tokens = estimate_message_tokens(retained_view(keep_from)); } - std::vector retained( - recovered.messages.begin() + static_cast(keep_from), - recovered.messages.end()); - if (options.clear_tool_outputs && options.target_tokens > 0) { - result.cleared_tool_outputs = clear_oldest_tool_outputs( - retained, options.target_tokens, options.keep_recent_tool_outputs); - } - if (keep_from > 0 || result.cleared_tool_outputs > 0) { - result.pruned_messages = static_cast(keep_from); + if (keep_from > 0 || result.cleared_tool_outputs > 0 || + result.thinned_groups > 0) { + const std::vector retained = retained_view(keep_from); + result.pruned_messages = static_cast( + recovered.messages.size() - (std::min)(recovered.messages.size(), + retained.size())); result.replacement_history = recover_provider_history(retained).messages; } @@ -178,7 +287,7 @@ ThreadRepairResult plan_thread_repair( const bool recovery_changed = result.history_issues.changed() || result.load_issues.recovered(); if (result.pruned_groups == 0 && result.cleared_tool_outputs == 0 && - !recovery_changed) { + result.thinned_groups == 0 && !recovery_changed) { const bool cannot_meet_target = target_requires_prune || options.force_prune_one_group; result.status = cannot_meet_target @@ -201,17 +310,7 @@ ThreadRepairResult plan_thread_repair( result.checkpoint.post_tokens = result.post_tokens; result.checkpoint.replacement_history = result.replacement_history; result.status = ThreadRepairStatus::Repaired; - if (result.pruned_groups > 0 && result.cleared_tool_outputs > 0) { - result.reason = - "old completed user-turn groups were pruned and old tool outputs " - "were cleared"; - } else if (result.pruned_groups > 0) { - result.reason = "old completed user-turn groups were pruned"; - } else if (result.cleared_tool_outputs > 0) { - result.reason = "old tool outputs were cleared"; - } else { - result.reason = "provider history structure was recovered"; - } + result.reason = repair_reason(result); return result; } diff --git a/src/domain/session/thread_repair.hpp b/src/domain/session/thread_repair.hpp index 81853995e..fd04820f9 100644 --- a/src/domain/session/thread_repair.hpp +++ b/src/domain/session/thread_repair.hpp @@ -31,14 +31,22 @@ struct ThreadRepairOptions { std::string trigger = "repair-manual"; int target_tokens = 0; bool force_prune_one_group = false; - // 老回合丢完(或只剩当前回合)仍超 target_tokens 时,继续在保留的历史里 - // 把最旧的工具输出换成占位符腾空间;工具输出清完还不够,再把体积超过 - // 4KB 的工具调用参数(典型是 file_write 的整篇文件内容)换成占位 JSON。 - // 两遍都从最旧往最新清,最近 keep_recent_tool_outputs 条工具结果 / 调用 - // 不动 —— 模型要靠最新那条结果继续干活。默认关闭,只有明确要「不惜代价 - // 腾出空间」的调用方(PA 兜底、摘要压缩失败后的机械兜底)打开。 + // 超 target_tokens 时**先于**丢老回合,把整段历史里最旧的工具输出换成 + // 占位符腾空间;工具输出清完还不够,再把体积超过 4KB 的工具调用参数 + // (典型是 file_write 的整篇文件内容)换成占位 JSON。两遍都从最旧往最新清, + // 最近 keep_recent_tool_outputs 条工具结果 / 调用不动 —— 模型要靠最新那条 + // 结果继续干活。默认关闭,只有明确要「不惜代价腾出空间」的调用方(PA 兜底、 + // 摘要压缩失败后的机械兜底)打开。 + // 先清输出、后丢回合:工具输出是历史里最占地方、也最能重新获取的部分; + // 老回合里的用户消息才是任务要求与纠正,丢了模型就「失忆」(反馈 + // LINDANDAN069:先丢整组回合的旧顺序把最初的任务说明、用户的多次纠正和 + // 压缩摘要一起删掉,而实际省下的空间主要来自随后清掉的工具输出)。 bool clear_tool_outputs = false; int keep_recent_tool_outputs = 1; + // 需要丢老回合时先「精简」:只删其中的工具调用与工具结果,保留用户消息、 + // 压缩摘要和助手的纯文本结论;精简完仍超目标才整组丢弃,而且整组丢弃时 + // 保留最新一份压缩摘要。默认关闭(手动修复与通用恢复链保持整组丢弃)。 + bool thin_old_turns_first = false; }; struct ThreadRepairResult { @@ -53,6 +61,8 @@ struct ThreadRepairResult { int pruned_messages = 0; // clear_tool_outputs 打开时被换成占位符的工具输出 + 工具调用参数条数。 int cleared_tool_outputs = 0; + // thin_old_turns_first 打开时被精简(删掉工具往返、保留用户消息与结论)的老回合数。 + int thinned_groups = 0; std::string reason; bool repaired() const { return status == ThreadRepairStatus::Repaired; } diff --git a/src/engine/agent/compaction/compact.cpp b/src/engine/agent/compaction/compact.cpp index 87b6c2f0f..ad89c6286 100644 --- a/src/engine/agent/compaction/compact.cpp +++ b/src/engine/agent/compaction/compact.cpp @@ -3,6 +3,7 @@ #include "compact_prompt.hpp" #include "session/compact_checkpoint.hpp" #include "session/session_history_recovery.hpp" +#include "llm/tool_protocol_names.hpp" #include "pa/pa_quirks.hpp" #include "provider/text_tool_call_recovery.hpp" #include "utils/encoding.hpp" @@ -148,6 +149,68 @@ int estimate_history_tokens(const std::vector& history) { : static_cast(tokens); } +// 压缩前加载过的 skill(skill_view 调用 + 显式提及展开的 片段),按首次 +// 出现顺序去重。skill_view 的结果是工具输出,压缩后一定不在上下文里了。 +std::vector loaded_skill_names( + const std::vector& history) { + std::vector names; + auto add = [&names](const std::string& name) { + if (name.empty() || name.size() > 128) return; + if (std::find(names.begin(), names.end(), name) == names.end()) { + names.push_back(name); + } + }; + static const std::string kFragmentOpen = "\n"; + for (const auto& message : history) { + if (message.role == "assistant" && message.tool_calls.is_array()) { + for (const auto& call : message.tool_calls) { + if (!call.is_object() || !call.contains("function")) continue; + const auto& function = call["function"]; + if (!function.is_object() || + function.value("name", std::string{}) != "skill_view") { + continue; + } + const auto arguments = function.value("arguments", std::string{}); + const auto parsed = + nlohmann::json::parse(arguments, nullptr, false); + if (parsed.is_object() && parsed.contains("name") && + parsed["name"].is_string()) { + add(parsed["name"].get()); + } + } + } else if (message.role == "user") { + std::size_t pos = 0; + while ((pos = message.content.find(kFragmentOpen, pos)) != + std::string::npos) { + pos += kFragmentOpen.size(); + const auto end = message.content.find("", pos); + if (end == std::string::npos) break; + add(message.content.substr(pos, end - pos)); + pos = end; + } + } + } + return names; +} + +// 附在摘要末尾的确定性提醒:不依赖摘要模型记得写。反馈 LINDANDAN069 里 skill +// 刚加载完 19 秒就触发了自动压缩,之后的模型没再加载它,skill 里写好的经验 +// 等于没看过。 +std::string loaded_skills_reminder( + const std::vector& history) { + const auto names = loaded_skill_names(history); + if (names.empty()) return {}; + std::string text = "\n\nSkills loaded before this checkpoint: "; + for (std::size_t i = 0; i < names.size(); ++i) { + if (i > 0) text += ", "; + text += names[i]; + } + text += ". Their full instructions are no longer in context; call `" + + acecode::model_tool_name_for_native("skill_view") + + "` again before relying on any of them."; + return text; +} + int shrink_history_for_overflow(std::vector& history, int overflow_retries) { if (history.empty()) return 0; @@ -510,6 +573,7 @@ CompactResult compact_messages( } } + summary_suffix += loaded_skills_reminder(original_history); result.compacted_messages = build_compacted_history( original_history, summary_suffix, COMPACT_USER_MESSAGE_MAX_TOKENS); const int before_tokens = estimate_message_tokens(original_history); diff --git a/src/engine/agent/compaction/compact_prompt.cpp b/src/engine/agent/compaction/compact_prompt.cpp index 9bebdfd19..a4848fd3d 100644 --- a/src/engine/agent/compaction/compact_prompt.cpp +++ b/src/engine/agent/compaction/compact_prompt.cpp @@ -12,7 +12,13 @@ const std::string kCompactPrompt = "- Current progress and key decisions made\n" "- Important context, constraints, or user preferences\n" "- What remains to be done (clear next steps)\n" - "- Any critical data, examples, or references needed to continue\n\n" + "- Any critical data, examples, or references needed to continue\n" + // ACECode 追加的四条(反馈 LINDANDAN069):压缩后模型忘了用户的原始要求与 + // 纠正、忘了刚总结出的做法,又把已验证的函数重写一遍、重新踩坑。 + "- Every explicit instruction, requirement and correction the user gave, quoted as closely as possible; they must survive this checkpoint\n" + "- Lessons learned in this session: what failed, why, and the approach that finally worked\n" + "- Scripts, helper functions and files that were already written and verified, with exact paths and names, so the next LLM reuses them instead of rewriting them\n" + "- Skills and memory entries that were loaded and still apply, by name, so the next LLM can reload them\n\n" "Be concise, structured, and focused on helping the next LLM seamlessly continue the work." // 以下一段是 ACECode 有意偏离 Codex 原文的追加:压缩请求不带工具表,部分模型 // (实测 dots3)会接着历史里的 tool_calls「做下一步」,把工具调用写成正文当摘要。 diff --git a/src/engine/agent/compaction/compaction_controller.cpp b/src/engine/agent/compaction/compaction_controller.cpp index 3a08e0334..6fc82e9c6 100644 --- a/src/engine/agent/compaction/compaction_controller.cpp +++ b/src/engine/agent/compaction/compaction_controller.cpp @@ -59,6 +59,9 @@ bool CompactionController::mechanical_fallback( // 读了一堆大文件正是摘要请求本身也会被拒的那种场景。 options.clear_tool_outputs = true; options.keep_recent_tool_outputs = 1; + // 摘要已经失败,这次修剪就是唯一的「压缩」:丢老回合前先精简,保住用户 + // 消息、结论与上一份摘要,否则模型连任务说明都一起丢了。 + options.thin_old_turns_first = true; auto repair = history_.repair(inputs.session, options); LOG_WARN("[compact-fallback] mechanical prune after summarization failure; " @@ -68,27 +71,32 @@ bool CompactionController::mechanical_fallback( " pruned_groups=" + std::to_string(repair.pruned_groups) + " cleared_tool_outputs=" + std::to_string(repair.cleared_tool_outputs) + + " thinned_groups=" + std::to_string(repair.thinned_groups) + " target_tokens=" + std::to_string(options.target_tokens) + " reason=" + repair.reason); if (!repair.repaired()) { return false; } - compact_generation_.fetch_add(1, std::memory_order_relaxed); - last_api_total_tokens_.store(0, std::memory_order_relaxed); + mark_history_repaired(); events_.emit(SessionEventKind::AgentProgress, nlohmann::json{ {"phase", "context_repair"}, {"label", "Compaction failed; pruned oldest history instead"}, {"detail", repair.reason}, }); + const std::string thinned_text = repair.thinned_groups > 0 + ? "、精简 " + std::to_string(repair.thinned_groups) + + " 组旧回合(保留用户消息与结论)" + : std::string(); transcript_.emit_transcript_system_message(inputs.session, "[智能压缩] 摘要压缩失败(" + log_truncate(summarization_error, 160) + "),已改为丢弃最旧的 " + std::to_string(repair.pruned_groups) + " 组历史、清除 " + std::to_string(repair.cleared_tool_outputs) + - " 条旧工具输出腾出空间,会话继续。", + " 条旧工具输出" + thinned_text + "腾出空间,会话继续。", make_compact_notice_metadata(compact_notice_id, "warning", false, {{"error", summarization_error}, {"groups", repair.pruned_groups}, + {"thinned", repair.thinned_groups}, {"outputs", repair.cleared_tool_outputs}})); return true; } @@ -298,6 +306,9 @@ void CompactionController::finish_busy(LifetimeRef terminal) void CompactionController::mark_history_repaired() { compact_generation_.fetch_add(1, std::memory_order_relaxed); last_api_total_tokens_.store(0, std::memory_order_relaxed); + // 修复可能清掉 / 丢掉了之前的读取结果;再读同一文件必须拿到真实内容, + // 不能是指向已不存在结果的「文件未变」占位。与摘要压缩后的处理一致。 + environment_.mtime_tracker().clear_read_observations(); } } // namespace acecode::agent diff --git a/src/engine/agent/tool_exec/tool_result_committer.cpp b/src/engine/agent/tool_exec/tool_result_committer.cpp index 6d3032bf2..9d1767727 100644 --- a/src/engine/agent/tool_exec/tool_result_committer.cpp +++ b/src/engine/agent/tool_exec/tool_result_committer.cpp @@ -35,7 +35,10 @@ ToolBatchOutcome ToolResultCommitter::commit(std::vector& slots) { } } - auto record_file_read_result_reference = [](MtimeTracker& tracker, const ToolCall& tc, const ToolResult& result) { + // 与 file_read 记录观测时同一口径(ToolContextFactory 填的会话 id)。 + const std::string read_scope = + session_manager_ ? session_manager_->current_session_id() : std::string{}; + auto record_file_read_result_reference = [&read_scope](MtimeTracker& tracker, const ToolCall& tc, const ToolResult& result) { if (!result.success || tc.function_name != "file_read") return; if (result.output.rfind("File unchanged since last read.", 0) == 0) return; @@ -67,7 +70,8 @@ ToolBatchOutcome ToolResultCommitter::commit(std::vector& slots) { persisted_output_filepath(result.output), byte_mode, uint64_arg("byte_offset"), - static_cast(uint64_arg("max_bytes"))); + static_cast(uint64_arg("max_bytes")), + read_scope); }; for (size_t i = 0; i < slots.size(); ++i) { diff --git a/src/engine/prompt/system_prompt.cpp b/src/engine/prompt/system_prompt.cpp index 86e1f9514..6c4de0e88 100644 --- a/src/engine/prompt/system_prompt.cpp +++ b/src/engine/prompt/system_prompt.cpp @@ -225,6 +225,9 @@ std::string build_system_prompt(const ToolExecutor& tools, const std::string& cw const bool bash_allowed = guidance_allows("bash"); const bool skill_view_allowed = guidance_allows("skill_view"); const bool skills_list_allowed = guidance_allows("skills_list"); + // 记忆工具可能没注册(headless 的工具面):不能让模型去调一个不存在的工具。 + const bool memory_write_allowed = + tools.has_tool("memory_write") && guidance_allows("memory_write"); const bool enter_worktree_allowed = guidance_allows("EnterWorktree"); const bool exit_worktree_allowed = guidance_allows("ExitWorktree"); const std::string file_read_name = @@ -607,6 +610,24 @@ std::string build_system_prompt(const ToolExecutor& tools, const std::string& cw oss << "\nSkill selection is turn-scoped: do not assume a prior turn selected a skill unless the current request names or clearly matches it.\n\n"; } + // 「记住」要落到会被自动注入的地方。反馈 LINDANDAN069:用户让模型记住做法, + // 模型把经验写进了子目录里的 CLAUDE.md / .acecode/MEMORY.md,ACECode 从不 + // 自动加载这些文件,下一轮压缩之后经验就「忘」了。 + if (memory_write_allowed) { + oss << "# Memory\n\n" + << "- When the user asks you to remember something for later (\"remember this\", " + << "\"next time do X\", \"don't repeat this mistake\"), save it with `memory_write`. " + << "The memory index is included in every future request and survives context " + << "compaction.\n" + << "- Do not substitute ad-hoc notes files (MEMORY.md, LESSONS_LEARNED.md, or a " + << "CLAUDE.md/AGENTS.md in a subdirectory): they are not loaded automatically, so the " + << "lesson is lost once it leaves the context. Project instruction files are loaded " + << "only from the working directory and its parent directories.\n" + << "- Put the actionable rule in the memory description (what to do, what to reuse " + << "and where it lives) so it is visible without opening the entry, and call " + << "`memory_read` for the details before starting related work.\n\n"; + } + return oss.str(); } diff --git a/tests/agent/agent_loop_pa_rescue_test.cpp b/tests/agent/agent_loop_pa_rescue_test.cpp index de6aeaa29..3f8b6e64d 100644 --- a/tests/agent/agent_loop_pa_rescue_test.cpp +++ b/tests/agent/agent_loop_pa_rescue_test.cpp @@ -14,12 +14,14 @@ #include "session/session_client.hpp" #include "session/session_storage.hpp" #include "session/thread_repair.hpp" +#include "tool/mtime_tracker.hpp" #include "tool/tool_executor.hpp" #include "test_support/agent/stub_provider.hpp" #include #include #include +#include #include #include #include @@ -296,6 +298,74 @@ TEST(AgentLoopPaRescue, ClearsToolOutputsInsideTheCurrentTurn) { EXPECT_TRUE(has_system_event(events, "清除 1 条旧工具输出")); } +// 触发场景:会话里有一个老回合 —— 用户给出任务要求、模型调了一次工具拿回约 +// 6 万字符的输出、再给出结论;当前回合的请求被 PA 连续拒收(原样重发也被拒)。 +// 期望行为:收缩时先把那条旧工具输出换成占位符,老回合里的任务要求与结论都 +// 留在被接受的请求里;回合正常结束,没有错误事件。 +// 回归背景(反馈 LINDANDAN069,模型 aicoder-pro):旧顺序先整组丢老回合, +// 一次拒收就把最初的任务说明、用户的多次纠正和压缩摘要全部删掉,模型此后 +// 「忘了」任务要求和刚总结出的做法,又去重写已验证过的函数。 +// 6 万字符约 1.5 万 token:远大于测试里 system prompt 与工具表这些固定部分, +// 保证「只清这条输出」就能缩到 85% 目标以下,不用动老回合本身。 +// 另外:收缩前本会话对某文件留有「已读观测」,收缩后必须清掉 —— 读取结果可能 +// 刚被换成占位符,再读同一文件要拿到真实内容,而不是「文件未变、参见先前结果」。 +TEST(AgentLoopPaRescue, ClearsOldToolOutputsBeforeDroppingEarlierInstructions) { + RescueWaitGuard wait_guard; + RescueHarness h("pa_rescue_keep_instructions"); + register_read_only_tool(h.tools, "tool_a", "unused"); + h.loop.push_message(loop_msg("user", "original task: verify every expect")); + acecode::ChatMessage old_call = loop_msg("assistant", ""); + old_call.tool_calls = nlohmann::json::array({nlohmann::json{ + {"id", "old-call"}, + {"type", "function"}, + {"function", {{"name", "tool_a"}, {"arguments", "{}"}}}, + }}); + h.loop.push_message(old_call); + const std::string old_output(60000, 'O'); + acecode::ChatMessage old_result = loop_msg("tool", old_output); + old_result.tool_call_id = "old-call"; + h.loop.push_message(old_result); + h.loop.push_message(loop_msg( + "assistant", "old conclusion: reuse the validated helper")); + // 当前回合先调一次工具:最近一条工具输出按规则受保护,旧输出才是可清的那条。 + register_read_only_tool(h.tools, "tool_b", "fresh result"); + h.provider->push_tool_call("tool_b", "{}", "call-b"); + h.push_pa_errors(3); + h.provider->push_text("accepted without forgetting"); + const auto read_file = h.cwd / "already_read.txt"; + std::ofstream(read_file) << "content the model read earlier"; + const std::string scope = h.session.current_session_id(); + acecode::MtimeTracker::instance().record_read_observation( + read_file.string(), 0, 0, false, 0, 0, scope); + ASSERT_TRUE(acecode::MtimeTracker::instance().has_unchanged_read_observation( + read_file.string(), 0, 0, false, 0, 0, scope)); + + const auto events = wait_for_done(h.loop, [&] { + h.loop.submit("latest user request"); + }); + + EXPECT_FALSE(acecode::MtimeTracker::instance().has_unchanged_read_observation( + read_file.string(), 0, 0, false, 0, 0, scope)) + << "历史被修复后,已读观测必须失效"; + // 1 次工具调用 + 3 次被拒 + 1 次被接受。 + ASSERT_EQ(h.provider->turn_count(), 5); + const auto accepted_request = h.provider->messages_for_turn(4); + EXPECT_TRUE(request_contains(accepted_request, + "original task: verify every expect")) + << "老回合的任务要求不能因为收缩而丢失"; + EXPECT_TRUE(request_contains(accepted_request, + "old conclusion: reuse the validated helper")); + EXPECT_FALSE(request_contains(accepted_request, old_output)) + << "旧工具输出应被占位符替换"; + EXPECT_TRUE(request_contains(accepted_request, + acecode::kClearedToolOutputPlaceholder)); + EXPECT_TRUE(request_contains(accepted_request, "latest user request")); + EXPECT_TRUE(request_contains(accepted_request, "fresh result")) + << "最近一条工具输出必须保留"; + EXPECT_FALSE(has_error_event(events)); + EXPECT_TRUE(has_system_event(events, "已丢弃最旧的 0 组历史、清除 1 条")); +} + // 触发场景:历史里只有当前这一条用户输入,什么都缩不了,服务端仍连续拒收。 // 期望行为:收缩腾不出空间就改紧急档(工具定义被剥掉);紧急档也被拒就按退避 // 等待后原样重发,直到被接受;全程没有错误事件。 diff --git a/tests/agent/compact_core_test.cpp b/tests/agent/compact_core_test.cpp index 26dfb7c4a..898de60f2 100644 --- a/tests/agent/compact_core_test.cpp +++ b/tests/agent/compact_core_test.cpp @@ -884,6 +884,75 @@ TEST(CompactCore, ShortChineseSummaryIsAccepted) { // 触发场景:检查压缩提示词本身。 // 期望行为:保留 Codex 原文开头;末尾追加「不能调工具、不要输出调用标签、用纯文本」, // 同时明确允许引用命令、路径和代码(原提示词要求保留关键数据与引用,不能被削弱)。 +// 触发场景:被压缩的历史里,模型用 skill_view 加载过 paoffice-cdp-techniques +// (结果是一条工具输出),用户消息里还有一段显式提及展开的 片段 commit; +// 同一个 skill 被加载了两次。 +// 期望行为:摘要末尾附上确定性的提醒,按首次出现顺序、去重后列出两个 skill, +// 并要求重新调用 skill_view;摘要模型写的正文原样保留在前面。 +// 回归背景(反馈 LINDANDAN069):skill 刚加载完 19 秒就触发自动压缩,工具输出 +// 被摘要替换,之后的模型再也没加载它,skill 里写好的经验等于没看过。 +TEST(CompactCore, SummaryRemindsToReloadSkillsLoadedBeforeCheckpoint) { + ChatStubProvider provider; + auto first_view = msg("assistant", ""); + first_view.tool_calls = nlohmann::json::array({{ + {"id", "view-1"}, + {"type", "function"}, + {"function", {{"name", "skill_view"}, + {"arguments", R"({"name":"paoffice-cdp-techniques"})"}}}, + }}); + auto second_view = first_view; + second_view.tool_calls[0]["id"] = "view-2"; + auto first_output = msg("tool", "skill body"); + first_output.tool_call_id = "view-1"; + auto second_output = msg("tool", "skill body"); + second_output.tool_call_id = "view-2"; + std::vector messages{ + msg("user", "run all cases", "u1"), + first_view, + first_output, + msg("user", "please $commit\n\n\ncommit\n" + "/skills/commit/SKILL.md\nbody\n", + "u2"), + second_view, + second_output, + }; + + auto result = acecode::compact_messages(provider, messages, {}, true, nullptr); + + ASSERT_TRUE(result.performed) << result.error; + EXPECT_EQ(result.summary_text.rfind("Important retained context.", 0), 0u) + << "摘要模型写的正文必须原样在前"; + EXPECT_NE(result.summary_text.find( + "Skills loaded before this checkpoint: " + "paoffice-cdp-techniques, commit."), + std::string::npos) + << result.summary_text; + EXPECT_NE(result.summary_text.find("call `skill_view` again"), + std::string::npos); + ASSERT_FALSE(result.compacted_messages.empty()); + EXPECT_NE(result.compacted_messages.back().content.find( + "Skills loaded before this checkpoint"), + std::string::npos) + << "提醒要进入模型实际收到的摘要消息"; +} + +// 触发场景:被压缩的历史里没有加载过任何 skill。 +// 期望行为:摘要与摘要模型的输出逐字相同,不追加任何提醒。 +TEST(CompactCore, SummaryIsUnchangedWhenNoSkillWasLoaded) { + ChatStubProvider provider; + std::vector messages{ + msg("user", "first request", "u1"), + tool_call_message("call-1"), + tool_output_message("call-1"), + msg("user", "latest request", "u2"), + }; + + auto result = acecode::compact_messages(provider, messages, {}, true, nullptr); + + ASSERT_TRUE(result.performed) << result.error; + EXPECT_EQ(result.summary_text, "Important retained context."); +} + TEST(CompactCore, PromptForbidsToolCallsButAllowsQuotingCommands) { const std::string& prompt = acecode::get_compact_prompt(); EXPECT_EQ(prompt.rfind("You are performing a CONTEXT CHECKPOINT COMPACTION.", 0), 0u); diff --git a/tests/memory/memory_registry_test.cpp b/tests/memory/memory_registry_test.cpp index e767614f4..653048ce6 100644 --- a/tests/memory/memory_registry_test.cpp +++ b/tests/memory/memory_registry_test.cpp @@ -232,6 +232,36 @@ TEST_F(MemoryRegistryTest, RemoveDropsFileAndIndexLine) { EXPECT_EQ(idx.find("to_forget"), std::string::npos); } +// 场景:两份 MemoryRegistry 模拟两个进程(TUI 与某个工作区的 daemon,或两个工作区 +// 的 daemon),都在启动时 scan 过一次空目录;之后进程 A 写入 a、进程 B 再写入 b。 +// 期望:MEMORY.md 同时保留 a 与 b 两行;进程 B 删除 a 也能成功。 +// 回归背景:写索引只按本进程启动时的缓存渲染,B 不认识 A 后来写的 a,会把 a 那一行 +// 当成「条目已删」丢掉 —— daemon 接入记忆后多进程共写同一目录成为常态。 +TEST_F(MemoryRegistryTest, UpsertKeepsEntriesWrittenByAnotherProcess) { + acecode::MemoryRegistry process_a; + acecode::MemoryRegistry process_b; + process_a.scan(); + process_b.scan(); + + std::string err; + ASSERT_TRUE(process_a.upsert("from_a", acecode::MemoryType::Project, + "written by process a", "a body", + acecode::MemoryWriteMode::Create, err)) + << err; + ASSERT_TRUE(process_b.upsert("from_b", acecode::MemoryType::Project, + "written by process b", "b body", + acecode::MemoryWriteMode::Create, err)) + << err; + + const std::string idx = read_file(acecode::get_memory_index_path()); + EXPECT_NE(idx.find("from_a.md"), std::string::npos) << idx; + EXPECT_NE(idx.find("from_b.md"), std::string::npos) << idx; + + ASSERT_TRUE(process_b.remove("from_a", err)) << err; + EXPECT_EQ(read_file(acecode::get_memory_index_path()).find("from_a.md"), + std::string::npos); +} + // 场景:并发 upsert 不崩溃,最终状态一致(由 mutex 保证) TEST_F(MemoryRegistryTest, ConcurrentUpsertIsSafe) { acecode::MemoryRegistry reg; diff --git a/tests/prompt/system_prompt_test.cpp b/tests/prompt/system_prompt_test.cpp index ac3ff2567..8b311f0f0 100644 --- a/tests/prompt/system_prompt_test.cpp +++ b/tests/prompt/system_prompt_test.cpp @@ -859,6 +859,30 @@ void register_probe_tools(acecode::ToolExecutor& tools, } // namespace +// 场景:工具表里注册了 memory_write / memory_read;另一份工具表没有。 +// 期望:有 memory_write 时静态提示出现「# Memory」段,明确「让我记住」要用 +// memory_write、不要另写 MEMORY.md / LESSONS_LEARNED.md / 子目录 CLAUDE.md; +// 没有 memory_write 时整段不出现。 +// 回归背景(反馈 LINDANDAN069):用户要求「记住这个处理方式」,记忆目录为空、 +// 提示里没有任何记忆指引,模型把经验写进了子目录的 CLAUDE.md 与项目里的 +// .acecode/MEMORY.md,ACECode 从不自动加载它们,压缩之后经验就丢了。 +TEST_F(SystemPromptTest, MemoryGuidanceRoutesRememberRequestsToMemoryWrite) { + acecode::ScopedModelToolNameMappings none({}); + acecode::ToolExecutor tools; + register_probe_tools(tools, {"memory_write", "memory_read"}); + const std::string with_memory = + acecode::build_system_prompt(tools, temp_home.string()); + EXPECT_NE(with_memory.find("# Memory\n"), std::string::npos); + EXPECT_NE(with_memory.find("save it with `memory_write`"), std::string::npos); + EXPECT_NE(with_memory.find("LESSONS_LEARNED.md"), std::string::npos); + EXPECT_NE(with_memory.find("`memory_read`"), std::string::npos); + + acecode::ToolExecutor bare; + const std::string without_memory = + acecode::build_system_prompt(bare, temp_home.string()); + EXPECT_EQ(without_memory.find("# Memory\n"), std::string::npos); +} + // 场景:模型态是 gpt-5(偏好 apply_patch),工具表里 apply_patch 可用。 // 期望:出现 apply_patch 指引(相对路径 / 3 行上下文 / @@ 锚点、找不到时重读 // 而不是绕道 shell)与 "# Model-specific guidance" 段(自主推进、最小改动、脏 diff --git a/tests/session/thread_repair_test.cpp b/tests/session/thread_repair_test.cpp index f930fe1c3..a21110151 100644 --- a/tests/session/thread_repair_test.cpp +++ b/tests/session/thread_repair_test.cpp @@ -1,5 +1,7 @@ #include +#include "llm/message_predicates.hpp" +#include "llm/token_estimate.hpp" #include "session/compact_checkpoint.hpp" #include "session/session_manager.hpp" #include "session/session_storage.hpp" @@ -229,6 +231,181 @@ TEST(ThreadRepair, ClearsOversizedToolCallArgumentsAfterOutputs) { << "最近一条调用必须保留"; } +namespace { + +acecode::ThreadRepairOptions shrink_options(int target_tokens) { + // 与 PA 兜底 / 摘要失败后的机械兜底同一组开关。 + acecode::ThreadRepairOptions options; + options.trigger = "repair-test"; + options.target_tokens = target_tokens; + options.force_prune_one_group = true; + options.clear_tool_outputs = true; + options.keep_recent_tool_outputs = 1; + options.thin_old_turns_first = true; + return options; +} + +acecode::ChatMessage compact_summary(std::string content) { + acecode::ChatMessage item = message("user", std::move(content)); + item.is_compact_summary = true; + item.metadata = nlohmann::json{{"compact_summary", true}}; + return item; +} + +} // namespace + +// 触发场景:一个老回合(用户的任务说明 + 一次大工具输出 + 结论)加当前回合, +// 目标只比现规模低约 500 token —— 清掉那条旧工具输出就够。 +// 期望行为:只清旧工具输出,老回合的任务说明与结论、当前回合的输出都保留; +// 一组都不丢、也不精简。 +// 回归背景(反馈 LINDANDAN069):旧顺序先整组丢老回合、后清工具输出,PA 服务端 +// 随机拒收时一次把最初的任务说明、用户的多次纠正连同压缩摘要全部删掉,而真正 +// 省出空间的是随后清掉的工具输出 —— 模型此后「忘了」任务要求与刚学到的做法。 +TEST(ThreadRepair, ClearsToolOutputsBeforeDroppingOldTurns) { + const std::vector history{ + message("user", "original task: always verify every expect"), + assistant_call("old-call"), + tool_result("old-call", std::string(4000, 'O')), + message("assistant", "old conclusion: compose window found via tabs"), + message("user", "current request"), + assistant_call("new-call"), + tool_result("new-call", std::string(400, 'N')), + }; + const int pre = acecode::estimate_message_tokens(history); + + const auto result = acecode::plan_thread_repair( + history, shrink_options(pre - 500)); + + ASSERT_EQ(result.status, acecode::ThreadRepairStatus::Repaired); + EXPECT_EQ(result.pruned_groups, 0); + EXPECT_EQ(result.thinned_groups, 0); + EXPECT_EQ(result.cleared_tool_outputs, 1); + EXPECT_EQ(result.reason, "old tool outputs were cleared"); + EXPECT_TRUE(contains(result.replacement_history, "original task")) + << "任务说明是老回合里最不该丢的部分"; + EXPECT_TRUE(contains(result.replacement_history, "old conclusion")); + EXPECT_FALSE(contains(result.replacement_history, std::string(4000, 'O'))); + EXPECT_TRUE(contains(result.replacement_history, std::string(400, 'N'))) + << "最近一条工具输出必须保留"; +} + +// 触发场景:老回合里有 12 次工具往返,每次调用参数约 3KB(不到 4KB,清不掉)、 +// 返回只有 "ok"(比占位符还短,也清不掉);目标要求腾出这些往返的大头。 +// 期望行为:清工具输出腾不出空间,就把老回合精简成「任务说明 + 结论」: +// 工具调用与结果全部删掉,用户消息与助手的纯文本结论保留,整组不丢。 +// 回归背景:旧实现只能整组丢弃,模型连任务说明带结论一起丢;只留用户消息不留 +// 结论又会让模型以为老任务还没做、从头再做一遍,所以结论必须一起保留。 +TEST(ThreadRepair, ThinsOldTurnsBeforeDroppingThem) { + std::vector history{ + message("user", "original task: reuse netdisk_helpers.py"), + }; + for (int i = 0; i < 12; ++i) { + const std::string id = "old-" + std::to_string(i); + history.push_back(assistant_call( + id, R"({"path":"x.txt","content":")" + std::string(3000, 'P') + + R"("})")); + history.push_back(tool_result(id, "ok")); + } + history.push_back(message("assistant", "old conclusion: all cases rerun")); + history.push_back(message("user", "current request")); + const int pre = acecode::estimate_message_tokens(history); + + const auto result = acecode::plan_thread_repair( + history, shrink_options(pre / 2)); + + ASSERT_EQ(result.status, acecode::ThreadRepairStatus::Repaired); + EXPECT_EQ(result.cleared_tool_outputs, 0); + EXPECT_EQ(result.thinned_groups, 1); + EXPECT_EQ(result.pruned_groups, 0); + EXPECT_EQ(result.pruned_messages, 24); + ASSERT_EQ(result.replacement_history.size(), 3u); + EXPECT_EQ(result.replacement_history[0].content, + "original task: reuse netdisk_helpers.py"); + EXPECT_EQ(result.replacement_history[1].content, + "old conclusion: all cases rerun"); + EXPECT_EQ(result.replacement_history[2].content, "current request"); + EXPECT_LE(result.post_tokens, pre / 2); +} + +// 触发场景:历史开头是上一次压缩留下的「保留用户消息 + 摘要」,后面接着两个 +// 纯文本老回合和当前输入;目标极小(1 token),精简也腾不出空间,只能整组丢。 +// 期望行为:老回合整组丢掉,但最新那份压缩摘要保留在最前面,当前输入不动。 +// 回归背景:旧实现整组丢弃时摘要跟着所在的组一起消失 —— 那份摘要是此前所有 +// 回合的唯一记忆,丢了它模型对之前做过的事一无所知。 +TEST(ThreadRepair, KeepsLatestCompactSummaryWhenDroppingOldTurns) { + const std::vector history{ + message("user", "retained request from before compaction"), + compact_summary("handoff summary: helpers live in reports/"), + message("assistant", "reply after compaction"), + message("user", "second request"), + message("assistant", "second reply"), + message("user", "current request"), + }; + + const auto result = acecode::plan_thread_repair(history, shrink_options(1)); + + ASSERT_EQ(result.status, acecode::ThreadRepairStatus::Repaired); + EXPECT_EQ(result.pruned_groups, 2); + ASSERT_EQ(result.replacement_history.size(), 2u); + EXPECT_TRUE(acecode::is_compact_summary_message( + result.replacement_history[0])); + EXPECT_NE(result.replacement_history[0].content.find("helpers live in"), + std::string::npos); + EXPECT_EQ(result.replacement_history[1].content, "current request"); +} + +// 触发场景:同样的历史,但调用方没打开 thin_old_turns_first(手动修复、通用 +// 恢复链的默认值)。 +// 期望行为:与旧行为一致 —— 整组丢弃,摘要不额外保留。 +TEST(ThreadRepair, DefaultOptionsKeepWholeGroupPruning) { + const std::vector history{ + message("user", "retained request from before compaction"), + compact_summary("handoff summary: helpers live in reports/"), + message("assistant", "reply after compaction"), + message("user", "current request"), + }; + acecode::ThreadRepairOptions options; + options.force_prune_one_group = true; + + const auto result = acecode::plan_thread_repair(history, options); + + ASSERT_EQ(result.status, acecode::ThreadRepairStatus::Repaired); + EXPECT_EQ(result.pruned_groups, 1); + EXPECT_EQ(result.thinned_groups, 0); + ASSERT_EQ(result.replacement_history.size(), 1u); + EXPECT_EQ(result.replacement_history[0].content, "current request"); +} + +// 触发场景:当前回合里先有一次大输出(4000 字节),最近一次工具只返回了 "ok" +// (比占位符还短,本身不值得清);目标要求腾空间,保留最近 1 条。 +// 期望行为:最近那条 "ok" 占掉保护名额,更早的大输出被清掉。 +// 回归背景:旧实现只在「值得清的输出」里数最近 N 条,短输出不算,保护名额 +// 落到了更早的大输出上 —— 它恰恰是唯一能腾出空间的那条,于是一条都清不掉, +// 修复转而去精简 / 整组丢弃老回合(端到端用例里就是这么绕过去的)。 +TEST(ThreadRepair, ShortLatestToolOutputStillTakesTheProtectedSlot) { + const std::vector history{ + message("user", "current request"), + assistant_call("c1"), + tool_result("c1", std::string(4000, 'A')), + assistant_call("c2"), + tool_result("c2", "ok"), + }; + acecode::ThreadRepairOptions options; + options.force_prune_one_group = true; + options.target_tokens = 1; + options.clear_tool_outputs = true; + options.keep_recent_tool_outputs = 1; + + const auto result = acecode::plan_thread_repair(history, options); + + EXPECT_EQ(result.status, acecode::ThreadRepairStatus::Repaired); + EXPECT_EQ(result.cleared_tool_outputs, 1); + ASSERT_EQ(result.replacement_history.size(), 5u); + EXPECT_EQ(result.replacement_history[2].content, + acecode::kClearedToolOutputPlaceholder); + EXPECT_EQ(result.replacement_history[4].content, "ok"); +} + TEST(ThreadRepair, HealthyHistoryReportsNoChangeWithoutWritingCheckpoint) { const auto result = acecode::plan_thread_repair( {message("user", "request"), message("assistant", "answer")}, {}); diff --git a/tests/tool/file_read_tool_summary_test.cpp b/tests/tool/file_read_tool_summary_test.cpp index 6dd6e0c9d..04b422c1b 100644 --- a/tests/tool/file_read_tool_summary_test.cpp +++ b/tests/tool/file_read_tool_summary_test.cpp @@ -496,6 +496,39 @@ TEST(FileReadToolSummary, DuplicateFullReadReturnsUnchangedStub) { fs::remove(p); } +// 触发场景:同一进程里会话 A 先完整读一遍文件,会话 B 随后读同一个没变过的 +// 文件,然后会话 A 再读一次。 +// 期望行为:会话 B 拿到真实内容;只有会话 A 的第二次读命中「未变化」占位。 +// 回归背景:去重表不分会话时,会话 B 的第一次读直接返回占位,模型看不到内容。 +TEST(FileReadToolSummary, DuplicateReadIsOnlyDeduplicatedWithinTheSameSession) { + ToolImpl tool = create_file_read_tool(); + + auto p = make_temp_file("alpha\nbeta\n"); + nlohmann::json args = {{"file_path", p.string()}}; + ToolContext session_a; + session_a.session_id = "scope-test-session-a"; + ToolContext session_b; + session_b.session_id = "scope-test-session-b"; + + ToolResult first = tool.execute(args.dump(), session_a); + ASSERT_TRUE(first.success) << first.output; + EXPECT_NE(first.output.find("alpha\nbeta\n"), std::string::npos); + + ToolResult other_session = tool.execute(args.dump(), session_b); + ASSERT_TRUE(other_session.success) << other_session.output; + EXPECT_NE(other_session.output.find("alpha\nbeta\n"), std::string::npos) + << "另一个会话必须读到真实内容"; + EXPECT_EQ(other_session.output.find("File unchanged since last read"), + std::string::npos); + + ToolResult repeated = tool.execute(args.dump(), session_a); + ASSERT_TRUE(repeated.success) << repeated.output; + EXPECT_NE(repeated.output.find("File unchanged since last read"), + std::string::npos); + + fs::remove(p); +} + TEST(FileReadToolSummary, DuplicateReadStubIncludesPreviousPersistedReference) { ToolImpl tool = create_file_read_tool(); diff --git a/tests/tool/mtime_tracker_test.cpp b/tests/tool/mtime_tracker_test.cpp index a71ad186c..fb92efba0 100644 --- a/tests/tool/mtime_tracker_test.cpp +++ b/tests/tool/mtime_tracker_test.cpp @@ -39,4 +39,46 @@ TEST(MtimeTrackerTest, HumanWriteInvalidatesAgentBaselineAndReadObservation) { std::filesystem::remove(path, ec); } +// 触发场景:会话 A 读过文件并记下「已读观测」;同一进程里的会话 B(daemon 里 +// 另一个会话或子代理)以同样的范围读同一个没变过的文件。 +// 期望行为:只有会话 A 命中「未变化」;会话 B 与不带会话的调用都查不到观测, +// 必须真的读一次。按路径失效(人工改文件)对所有会话一起生效。 +// 回归背景:观测表是进程级单例、键里没有会话,会话 B 第一次读就拿到「File +// unchanged since last read」占位 —— 它从没见过那份内容,模型等于什么都没读到 +// (反馈 huangyuan816 排障时发现:新会话读 SDDisplayLink.m 拿到的是别的会话留下 +// 的占位)。 +TEST(MtimeTrackerTest, ReadObservationsAreScopedPerSession) { + const auto unique = std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()); + const auto path = std::filesystem::path(testing::TempDir()) / + ("acecode_mtime_scope_" + unique + ".txt"); + { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + ASSERT_TRUE(out.good()); + out << "alpha\n"; + } + + auto& tracker = acecode::MtimeTracker::instance(); + tracker.record_read_observation(path.string(), 0, 0, false, 0, 0, + "session-a"); + + EXPECT_TRUE(tracker.has_unchanged_read_observation( + path.string(), 0, 0, false, 0, 0, "session-a")); + EXPECT_FALSE(tracker.has_unchanged_read_observation( + path.string(), 0, 0, false, 0, 0, "session-b")) + << "别的会话没见过内容,不能拿到未变化占位"; + EXPECT_FALSE(tracker.has_unchanged_read_observation(path.string(), 0, 0)); + + tracker.record_read_observation(path.string(), 0, 0, false, 0, 0, + "session-b"); + tracker.invalidate_read_observations(path.string()); + EXPECT_FALSE(tracker.has_unchanged_read_observation( + path.string(), 0, 0, false, 0, 0, "session-a")); + EXPECT_FALSE(tracker.has_unchanged_read_observation( + path.string(), 0, 0, false, 0, 0, "session-b")); + + std::error_code ec; + std::filesystem::remove(path, ec); +} + } // namespace diff --git a/tests/upgrade/executable_version_test.cpp b/tests/upgrade/executable_version_test.cpp index 009d29fbd..b9aa70879 100644 --- a/tests/upgrade/executable_version_test.cpp +++ b/tests/upgrade/executable_version_test.cpp @@ -73,6 +73,10 @@ TEST(UpgradeExecutableVersion, RejectsFailureMalformedOutputAndOversizedOutput) } } +// 触发场景:探测目标 sleep 10 秒不退出,调用方显式给 100ms 超时。 +// 期望行为:按时判失败并只杀掉自己拉起的探测进程;错误文案写明等了多久、 +// 并点出最常见的原因(安全软件还在扫描),升级日志与界面只看得到这一句。 +// 3 秒上限:100ms 超时 + 终止等待,实测远小于 1 秒,3 秒只为留出机器抖动。 TEST(UpgradeExecutableVersion, TimesOutAndTerminatesOnlyItsProbe) { VersionFixture fixture; fixture.mode("timeout"); @@ -80,12 +84,23 @@ TEST(UpgradeExecutableVersion, TimesOutAndTerminatesOnlyItsProbe) { const auto start = std::chrono::steady_clock::now(); EXPECT_FALSE(verify_executable_version(fixture.executable, "9.9.9", &error, std::chrono::milliseconds(100))); - EXPECT_NE(error.find("timed out"), std::string::npos); + EXPECT_NE(error.find("timed out after 100ms"), std::string::npos) << error; + EXPECT_NE(error.find("security software"), std::string::npos) << error; EXPECT_LT(std::chrono::steady_clock::now() - start, std::chrono::seconds(3)); fixture.mode(""); EXPECT_TRUE(verify_executable_version(fixture.executable, "9.9.9", &error)) << error; } +// 触发场景:不传超时参数,走升级流程实际使用的默认值。 +// 期望行为:默认超时至少 60 秒。 +// 回归背景(反馈 ZHAOZEYIN831,0.9.26 → 0.9.27):默认值曾是 5 秒,企业杀软在新 +// exe 首次执行时整包扫描,`--version` 5 秒内返回不了,下载 / 校验 / 解压都成功的 +// 升级在最后一步被判失败并回滚。60 秒对应 Defender 云端首见拦截可挂起执行的上限; +// 正常机器上探测子进程一退出就返回,这个值只在真卡住时才会被用满。 +TEST(UpgradeExecutableVersion, DefaultProbeTimeoutToleratesSecurityScanDelay) { + EXPECT_GE(kExecutableVersionProbeTimeout, std::chrono::seconds(60)); +} + TEST(UpgradeExecutableVersion, RejectsOldStagingWithoutChangingInstallation) { VersionFixture fixture; fixture.mode("old"); diff --git a/web/src/i18n/catalogs/systemNotice.js b/web/src/i18n/catalogs/systemNotice.js index 9e06f51f3..363243e8b 100644 --- a/web/src/i18n/catalogs/systemNotice.js +++ b/web/src/i18n/catalogs/systemNotice.js @@ -17,7 +17,7 @@ export const systemNoticeZhCN = { context_checkpoint: '上下文压缩检查点', context_compact_failed: '上下文压缩失败', context_compact_warning: '上下文压缩警告', context_compact_stopped: '上下文压缩已停止', context_threshold_lowered: '上下文压缩阈值已下调', context_retrying: '上下文请求重试中', - context_waiting: '等待服务恢复', context_history_pruned: '旧上下文已清理', + context_waiting: '等待服务恢复', context_history_pruned: '旧上下文已清理', context_history_thinned: '旧回合已精简', context_emergency: '已切换精简请求', response_empty_retry: '空回复重试中', response_truncated: '回复已被截断', response_text_tool_call_retry: '文本工具调用重试中', @@ -45,6 +45,7 @@ export const systemNoticeZhCN = { context_retrying: '服务端报告请求上下文过大,先原样重试以确认是否为瞬时故障;确认拒收后才会收缩历史。', context_waiting: '最小请求仍被拒收。将从 5 秒起、最长 60 秒的间隔重试,最多 {{attempts}} 次,可随时停止。', context_history_pruned: '服务端拒收请求(第 {{round}} 次收缩):已丢弃最旧的 {{groups}} 组历史、清除 {{outputs}} 条旧工具输出后重试。', + context_history_thinned: '服务端拒收请求(第 {{round}} 次收缩):已清除 {{outputs}} 条旧工具输出、精简 {{thinned}} 组旧回合(保留用户消息与结论)、丢弃 {{groups}} 组最旧历史后重试。', context_emergency: '已精简请求,移除额外工具定义和注入上下文,仅保留核心工具。', response_empty_retry: '模型返回空回复({{reason}}),正在自动重试 {{attempt}}/{{attempts}}。', response_text_tool_call_retry: '模型把工具调用写成了正文文本,未执行,正在要求其改用原生工具调用重发 {{attempt}}/{{attempts}}。', @@ -71,6 +72,7 @@ export const systemNoticeZhCN = { unlimited: '未设置上限', providerUnavailable: '模型服务不可用。', emptyResponse: '无正文也无工具调用', truncatedResponse: '输出被 token 上限截断,思考耗尽了输出预算', compactFallback: '摘要压缩失败,已改为丢弃最旧的 {{groups}} 组历史、清除 {{outputs}} 条旧工具输出,会话继续。', + compactFallbackThinned: '摘要压缩失败,已改为清除 {{outputs}} 条旧工具输出、精简 {{thinned}} 组旧回合(保留用户消息与结论)、丢弃 {{groups}} 组最旧历史,会话继续。', remoteOn: '运行中', remoteOff: '未运行', remoteUnconfigured: '未配置', remoteHint: '在会话中执行 /rc,将其绑定到默认通道。', remoteConnected: '通道“{{channel}}”已连接。', remoteReused: '已复用现有运行实例。', @@ -96,7 +98,7 @@ export const systemNoticeEnUS = { context_checkpoint: 'Context compaction checkpoint', context_compact_failed: 'Context compaction failed', context_compact_warning: 'Context compaction warning', context_compact_stopped: 'Context compaction stopped', context_threshold_lowered: 'Context compaction threshold lowered', context_retrying: 'Retrying context request', - context_waiting: 'Waiting for service recovery', context_history_pruned: 'Old context cleared', context_emergency: 'Using reduced request', + context_waiting: 'Waiting for service recovery', context_history_pruned: 'Old context cleared', context_history_thinned: 'Old turns condensed', context_emergency: 'Using reduced request', response_empty_retry: 'Retrying empty response', response_truncated: 'Response truncated', response_text_tool_call_retry: 'Retrying text-form tool call', turn_interjected: 'Interrupted by new message', turn_interrupted: 'Response stopped', iteration_limit: 'Iteration limit reached', @@ -123,6 +125,7 @@ export const systemNoticeEnUS = { context_retrying: 'The server reported an oversized context. Retrying the unchanged request to check for a transient failure before shrinking history.', context_waiting: 'The minimal request was still rejected. Retrying at intervals from 5 to 60 seconds, up to {{attempts}} times. You can stop at any time.', context_history_pruned: 'The server rejected the request (shrink round {{round}}). Removed {{groups}} oldest history groups and cleared {{outputs}} old tool outputs before retrying.', + context_history_thinned: 'The server rejected the request (shrink round {{round}}). Cleared {{outputs}} old tool outputs, condensed {{thinned}} old turns (user messages and conclusions kept) and removed {{groups}} oldest history groups before retrying.', context_emergency: 'Reduced the request by removing extra tool definitions and injected context, keeping only core tools.', response_empty_retry: 'The model returned an empty response ({{reason}}). Retrying {{attempt}}/{{attempts}}.', response_text_tool_call_retry: 'The model wrote a tool call as plain text, so it was not executed. Asking it to resend through native tool calling ({{attempt}}/{{attempts}}).', @@ -149,6 +152,7 @@ export const systemNoticeEnUS = { unlimited: 'No limit set', providerUnavailable: 'The model provider is unavailable.', emptyResponse: 'no text or tool calls', truncatedResponse: 'output was truncated by the token limit; reasoning exhausted the output budget', compactFallback: 'Summary compaction failed. Removed {{groups}} oldest history groups and cleared {{outputs}} old tool outputs instead; the conversation continues.', + compactFallbackThinned: 'Summary compaction failed. Cleared {{outputs}} old tool outputs, condensed {{thinned}} old turns (user messages and conclusions kept) and removed {{groups}} oldest history groups instead; the conversation continues.', remoteOn: 'Running', remoteOff: 'Not running', remoteUnconfigured: 'Not configured', remoteHint: 'Run /rc in a session to bind it to the default channel.', remoteConnected: "Channel '{{channel}}' connected.", remoteReused: 'Existing runtime reused.', diff --git a/web/src/lib/systemNotice.js b/web/src/lib/systemNotice.js index 42749ec3c..ea47872be 100644 --- a/web/src/lib/systemNotice.js +++ b/web/src/lib/systemNotice.js @@ -142,7 +142,7 @@ export function presentSystemNotice(message = {}, t = tr) { else if (typeof params.summary === 'string') details = `${t('systemNotice.fields.summary')}:\n${params.summary}`; else if (typeof params.text === 'string') details = params.text; else if (params.provider_unavailable === true) details = t('systemNotice.providerUnavailable'); - else if (code === 'context_compact_warning' && params.groups != null) details = t('systemNotice.compactFallback', params); + else if (code === 'context_compact_warning' && params.groups != null) details = t(params.thinned ? 'systemNotice.compactFallbackThinned' : 'systemNotice.compactFallback', params); else if (template) details = template; else if (typeof params.error === 'string') details = title; if (typeof params.error === 'string' && params.error) details += `\n\n${t('systemNotice.fields.error')}:\n${params.error}`; diff --git a/web/src/lib/systemNotice.test.js b/web/src/lib/systemNotice.test.js index e9988e3d5..e4cb0b7f5 100644 --- a/web/src/lib/systemNotice.test.js +++ b/web/src/lib/systemNotice.test.js @@ -141,3 +141,22 @@ run('text-form tool call retry notice is localized with attempt counters', () => assert.match(english.text, /\(1\/2\)/); for (const text of [cn.text, english.text]) assert.doesNotMatch(text, /\{\{|Original fallback/); }); + +// 场景:PA 兜底 / 摘要失败后的机械兜底改为「先清工具输出、再精简旧回合、最后才整组丢弃」, +// 新增 context_history_thinned 与带 thinned 参数的 context_compact_warning。 +// 期望:两种语言都带出精简组数、清除条数与丢弃组数;旧通知(没有 thinned)仍走原文案。 +run('context repair notices report condensed turns and keep the legacy wording', () => { + const thinned = notice('context_history_thinned', { round: 1, groups: 0, thinned: 2, outputs: 5 }); + const cn = presentSystemNotice(thinned, zh); + const english = presentSystemNotice(thinned, en); + assert.equal(cn.title, '旧回合已精简'); + assert.equal(english.title, 'Old turns condensed'); + assert.match(cn.text, /清除 5 条旧工具输出、精简 2 组旧回合/); + assert.match(english.text, /condensed 2 old turns/); + const fallback = presentSystemNotice(notice('context_compact_warning', { groups: 0, thinned: 3, outputs: 4, error: 'summary failed' }), zh); + assert.match(fallback.text, /精简 3 组旧回合/); + assert.ok(fallback.text.includes('summary failed')); + const legacy = presentSystemNotice(notice('context_compact_warning', { groups: 1, outputs: 2, error: 'summary failed' }), zh); + assert.match(legacy.text, /丢弃最旧的 1 组历史、清除 2 条旧工具输出/); + for (const text of [cn.text, english.text, fallback.text, legacy.text]) assert.doesNotMatch(text, /\{\{|Original fallback/); +}); From 959751642d83340525662b71b276bd5dcfbd3057 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Thu, 1 Oct 2026 00:03:49 +0800 Subject: [PATCH 2/3] Retry once when a reply leaks tool-argument template markup Feedback huangyuan816 (0.9.27, aicoder-pro): the first reply was a run of digits ending in , and the bash call in the same reply carried garbled arguments that were executed anyway; the rest of the turn went off topic. When reply text outside code contains / or their closing tags, TurnRunner now discards the whole step before running any tool call, clears the streamed draft in TUI and Web, emits a response_corrupted_retry notice and resends the unchanged request. The allowance is one retry per step and resets after a valid tool batch; a second corrupted reply follows the normal flow. is deliberately not treated as corruption because models that write their reasoning into the reply emit it legitimately. --- CLAUDE.md | 2 + .../provider/text_tool_call_recovery.cpp | 20 +++ .../provider/text_tool_call_recovery.hpp | 9 ++ src/engine/agent/turn/response_recovery.hpp | 2 + src/engine/agent/turn/turn_runner.cpp | 57 ++++++++ src/engine/agent/turn/turn_runner.hpp | 3 + tests/agent/agent_loop_termination_test.cpp | 138 ++++++++++++++++++ .../provider/text_tool_call_recovery_test.cpp | 35 +++++ web/src/i18n/catalogs/systemNotice.js | 6 +- web/src/lib/systemNotice.test.js | 14 ++ 10 files changed, 284 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9892a5ff..e8e9307aa 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,现已登记)。 diff --git a/src/adapters/provider/text_tool_call_recovery.cpp b/src/adapters/provider/text_tool_call_recovery.cpp index 0dad5f65c..e4450964f 100644 --- a/src/adapters/provider/text_tool_call_recovery.cpp +++ b/src/adapters/provider/text_tool_call_recovery.cpp @@ -1203,6 +1203,26 @@ bool text_contains_tool_call_markup(std::string_view text) { return detect_suspicious_text_tool_call(text).has_value(); } +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 (starts_with_at(text, i, marker)) return std::string(marker); + } + } + fence.feed(c); + } + return std::nullopt; +} + // ---- 混合形态:回显比对 ----------------------------------------------------- namespace { 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/engine/agent/turn/response_recovery.hpp b/src/engine/agent/turn/response_recovery.hpp index 93b081cce..7075ecdaa 100644 --- a/src/engine/agent/turn/response_recovery.hpp +++ b/src/engine/agent/turn/response_recovery.hpp @@ -10,6 +10,8 @@ class GoalRuntime; struct ResponseRecoveryState { int empty_response_retries = 0; int text_tool_call_corrections = 0; + // 正文混入工具参数模板标记(输出损坏)后的丢弃重发次数,见 TurnRunner。 + int corrupted_output_retries = 0; }; struct ResponseRecoveryResult { HandleErrorResult action = HandleErrorResult::Proceed; diff --git a/src/engine/agent/turn/turn_runner.cpp b/src/engine/agent/turn/turn_runner.cpp index 682198a9f..fddd27021 100644 --- a/src/engine/agent/turn/turn_runner.cpp +++ b/src/engine/agent/turn/turn_runner.cpp @@ -18,7 +18,12 @@ #include "agent/transcript/conversation_history.hpp" #include "agent/transcript/transcript_writer.hpp" #include "agent/turn/turn_outcome.hpp" +#include "agent/compaction/compact.hpp" +#include "agent/transcript/transcript_queries.hpp" +#include "provider/text_tool_call_recovery.hpp" +#include "session/event_dispatcher.hpp" #include "session/session_manager.hpp" +#include "session/system_notice.hpp" #include "config/config.hpp" #include "utils/abort_signal.hpp" #include "utils/logger.hpp" @@ -27,6 +32,36 @@ namespace acecode::agent { using detail::kDefaultNoModelConfiguredPrompt; +namespace { +// 输出损坏只重发一次:网关偶发把模板残片吐进输出流,重采通常就好;连续两次 +// 损坏多半是服务端持续异常,继续重发只会烧 token,按原流程往下走不卡住回合。 +constexpr int kMaxCorruptedOutputRetries = 1; +} // namespace + +void TurnRunner::discard_corrupted_output(const ChatResponse& response, + const std::string& marker, int attempt, int attempts) { + LOG_WARN("Corrupted model output: leaked template markup " + marker + + " in reply text; discarding the step without running its " + + std::to_string(response.tool_calls.size()) + + " tool call(s) and retrying " + std::to_string(attempt) + "/" + + std::to_string(attempts) + " excerpt=" + + log_truncate(response.content, 300)); + // 与 provider 的 Retry 事件同一套清理:TUI 丢掉流式草稿行,Web 用已落盘 + // 的消息整体替换转录,损坏的正文不留在界面上。 + const auto callbacks = callbacks_.snapshot(); + if (callbacks.on_stream_retry_reset) callbacks.on_stream_retry_reset(); + const std::vector visible = + session_ ? session_->load_active_messages() : history_.view(); + events_.emit(SessionEventKind::TranscriptReplace, + detail::build_transcript_replace_payload(visible, CompactResult{})); + transcript_.emit_transcript_system_message(session_, + std::string(u8"[输出异常] 模型回复里混入了工具参数模板标记(") + marker + + u8"),正文与工具调用都不可信,已丢弃并重新请求 " + + std::to_string(attempt) + "/" + std::to_string(attempts) + u8"…", + make_system_notice_metadata("response_corrupted_retry", + {{"attempt", attempt}, {"attempts", attempts}, {"marker", marker}})); +} + void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal_context, const ChatMessage* retry_message, LifetimeRef terminal) { // Capture the owner before callbacks can switch/delete the active session. @@ -249,6 +284,28 @@ void TurnRunner::run(TurnContext& turn, const UserInput& input, bool hidden_goal break; } + // Phase 4b: 输出损坏 —— 正文里混进了工具参数模板标记( 等)。 + // 这一步的正文和工具调用都不可信:整条丢弃(不入历史、不执行工具)、 + // 原样重发一次;重发后仍损坏就按原流程继续。反馈 huangyuan816:第一条 + // 回复是一串数字加 ,同一回复里的 bash 命令也夹着乱码,照样 + // 被执行,之后整个回合跑题。检查放在 has_tool_calls 分支之前,带原生 + // 工具调用的回复同样拦下。 + if (turn.response_recovery.corrupted_output_retries < + kMaxCorruptedOutputRetries) { + if (const auto marker = find_leaked_tool_argument_markup( + provider_result.accumulated.content)) { + ++turn.response_recovery.corrupted_output_retries; + discard_corrupted_output(provider_result.accumulated, *marker, + turn.response_recovery.corrupted_output_retries, + kMaxCorruptedOutputRetries); + steps_.response(session_, + current_model_step, provider_result, step_usage, "retry"); + steps_.finish(current_model_step, "retry", step_usage); + if (total_iterations > 0) --total_iterations; + continue; + } + } + // Usage estimation when provider didn't report usage。必须覆盖所有轮: // 旧条件把「纯工具调用轮(无正文)」排除,导致不上报 usage 的 // provider 下 goal 预算在工具轮从不入账,budget_limited 永不触发。 diff --git a/src/engine/agent/turn/turn_runner.hpp b/src/engine/agent/turn/turn_runner.hpp index 595031372..c21cf3cb9 100644 --- a/src/engine/agent/turn/turn_runner.hpp +++ b/src/engine/agent/turn/turn_runner.hpp @@ -49,6 +49,9 @@ class TurnRunner { CompactionInputs compaction_inputs(const TurnContext& turn, LifetimeRef terminal) const; bool drain_inputs(bool close_if_empty); + // 丢弃一步损坏的输出(不入历史、不执行工具),清掉界面上的流式草稿并提示。 + void discard_corrupted_output(const ChatResponse& response, + const std::string& marker, int attempt, int attempts); ToolBatchOutcome execute_tools(TurnContext& turn, const ChatResponse& response, const std::shared_ptr& provider, const ProgressEmitter& progress); diff --git a/tests/agent/agent_loop_termination_test.cpp b/tests/agent/agent_loop_termination_test.cpp index b90de480d..7612abf53 100644 --- a/tests/agent/agent_loop_termination_test.cpp +++ b/tests/agent/agent_loop_termination_test.cpp @@ -305,6 +305,14 @@ class AgentLoopHarness { response.tool_calls = std::move(calls); provider_->push_response(std::move(response)); } + // 同一条回复里既有正文又有原生工具调用。 + void push_text_with_tool_calls(std::string text, + std::vector calls) { + ScriptedResponse response; + response.text = std::move(text); + response.tool_calls = std::move(calls); + provider_->push_response(std::move(response)); + } void register_tool(ToolImpl tool) { tools_.register_tool(std::move(tool)); } @@ -2216,6 +2224,136 @@ TEST(AgentLoopTermination, CorrectionDoesNotUnderflowIterationCounter) { EXPECT_EQ(h.last_system_message().find("max_iterations"), std::string::npos); } +namespace { + +// 计数型只读工具:断言「损坏那一步的工具调用没有被执行」。 +ToolImpl create_counting_tool(const std::string& name, + std::shared_ptr> runs) { + ToolDef def; + def.name = name; + def.description = "Counts executions for agent-loop tests."; + def.parameters = { + {"type", "object"}, + {"properties", nlohmann::json::object()} + }; + ToolImpl impl; + impl.definition = def; + impl.execute = [runs](const std::string&, const acecode::ToolContext&) { + runs->fetch_add(1); + return ToolResult{"counted", true}; + }; + impl.is_read_only = true; + impl.source = ToolSource::Builtin; + return impl; +} + +acecode::ToolCall probe_call(const std::string& id) { + acecode::ToolCall call; + call.id = id; + call.function_name = "probe"; + call.function_arguments = "{}"; + return call; +} + +// 反馈 huangyuan816 第一条回复的开头与结尾(一串数字 + 结尾的 )。 +const char* const kCorruptedReply = + " roots\n# 3.3# 45 4}\n443void\n38 id\n37\n41id\n40"; + +bool assistant_said(const std::vector& messages, + const std::string& needle) { + for (const auto& msg : messages) { + if (msg.role == "assistant" && + msg.content.find(needle) != std::string::npos) { + return true; + } + } + return false; +} + +} // namespace + +// 场景:模型第一条回复的正文混进了工具参数模板标记(),同一回复里 +// 还带着一个原生工具调用;重发后正常回答。 +// 期望:损坏那一步整条丢弃 —— 工具不执行、正文和调用都不入历史,两次请求的 +// 消息逐条相同(原样重发);发一条 response_corrupted_retry 通知(1/1,带出 +// 命中的标记);最终回复正常落盘,没有错误。 +// 回归背景(反馈 huangyuan816,0.9.27):坏回复里的 bash 命令也夹着乱码 +// (`ls …/2&&`),照样被执行,之后整个回合跑题、答非所问。 +TEST(AgentLoopTermination, CorruptedOutputIsDiscardedAndRequestedAgainOnce) { + AgentLoopHarness h; + auto runs = std::make_shared>(0); + h.register_tool(create_counting_tool("probe", runs)); + h.push_text_with_tool_calls(kCorruptedReply, {probe_call("bad-1")}); + h.push_text("recovered answer"); + + ASSERT_TRUE(h.submit_and_wait("why is the gif slow")); + EXPECT_EQ(h.turn_count(), 2); + EXPECT_EQ(runs->load(), 0) << "损坏那一步的工具调用不能执行"; + EXPECT_EQ(h.count_by_role("error"), 0); + + const auto first = h.request_messages_for_turn(0); + const auto second = h.request_messages_for_turn(1); + ASSERT_EQ(first.size(), second.size()) << "丢弃后原样重发,历史不能多出消息"; + for (std::size_t i = 0; i < first.size(); ++i) { + EXPECT_EQ(first[i].content, second[i].content) << "message " << i; + } + + const auto persisted = h.persisted_messages(); + for (const auto& msg : persisted) { + if (msg.role == "system") continue; // 通知本身会复述命中的标记 + EXPECT_EQ(msg.content.find(""), std::string::npos) + << msg.content; + EXPECT_NE(msg.tool_call_id, "bad-1"); + if (msg.tool_calls.is_array()) { + for (const auto& call : msg.tool_calls) { + EXPECT_NE(call.value("id", std::string{}), "bad-1"); + } + } + } + EXPECT_TRUE(assistant_said(persisted, "recovered answer")); + + const auto notices = notice_params_for(h.snapshot_events(), + "response_corrupted_retry"); + ASSERT_EQ(notices.size(), 1u); + EXPECT_EQ(notices[0].value("attempt", 0), 1); + EXPECT_EQ(notices[0].value("attempts", 0), 1); + EXPECT_EQ(notices[0].value("marker", std::string{}), ""); +} + +// 场景:重发之后的回复仍然带着模板标记。 +// 期望:只重发一次,第二次按原流程当普通回复收下,回合正常结束,不报错。 +// 连续两次损坏多半是服务端持续异常,再重发只会烧 token、把回合卡住。 +TEST(AgentLoopTermination, CorruptedOutputIsRequestedAgainOnlyOnce) { + AgentLoopHarness h; + h.push_text(kCorruptedReply); + h.push_text("still broken x"); + + ASSERT_TRUE(h.submit_and_wait("go")); + EXPECT_EQ(h.turn_count(), 2); + EXPECT_EQ(h.count_by_role("error"), 0); + EXPECT_EQ(notice_params_for(h.snapshot_events(), + "response_corrupted_retry").size(), 1u); + EXPECT_TRUE(assistant_said(h.persisted_messages(), "still broken")); +} + +// 场景:损坏 → 重发得到一次正常的工具调用 → 下一步又损坏 → 再重发得到正常回答。 +// 期望:重发名额按「连续」计,正常产出一次工具批次后清零,所以第二次损坏 +// 同样会被丢弃重发一次:共 4 次请求、2 条通知、没有错误。 +TEST(AgentLoopTermination, CorruptedOutputAllowanceResetsAfterValidToolStep) { + AgentLoopHarness h; + h.push_text(kCorruptedReply); + h.push_tool_call("noop", "{}", "c1"); + h.push_text(kCorruptedReply); + h.push_text("done"); + + ASSERT_TRUE(h.submit_and_wait("go")); + EXPECT_EQ(h.turn_count(), 4); + EXPECT_EQ(h.count_by_role("error"), 0); + EXPECT_EQ(notice_params_for(h.snapshot_events(), + "response_corrupted_retry").size(), 2u); + EXPECT_TRUE(assistant_said(h.persisted_messages(), "done")); +} + // 场景:修复上线前落盘的老会话里有一条 assistant 消息整条是文本工具调用(测试 // stub 不经过 provider 的文本调用恢复,原样返回,等价于旧版本落盘的消息)。 // 期望:下一回合发给模型的历史里它被换成固定说明,不再出现 +#include #include #include #include @@ -774,3 +775,37 @@ TEST(TextToolCallRecoveryTest, SanitizeIsIdempotentAndByteStable) { } EXPECT_EQ(history[2].content, "keep me"); } + +// 触发场景:网关把 GLM 系工具参数模板残片吐进了正文。第一条是反馈 huangyuan816 +// 的原文(一串数字 + `` + 结尾的 ``),另外三条分别只带 +// `` / `` / ``。 +// 期望行为:都判为输出损坏,返回命中的那个标记,供 AgentLoop 丢弃这一步并重发。 +TEST(TextToolCallRecoveryTest, LeakedToolArgumentMarkupIsDetected) { + const std::string huangyuan816 = + " roots\n# 3.3# 45 4}\n443void\n38 id\n37\n41id\n47\n36id\n" + "52\n40\n17.18\n19\n12\n21\n20\n39id\n40"; + EXPECT_EQ(acecode::find_leaked_tool_argument_markup(huangyuan816), + std::optional("")); + EXPECT_EQ(acecode::find_leaked_tool_argument_markup("bash\ncommand"), + std::optional("")); + EXPECT_EQ(acecode::find_leaked_tool_argument_markup("command ls"), + std::optional("")); + EXPECT_EQ(acecode::find_leaked_tool_argument_markup("ls -la"), + std::optional("")); +} + +// 触发场景:正常回复在行内代码或代码块里讨论这些标记(比如解释 GLM 的工具调用 +// 模板),或者只出现 `` / 相近的普通单词。 +// 期望行为:都不算损坏。`` 刻意不处理 —— 把推理写进正文的模型会合法地 +// 输出它,拿它当损坏信号会误伤正常回复。 +TEST(TextToolCallRecoveryTest, ToolArgumentMarkupInCodeOrThinkTagIsNotCorruption) { + EXPECT_FALSE(acecode::find_leaked_tool_argument_markup( + "GLM wraps each value in `` tags.")); + EXPECT_FALSE(acecode::find_leaked_tool_argument_markup( + "Template:\n```\npath\na.txt\n```\n")); + EXPECT_FALSE(acecode::find_leaked_tool_argument_markup( + "done\nThe fix is in place.")); + EXPECT_FALSE(acecode::find_leaked_tool_argument_markup( + "pass arg_value and arg_key as plain words")); + EXPECT_FALSE(acecode::find_leaked_tool_argument_markup("")); +} diff --git a/web/src/i18n/catalogs/systemNotice.js b/web/src/i18n/catalogs/systemNotice.js index 363243e8b..307c4904b 100644 --- a/web/src/i18n/catalogs/systemNotice.js +++ b/web/src/i18n/catalogs/systemNotice.js @@ -20,7 +20,7 @@ export const systemNoticeZhCN = { context_waiting: '等待服务恢复', context_history_pruned: '旧上下文已清理', context_history_thinned: '旧回合已精简', context_emergency: '已切换精简请求', response_empty_retry: '空回复重试中', response_truncated: '回复已被截断', - response_text_tool_call_retry: '文本工具调用重试中', + response_text_tool_call_retry: '文本工具调用重试中', response_corrupted_retry: '输出异常重试中', turn_interjected: '插话中断', turn_interrupted: '已停止回复', iteration_limit: '已达到执行轮数上限', hook_message: '钩子通知', session_continued: '已转入新会话', plan_enabled: '计划模式已启用', lsp_status: '语言服务信息', sandbox_status: '沙箱信息', @@ -49,6 +49,7 @@ export const systemNoticeZhCN = { context_emergency: '已精简请求,移除额外工具定义和注入上下文,仅保留核心工具。', response_empty_retry: '模型返回空回复({{reason}}),正在自动重试 {{attempt}}/{{attempts}}。', response_text_tool_call_retry: '模型把工具调用写成了正文文本,未执行,正在要求其改用原生工具调用重发 {{attempt}}/{{attempts}}。', + response_corrupted_retry: '模型回复里混入了工具参数模板标记({{marker}}),正文与工具调用都不可信,已丢弃并重新请求 {{attempt}}/{{attempts}}。', response_truncated: '本回复因输出 token 上限被截断,内容可能不完整。', turn_interjected: '当前回复因新消息插话而中断。', turn_interrupted: '用户已停止当前回复。', iteration_limit: '已达到执行轮数上限({{limit}}),自动执行已停止。', @@ -100,7 +101,7 @@ export const systemNoticeEnUS = { context_threshold_lowered: 'Context compaction threshold lowered', context_retrying: 'Retrying context request', context_waiting: 'Waiting for service recovery', context_history_pruned: 'Old context cleared', context_history_thinned: 'Old turns condensed', context_emergency: 'Using reduced request', response_empty_retry: 'Retrying empty response', response_truncated: 'Response truncated', - response_text_tool_call_retry: 'Retrying text-form tool call', + response_text_tool_call_retry: 'Retrying text-form tool call', response_corrupted_retry: 'Retrying corrupted output', turn_interjected: 'Interrupted by new message', turn_interrupted: 'Response stopped', iteration_limit: 'Iteration limit reached', hook_message: 'Hook notification', session_continued: 'Continued in a new session', plan_enabled: 'Plan mode enabled', lsp_status: 'Language service information', sandbox_status: 'Sandbox information', @@ -129,6 +130,7 @@ export const systemNoticeEnUS = { context_emergency: 'Reduced the request by removing extra tool definitions and injected context, keeping only core tools.', response_empty_retry: 'The model returned an empty response ({{reason}}). Retrying {{attempt}}/{{attempts}}.', response_text_tool_call_retry: 'The model wrote a tool call as plain text, so it was not executed. Asking it to resend through native tool calling ({{attempt}}/{{attempts}}).', + response_corrupted_retry: 'The model reply contained leaked tool-argument template markup ({{marker}}), so its text and tool calls were discarded. Requesting again ({{attempt}}/{{attempts}}).', response_truncated: 'This response reached the output token limit and may be incomplete.', turn_interjected: 'The response was interrupted by a new message.', turn_interrupted: 'The user stopped the response.', iteration_limit: 'The iteration limit ({{limit}}) was reached; automatic execution stopped.', diff --git a/web/src/lib/systemNotice.test.js b/web/src/lib/systemNotice.test.js index e4cb0b7f5..8cbd57fcc 100644 --- a/web/src/lib/systemNotice.test.js +++ b/web/src/lib/systemNotice.test.js @@ -160,3 +160,17 @@ run('context repair notices report condensed turns and keep the legacy wording', assert.match(legacy.text, /丢弃最旧的 1 组历史、清除 2 条旧工具输出/); for (const text of [cn.text, english.text, fallback.text, legacy.text]) assert.doesNotMatch(text, /\{\{|Original fallback/); }); + +// 场景:模型回复正文混进工具参数模板标记,AgentLoop 丢弃这一步并重发一次。 +// 期望:标题与正文按当前语言显示,原样带出命中的标记和次数。 +run('corrupted output retry notice shows the leaked marker and attempt counters', () => { + const message = notice('response_corrupted_retry', { attempt: 1, attempts: 1, marker: '' }); + const cn = presentSystemNotice(message, zh); + const english = presentSystemNotice(message, en); + assert.equal(cn.title, '输出异常重试中'); + assert.equal(english.title, 'Retrying corrupted output'); + assert.ok(cn.text.includes('()')); + assert.ok(cn.text.includes('重新请求 1/1')); + assert.ok(english.text.includes('()')); + for (const text of [cn.text, english.text]) assert.doesNotMatch(text, /\{\{|Original fallback/); +}); From 240f43d69d263b79e3c619ad652f75503e947075 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Thu, 1 Oct 2026 12:05:28 +0800 Subject: [PATCH 3/3] Unify memory across TUI, daemon and headless with optional summarization Memory behaved differently on every surface: the TUI had its own injection and /memory text, the daemon only gained the memory tools in the previous commit, and everything shared one global directory. - One MemoryService backs every surface. Global memory stays under /memory; workspace memory lives in the existing workspace project directory and is chosen from the session's own project dir, not the process cwd. Entries record created/updated times and provenance (manual or summary, source sessions), and secrets are redacted before anything is written. - Memory is injected as a per-session snapshot in the session-context block. It stays frozen for the session, so memory written mid-session no longer changes the request prefix, and is rebuilt after compaction or thread repair. Each scope gets its own 8 KiB budget, newest first, with an omission note and age labels. - /memory is shared by the TUI and the web (list, view, edit, forget, reset, off, on, flush). A per-session memory_mode lets a session opt out, which also removes the memory tools from the model's tool list. - Settings > Personalization > Memory in the web UI and the TUI settings center: use-memory switch, memory summarization (off by default) with a summary model that defaults to the session's current model, per-scope browsing, editing and deletion, reset and status. REST: /api/config/memory, /api/memory, /api/memory//, /api/memory/reset. - Memory summarization runs in the daemon and the TUI only. Sessions idle for idle_minutes are extracted into redacted inbox observations; batches are consolidated into create/update/merge/delete plans that must cite their observations, leave manual entries alone and respect tombstones, and are applied with rollback. /memory flush runs both steps immediately. Progress, leases, tombstones and status live in a SQLite state database with a cross-process write lock. Purging a session withdraws its observations and the summary entries that only it supported. - Model output is parsed tolerantly, so models that write their reasoning into the reply still yield the JSON object. Spec and verification: openspec/changes/unify-memory-system. --- CLAUDE.md | 15 +- docs/daemon-api.md | 61 +- docs/help-source/group4.py | 8 +- docs/help-source/group6_reference.py | 4 +- docs/help-source/sources.json | 4 +- docs/help/assets/search-index.js | 24 +- docs/help/memory.html | 9 +- docs/help/reference.html | 4 +- docs/releases/v0.9.31.md | 11 + .../unify-memory-system/.openspec.yaml | 2 + .../changes/unify-memory-system/design.md | 112 +++ .../changes/unify-memory-system/proposal.md | 44 + .../specs/global-memory/spec.md | 280 +++++++ .../specs/memory-summarization/spec.md | 138 ++++ openspec/changes/unify-memory-system/tasks.md | 73 ++ .../unify-memory-system/verification.md | 53 ++ src/adapters/provider/leaked_tool_markup.cpp | 29 + .../provider/text_tool_call_recovery.cpp | 20 - src/adapters/tool/file_read_tool.cpp | 7 +- src/adapters/tool/memory_read_tool.cpp | 201 +++-- src/adapters/tool/memory_read_tool.hpp | 23 +- src/adapters/tool/memory_tool_scope.cpp | 22 + src/adapters/tool/memory_tool_scope.hpp | 19 + src/adapters/tool/memory_write_tool.cpp | 106 ++- src/adapters/tool/memory_write_tool.hpp | 17 +- src/apps/daemon/daemon_memory.cpp | 50 ++ src/apps/daemon/daemon_memory.hpp | 24 + src/apps/daemon/worker.cpp | 46 +- src/apps/headless/headless_runner.cpp | 25 +- src/apps/tui/app/tui_app.cpp | 10 +- src/apps/tui/app/tui_app.hpp | 5 + src/apps/tui/app/tui_app_memory.cpp | 46 ++ src/apps/tui/app/tui_app_session.cpp | 9 +- src/apps/tui/app/tui_app_shutdown.cpp | 2 + .../tui/app/tui_command_context_factory.cpp | 2 +- .../tui/app/tui_command_context_factory.hpp | 4 +- src/apps/tui/app/tui_runtime_init.cpp | 34 +- src/apps/tui/app/tui_runtime_init.hpp | 6 +- src/apps/tui/app/tui_services.cpp | 7 +- src/apps/tui/app/tui_services.hpp | 5 +- src/apps/tui/commands/command_registry.hpp | 4 +- src/apps/tui/commands/memory_command.cpp | 178 +--- src/apps/tui/commands/memory_command.hpp | 11 +- .../tui/settings/memory_settings_section.cpp | 147 ++++ .../tui/settings/memory_settings_section.hpp | 37 + src/apps/tui/settings/settings_center.cpp | 22 +- .../web/handlers/builtin_command_handler.cpp | 3 +- src/apps/web/handlers/commands_handler.cpp | 4 + src/apps/web/handlers/memory_handler.cpp | 151 ++++ src/apps/web/handlers/memory_handler.hpp | 46 ++ src/apps/web/routes/routes_memory.cpp | 167 ++++ src/apps/web/server.cpp | 1 + src/apps/web/server.hpp | 3 + src/apps/web/server_impl.hpp | 1 + src/base/config/config.cpp | 18 +- src/base/config/config.hpp | 21 +- src/base/config/memory_config.cpp | 55 ++ src/base/config/settings_mutations.cpp | 40 + src/base/config/settings_mutations.hpp | 6 + src/domain/memory/memory_forget.cpp | 59 ++ src/domain/memory/memory_forget.hpp | 23 + src/domain/memory/memory_frontmatter.cpp | 215 ++++- src/domain/memory/memory_frontmatter.hpp | 29 +- src/domain/memory/memory_inbox.cpp | 223 ++++++ src/domain/memory/memory_inbox.hpp | 67 ++ src/domain/memory/memory_paths.cpp | 48 +- src/domain/memory/memory_paths.hpp | 32 +- src/domain/memory/memory_registry.cpp | 186 ++++- src/domain/memory/memory_registry.hpp | 79 +- src/domain/memory/memory_service.cpp | 94 +++ src/domain/memory/memory_service.hpp | 66 ++ src/domain/memory/memory_state_store.cpp | 509 ++++++++++++ src/domain/memory/memory_state_store.hpp | 154 ++++ src/domain/memory/memory_types.cpp | 16 + src/domain/memory/memory_types.hpp | 29 +- src/domain/memory/secret_redaction.cpp | 266 ++++++ src/domain/memory/secret_redaction.hpp | 30 + src/domain/session/session_manager.cpp | 34 +- src/domain/session/session_manager.hpp | 7 + src/domain/session/session_manager_flags.cpp | 52 ++ .../session/session_purge_listeners.cpp | 57 ++ .../session/session_purge_listeners.hpp | 20 + src/domain/session/session_storage.cpp | 12 +- src/domain/session/session_storage.hpp | 8 + src/engine/agent/agent_loop.cpp | 2 +- src/engine/agent/agent_loop_services.hpp | 5 +- .../compaction/compaction_controller.cpp | 1 + .../agent/compaction/compaction_window.cpp | 1 + .../agent/request/api_request_builder.cpp | 54 +- .../agent/request/api_request_builder.hpp | 14 +- .../agent/request/prompt_context_cache.cpp | 13 + .../agent/request/prompt_context_cache.hpp | 14 + .../agent/request/request_context_factory.cpp | 19 +- .../agent/request/request_context_source.hpp | 4 +- src/engine/prompt/memory_prompt.cpp | 132 +++ src/engine/prompt/memory_prompt.hpp | 33 + src/engine/prompt/system_prompt.cpp | 56 +- src/engine/prompt/system_prompt.hpp | 8 +- src/host/session_host/memory_command.cpp | 264 ++++++ src/host/session_host/memory_command.hpp | 29 + .../session_host/memory_consolidation.cpp | 161 ++++ .../session_host/memory_consolidation.hpp | 33 + src/host/session_host/memory_runtime.cpp | 86 ++ src/host/session_host/memory_runtime.hpp | 66 ++ src/host/session_host/memory_scheduler.cpp | 703 ++++++++++++++++ src/host/session_host/memory_scheduler.hpp | 86 ++ src/host/session_host/memory_summary.cpp | 511 ++++++++++++ src/host/session_host/memory_summary.hpp | 110 +++ src/host/session_host/session_registry.cpp | 9 +- src/host/session_host/session_registry.hpp | 8 +- .../session_host/session_registry_config.cpp | 3 +- .../session_host/session_registry_memory.cpp | 23 + .../session_host/session_registry_memory.hpp | 16 + tests/agent/agent_loop_memory_test.cpp | 200 +++++ tests/agent/agent_loop_termination_test.cpp | 74 +- .../config_memory_and_instructions_test.cpp | 43 +- tests/config/settings_mutations_test.cpp | 35 + tests/memory/memory_forget_test.cpp | 162 ++++ tests/memory/memory_read_tool_test.cpp | 261 +++--- tests/memory/memory_registry_test.cpp | 3 +- tests/memory/memory_scope_store_test.cpp | 247 ++++++ tests/memory/memory_state_store_test.cpp | 163 ++++ tests/memory/memory_write_tool_test.cpp | 226 +++--- tests/memory/secret_redaction_test.cpp | 102 +++ tests/permissions/permissions_test.cpp | 14 + tests/prompt/expert_context_prompt_test.cpp | 2 +- tests/prompt/git_status_prompt_test.cpp | 10 +- tests/prompt/memory_prompt_test.cpp | 180 +++++ tests/prompt/skills_index_prompt_test.cpp | 4 +- tests/prompt/system_prompt_test.cpp | 66 +- tests/session_host/memory_command_test.cpp | 236 ++++++ tests/session_host/memory_runtime_test.cpp | 148 ++++ tests/session_host/memory_scheduler_test.cpp | 469 +++++++++++ tests/session_host/memory_summary_test.cpp | 254 ++++++ .../test_support/agent/agent_loop_fixture.hpp | 4 +- .../test_support/memory/memory_test_home.hpp | 99 +++ tests/tui/commands/memory_command_test.cpp | 96 +++ tests/tui/memory_settings_section_test.cpp | 72 ++ .../tui/tui_command_context_factory_test.cpp | 16 +- tests/web/builtin_command_handler_test.cpp | 10 + tests/web/commands_handler_test.cpp | 13 +- tests/web/memory_handler_test.cpp | 113 +++ tests/web/web_server_smoke_test.cpp | 147 ++++ web/scripts/i18n-en-overrides.mjs | 75 ++ web/src/App.jsx | 1 + web/src/components/MemorySettings.jsx | 757 ++++++++++++++++++ web/src/components/SettingsPage.jsx | 13 +- web/src/i18n/catalogs/en-US.js | 1 + web/src/i18n/catalogs/systemNotice.js | 2 + web/src/i18n/catalogs/zh-CN.js | 1 + web/src/i18n/sourceCatalog.generated.js | 150 ++++ web/src/lib/api.js | 15 + web/src/lib/builtinCommandRouting.test.js | 17 + web/src/lib/memorySettings.js | 425 ++++++++++ web/src/lib/memorySettings.test.js | 588 ++++++++++++++ web/src/lib/runTests.js | 1 + web/src/lib/settingsSearch.js | 3 + .../lib/settingsWindowArchitecture.test.js | 2 +- web/src/lib/slashCommands.js | 3 +- web/src/lib/slashCommands.test.js | 11 +- 160 files changed, 11837 insertions(+), 952 deletions(-) create mode 100644 docs/releases/v0.9.31.md create mode 100644 openspec/changes/unify-memory-system/.openspec.yaml create mode 100644 openspec/changes/unify-memory-system/design.md create mode 100644 openspec/changes/unify-memory-system/proposal.md create mode 100644 openspec/changes/unify-memory-system/specs/global-memory/spec.md create mode 100644 openspec/changes/unify-memory-system/specs/memory-summarization/spec.md create mode 100644 openspec/changes/unify-memory-system/tasks.md create mode 100644 openspec/changes/unify-memory-system/verification.md create mode 100644 src/adapters/provider/leaked_tool_markup.cpp create mode 100644 src/adapters/tool/memory_tool_scope.cpp create mode 100644 src/adapters/tool/memory_tool_scope.hpp create mode 100644 src/apps/daemon/daemon_memory.cpp create mode 100644 src/apps/daemon/daemon_memory.hpp create mode 100644 src/apps/tui/app/tui_app_memory.cpp create mode 100644 src/apps/tui/settings/memory_settings_section.cpp create mode 100644 src/apps/tui/settings/memory_settings_section.hpp create mode 100644 src/apps/web/handlers/memory_handler.cpp create mode 100644 src/apps/web/handlers/memory_handler.hpp create mode 100644 src/apps/web/routes/routes_memory.cpp create mode 100644 src/base/config/memory_config.cpp create mode 100644 src/domain/memory/memory_forget.cpp create mode 100644 src/domain/memory/memory_forget.hpp create mode 100644 src/domain/memory/memory_inbox.cpp create mode 100644 src/domain/memory/memory_inbox.hpp create mode 100644 src/domain/memory/memory_service.cpp create mode 100644 src/domain/memory/memory_service.hpp create mode 100644 src/domain/memory/memory_state_store.cpp create mode 100644 src/domain/memory/memory_state_store.hpp create mode 100644 src/domain/memory/secret_redaction.cpp create mode 100644 src/domain/memory/secret_redaction.hpp create mode 100644 src/domain/session/session_manager_flags.cpp create mode 100644 src/domain/session/session_purge_listeners.cpp create mode 100644 src/domain/session/session_purge_listeners.hpp create mode 100644 src/engine/prompt/memory_prompt.cpp create mode 100644 src/engine/prompt/memory_prompt.hpp create mode 100644 src/host/session_host/memory_command.cpp create mode 100644 src/host/session_host/memory_command.hpp create mode 100644 src/host/session_host/memory_consolidation.cpp create mode 100644 src/host/session_host/memory_consolidation.hpp create mode 100644 src/host/session_host/memory_runtime.cpp create mode 100644 src/host/session_host/memory_runtime.hpp create mode 100644 src/host/session_host/memory_scheduler.cpp create mode 100644 src/host/session_host/memory_scheduler.hpp create mode 100644 src/host/session_host/memory_summary.cpp create mode 100644 src/host/session_host/memory_summary.hpp create mode 100644 src/host/session_host/session_registry_memory.cpp create mode 100644 src/host/session_host/session_registry_memory.hpp create mode 100644 tests/agent/agent_loop_memory_test.cpp create mode 100644 tests/memory/memory_forget_test.cpp create mode 100644 tests/memory/memory_scope_store_test.cpp create mode 100644 tests/memory/memory_state_store_test.cpp create mode 100644 tests/memory/secret_redaction_test.cpp create mode 100644 tests/prompt/memory_prompt_test.cpp create mode 100644 tests/session_host/memory_command_test.cpp create mode 100644 tests/session_host/memory_runtime_test.cpp create mode 100644 tests/session_host/memory_scheduler_test.cpp create mode 100644 tests/session_host/memory_summary_test.cpp create mode 100644 tests/test_support/memory/memory_test_home.hpp create mode 100644 tests/tui/commands/memory_command_test.cpp create mode 100644 tests/tui/memory_settings_section_test.cpp create mode 100644 tests/web/memory_handler_test.cpp create mode 100644 web/src/components/MemorySettings.jsx create mode 100644 web/src/lib/memorySettings.js create mode 100644 web/src/lib/memorySettings.test.js diff --git a/CLAUDE.md b/CLAUDE.md index e8e9307aa..cbd214dda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -316,9 +316,14 @@ 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 一个进程服务多个工作区。 -记忆在 TUI 与 daemon(桌面版 / Web)两端都接线: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_write` 时带一段 `# Memory` 指引(「记住」一律走 `memory_write`,别另写笔记文件)。headless 仍不注册记忆工具。 +- 存储:`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`)之后清空观测,因为之前的读取结果可能已被清成占位符。 @@ -762,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/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.cpp b/src/adapters/provider/text_tool_call_recovery.cpp index e4450964f..0dad5f65c 100644 --- a/src/adapters/provider/text_tool_call_recovery.cpp +++ b/src/adapters/provider/text_tool_call_recovery.cpp @@ -1203,26 +1203,6 @@ bool text_contains_tool_call_markup(std::string_view text) { return detect_suspicious_text_tool_call(text).has_value(); } -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 (starts_with_at(text, i, marker)) return std::string(marker); - } - } - fence.feed(c); - } - return std::nullopt; -} - // ---- 混合形态:回显比对 ----------------------------------------------------- namespace { diff --git a/src/adapters/tool/file_read_tool.cpp b/src/adapters/tool/file_read_tool.cpp index 1430fd3d0..811ace4d8 100644 --- a/src/adapters/tool/file_read_tool.cpp +++ b/src/adapters/tool/file_read_tool.cpp @@ -735,7 +735,6 @@ ToolResult execute_file_read( return ToolResult{ToolErrors::cannot_open_file(request.file_path), false}; } - // 按会话去重:同一进程里别的会话(含子代理)读过,不代表这个会话见过内容。 auto unchanged_observation = MtimeTracker::instance().unchanged_read_observation( request.file_path, @@ -743,8 +742,7 @@ ToolResult execute_file_read( request.end_line, request.byte_mode, request.byte_offset, - request.requested_max_bytes, - ctx.session_id); + request.requested_max_bytes, ctx.session_id); // 按会话去重 if (unchanged_observation.has_value()) { ToolSummary summary; summary.verb = "Read"; @@ -965,8 +963,7 @@ ToolResult execute_file_read( request.end_line, request.byte_mode, request.byte_offset, - request.requested_max_bytes, - ctx.session_id); + 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 f12a413ad..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,78 +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 (!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()) { + 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)可能刚写过新条目;目录里只有 // 几个小文件,每次读前重扫的代价可以忽略。 - registry.reload(); + 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)}); + } + } + nlohmann::json out; 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); + 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"] = 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); + 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}; } - 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}; + nlohmann::json arr = nlohmann::json::array(); + 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; } - 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); - } - 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); - } - out["entries"] = arr; - out["count"] = entries.size(); - if (entries.empty()) { - out["message"] = type_filter.has_value() - ? ("No memory entries of type '" + type_str + "'.") + 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/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 716c3353d..9256e2e7e 100644 --- a/src/apps/daemon/worker.cpp +++ b/src/apps/daemon/worker.cpp @@ -57,10 +57,8 @@ #include "tool/grep_tool.hpp" #include "tool/glob_tool.hpp" #include "tool/goal_tool.hpp" -#include "memory/memory_paths.hpp" -#include "memory/memory_registry.hpp" -#include "tool/memory_read_tool.hpp" -#include "tool/memory_write_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" @@ -477,26 +475,10 @@ 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()); - // 记忆(~/.acecode/memory):与 TUI(tui_runtime_init.cpp 的 - // initialize_memory_registry)同一套初始化与工具,先于 ToolExecutor 构造, - // 工具闭包引用它、析构顺序要反过来。daemon 曾经整条缺失(memory_registry = - // nullptr):桌面版 / Web 会话既没有记忆索引注入也没有记忆工具,用户让模型 - // 「记住」时它只能把经验写进不会被自动加载的文件(反馈 LINDANDAN069)。 - acecode::MemoryRegistry memory_registry; - acecode::MemoryConfig runtime_memory_cfg = cfg_mut.memory; - { - std::error_code memory_dir_ec; - std::filesystem::create_directories(acecode::get_memory_dir(), memory_dir_ec); - if (memory_dir_ec) { - LOG_ERROR("[memory] failed to create " + - acecode::get_memory_dir().generic_string() + ": " + - memory_dir_ec.message() + - " - memory will be disabled for this daemon"); - runtime_memory_cfg.enabled = false; - } else if (runtime_memory_cfg.enabled) { - memory_registry.scan(); - } - } + // 记忆:三入口共用 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); @@ -510,9 +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)); - tools.register_tool(acecode::create_memory_read_tool( - memory_registry, runtime_memory_cfg.max_index_bytes)); - tools.register_tool(acecode::create_memory_write_tool(memory_registry)); + memory_runtime->register_tools(tools); // spawn_subagent / wait_subagent:daemon 专属(TUI 无 SessionRegistry 不注册)。 // ToolExecutor 先于 SessionRegistry 构造,deps 用 shared_ptr 延迟回填 —— 回填 @@ -544,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 = &memory_registry; - reg_deps.memory_cfg = &runtime_memory_cfg; + 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; @@ -634,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; @@ -805,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; @@ -818,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 1e0eccfca..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,8 +58,25 @@ 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(); @@ -66,38 +85,34 @@ void MemoryRegistry::scan() { 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 { @@ -121,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 {}; @@ -130,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; } @@ -142,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); @@ -161,45 +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); @@ -212,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(); @@ -221,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()) { @@ -228,13 +298,12 @@ 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); @@ -253,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); @@ -267,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 6a2ad035a..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,10 +84,25 @@ 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; @@ -69,7 +111,14 @@ class MemoryRegistry { 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