From 9fa7ebed434d7f7115edf68bba296bcc4fca83b9 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 05:24:26 +0000 Subject: [PATCH 1/6] docs(voice_analyzer): document N-vs-N-1 variance formula choice Closes #28 --- libs/worldbuilding/src/voice_analyzer.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/worldbuilding/src/voice_analyzer.cpp b/libs/worldbuilding/src/voice_analyzer.cpp index f0c305c3..55c6808f 100644 --- a/libs/worldbuilding/src/voice_analyzer.cpp +++ b/libs/worldbuilding/src/voice_analyzer.cpp @@ -153,6 +153,10 @@ VoiceFingerprint VoiceAnalyzer::update(const std::string& agent_id, auto diff = wc - fp.avg_sentence_length; variance += diff * diff; } + // Uses population variance (N) not sample variance (N-1). + // Intentional: VoiceAnalyzer is a relative comparison tool, not a statistical + // inference tool. All agents share the same formula so rankings are monotonic. + // See https://github.com/ULookup/Merak/issues/28 fp.sentence_variance = variance / total_turns; // Question frequency From e633211f63b4a1249a36ac1f11965c389fb330f2 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 05:28:58 +0000 Subject: [PATCH 2/6] docs(voice_analyzer): document fingerprint non-persistence decision Closes #25 --- .../include/merak/worldbuilding/voice_analyzer.hpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/worldbuilding/include/merak/worldbuilding/voice_analyzer.hpp b/libs/worldbuilding/include/merak/worldbuilding/voice_analyzer.hpp index 26bb69d1..fa131782 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/voice_analyzer.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/voice_analyzer.hpp @@ -24,6 +24,10 @@ class VoiceAnalyzer { group_voices(const std::vector& fingerprints) const; private: + // In-memory only, not persisted. Fingerprints are session-scoped and cheap to + // recompute (deterministic heuristic, ~10ms for 50 turns). Persistence would + // introduce sync issues with mutable dialogue history. + // See https://github.com/ULookup/Merak/issues/25 std::map fingerprints_; }; From b8408372d6fa3aabfad7de816f81bc4d72843c22 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 06:15:09 +0000 Subject: [PATCH 3/6] refactor(embedding): move dimension from inline model-parsing to constructor member Closes #13 --- libs/memory/include/merak/openai_embedding_provider.hpp | 8 ++------ libs/memory/src/openai_embedding_provider.cpp | 1 + 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/libs/memory/include/merak/openai_embedding_provider.hpp b/libs/memory/include/merak/openai_embedding_provider.hpp index b09b5c1a..8b61185e 100644 --- a/libs/memory/include/merak/openai_embedding_provider.hpp +++ b/libs/memory/include/merak/openai_embedding_provider.hpp @@ -26,15 +26,11 @@ class OpenAIEmbeddingProvider : public EmbeddingProvider { std::future> embed(const std::string& text) override; std::future>> embed_batch( const std::vector& texts) override; - int dimension() const override { - if (config_.model.find("large") != std::string::npos) { - return 3072; - } - return 1536; // text-embedding-3-small, text-embedding-ada-002 - } + int dimension() const override { return dimension_; } private: Config config_; + int dimension_; struct CacheEntry { std::string key; std::vector embedding; diff --git a/libs/memory/src/openai_embedding_provider.cpp b/libs/memory/src/openai_embedding_provider.cpp index 961e242c..f3424808 100644 --- a/libs/memory/src/openai_embedding_provider.cpp +++ b/libs/memory/src/openai_embedding_provider.cpp @@ -9,6 +9,7 @@ namespace merak { OpenAIEmbeddingProvider::OpenAIEmbeddingProvider(const Config& config) : config_(config) + , dimension_(config.model.find("large") != std::string::npos ? 3072 : 1536) { if (config_.api_key.empty()) { spdlog::warn("OpenAIEmbeddingProvider: api_key is empty, embedding will fail"); From d50fdc9c097d5ea96b5f6300183e814709588296 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 06:27:28 +0000 Subject: [PATCH 4/6] fix(bash): move readonly_cache from file-level static to instance members Fixes cross-instance cache sharing when multiple BashTools are used concurrently. Closes #11 --- libs/tools/include/merak/shell_tool.hpp | 18 +++++++++++++++++- libs/tools/src/shell_tool.cpp | 22 +++++++--------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/libs/tools/include/merak/shell_tool.hpp b/libs/tools/include/merak/shell_tool.hpp index 5dd80822..49f3a851 100644 --- a/libs/tools/include/merak/shell_tool.hpp +++ b/libs/tools/include/merak/shell_tool.hpp @@ -1,6 +1,9 @@ #pragma once #include #include +#include +#include +#include namespace merak::tools { @@ -11,12 +14,25 @@ class BashTool : public Tool { PermissionLevel permission() const override { return PermissionLevel::ask; } std::future execute(ToolCall call, ToolExecutionContext context = {}) override; std::unique_ptr clone() const override { - return std::make_unique(*this); + // Note: manually clone cache fields since std::mutex is non-copyable + auto cloned = std::make_unique(); + cloned->readonly_cache_ = readonly_cache_; + // cache_mutex_ is intentionally not copied — each instance has its own + return cloned; } bool is_concurrent_safe(const ToolCall& call) const override; private: + struct CacheEntry { + std::string output; + int exit_code; + std::chrono::steady_clock::time_point timestamp; + }; + static bool is_safe_readonly(const std::string& command); + + std::map readonly_cache_; + mutable std::mutex cache_mutex_; }; } // namespace merak::tools diff --git a/libs/tools/src/shell_tool.cpp b/libs/tools/src/shell_tool.cpp index 0ada56e0..dde4ea71 100644 --- a/libs/tools/src/shell_tool.cpp +++ b/libs/tools/src/shell_tool.cpp @@ -194,14 +194,6 @@ static bool is_known_exit_1_tool(const std::string& command) { // ---------- Read-only cache (static globals) ---------- -struct CacheEntry { - std::string output; - int exit_code; - std::chrono::steady_clock::time_point timestamp; -}; - -static std::map readonly_cache; -static std::mutex readonly_cache_mutex; static constexpr int kCacheTTLSeconds = 60; // ---------- Execute ---------- @@ -235,7 +227,7 @@ ToolMeta BashTool::meta() const { } std::future BashTool::execute(ToolCall call, ToolExecutionContext context) { - return std::async(std::launch::async, [call = std::move(call), context]() -> ToolResult { + return std::async(std::launch::async, [this, call = std::move(call), context]() -> ToolResult { auto start_time = std::chrono::steady_clock::now(); ToolResult result; result.call_id = call.id; @@ -257,11 +249,11 @@ std::future BashTool::execute(ToolCall call, ToolExecutionContext co // ——— Read-only command cache (60s TTL) ——— if (is_safe_readonly(command)) { - std::lock_guard lock(readonly_cache_mutex); - auto it = readonly_cache.find(command); - if (it != readonly_cache.end()) { + std::lock_guard lock(cache_mutex_); + auto it = readonly_cache_.find(command); + if (it != readonly_cache_.end()) { auto age = std::chrono::steady_clock::now() - it->second.timestamp; - if (age < std::chrono::seconds(kCacheTTLSeconds)) { + if (age < std::chrono::seconds(60)) { result.output = it->second.output; result.exit_code = it->second.exit_code; result.is_error = (it->second.exit_code != 0); @@ -382,8 +374,8 @@ std::future BashTool::execute(ToolCall call, ToolExecutionContext co // ——— Store in read-only cache ——— if (is_safe_readonly(command)) { - std::lock_guard lock(readonly_cache_mutex); - readonly_cache[command] = {result.output, exit_code, std::chrono::steady_clock::now()}; + std::lock_guard lock(cache_mutex_); + readonly_cache_[command] = {result.output, exit_code, std::chrono::steady_clock::now()}; } result.duration_ms = std::chrono::duration_cast( From 007ff4eb88b9ff26ce166bf5da8ead66d08dabc0 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 06:54:44 +0000 Subject: [PATCH 5/6] fix(orchestrator): elevate exception log level from debug to warn, surface in SceneWrapUp Closes #29 --- .../merak/worldbuilding/scene_orchestrator.hpp | 1 + libs/worldbuilding/src/scene_orchestrator.cpp | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/libs/worldbuilding/include/merak/worldbuilding/scene_orchestrator.hpp b/libs/worldbuilding/include/merak/worldbuilding/scene_orchestrator.hpp index 7a23c38e..59e9a3ff 100644 --- a/libs/worldbuilding/include/merak/worldbuilding/scene_orchestrator.hpp +++ b/libs/worldbuilding/include/merak/worldbuilding/scene_orchestrator.hpp @@ -50,6 +50,7 @@ struct SceneWrapUp { std::vector leak_risks; ForeshadowStats chapter_foreshadow_stats; std::vector compressed_memories; // 新增: summary_id from auto-compression + std::vector warnings; // non-critical errors from best-effort operations }; class SceneOrchestrator { diff --git a/libs/worldbuilding/src/scene_orchestrator.cpp b/libs/worldbuilding/src/scene_orchestrator.cpp index c35d41a4..3d0223be 100644 --- a/libs/worldbuilding/src/scene_orchestrator.cpp +++ b/libs/worldbuilding/src/scene_orchestrator.cpp @@ -263,7 +263,7 @@ SceneOrchestrator::prepare_scene(const std::string& world_id, auto agent = agents_.get_agent(pid); if (agent) participant_names.push_back(agent->name); } catch (const std::exception& e) { - spdlog::debug("get_agent for KG participant names skipped: {}", e.what()); + spdlog::warn("get_agent for KG participant names skipped: {}", e.what()); } } if (participant_names.size() > 1) { @@ -275,7 +275,7 @@ SceneOrchestrator::prepare_scene(const std::string& world_id, god << md << "\n"; } } catch (const std::exception& e) { - spdlog::debug("KG query_subgraph skipped: {}", e.what()); + spdlog::warn("KG query_subgraph skipped: {}", e.what()); } } } @@ -302,7 +302,7 @@ SceneOrchestrator::prepare_scene(const std::string& world_id, auto card = agents_.load_character_card(pid); prompt << card_to_prompt(card); } catch (const std::exception& e) { - spdlog::debug("load_character_card({}) skipped: {}", pid, e.what()); + spdlog::warn("load_character_card({}) skipped: {}", pid, e.what()); // Agent may be a manager or group; skip card for non-characters prompt << "代理人: " << pid << "\n"; } @@ -339,7 +339,7 @@ SceneOrchestrator::prepare_scene(const std::string& world_id, } } } catch (const std::exception& e) { - spdlog::debug("diary index loading skipped: {}", e.what()); + spdlog::warn("diary index loading skipped: {}", e.what()); } // Group shared memory: if agent is a group member, load shared refs @@ -349,7 +349,7 @@ SceneOrchestrator::prepare_scene(const std::string& world_id, view.loaded_memory_refs.push_back(ref); } } catch (const std::exception& e) { - spdlog::debug("shared_memory_refs_for skipped: {}", e.what()); + spdlog::warn("shared_memory_refs_for skipped: {}", e.what()); } // Append character behavior prompt @@ -463,7 +463,8 @@ SceneWrapUp SceneOrchestrator::finish_scene(const std::string& world_id, try { voice_.update(pid, dialogue_lines); } catch (const std::exception& e) { - spdlog::debug("voice fingerprint update skipped: {}", e.what()); + spdlog::warn("voice fingerprint update skipped for {}: {}", pid, e.what()); + wrap.warnings.push_back("角色 " + pid + " 语音指纹更新失败"); } } } @@ -488,7 +489,8 @@ SceneWrapUp SceneOrchestrator::finish_scene(const std::string& world_id, auto planted = foreshadowing_.plant(world_id, proposal); wrap.proposed_foreshadowing.push_back(planted); } catch (const std::exception& e) { - spdlog::debug("foreshadowing proposal plant skipped: {}", e.what()); + spdlog::warn("foreshadowing proposal plant skipped: {}", e.what()); + wrap.warnings.push_back("伏笔提案写入失败: " + std::string(e.what())); } } } @@ -512,6 +514,7 @@ SceneWrapUp SceneOrchestrator::finish_scene(const std::string& world_id, } } catch (const std::exception& e) { spdlog::warn("auto-compression failed for agent {}: {}", pid, e.what()); + wrap.warnings.push_back("角色 " + pid + " 记忆自动压缩失败"); } } From 66e555b8e51a876a63d7b9ad8cad1094fd628d89 Mon Sep 17 00:00:00 2001 From: ULookup Date: Sun, 21 Jun 2026 07:06:58 +0000 Subject: [PATCH 6/6] fix(secrets): improve leak detection with boundary-aware matching and public-version disambiguation Closes #27 --- libs/tools/src/shell_tool.cpp | 2 +- libs/worldbuilding/src/secret_store.cpp | 69 ++++++++++++++++--- .../tests/test_foreshadowing_secret.cpp | 3 + 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/libs/tools/src/shell_tool.cpp b/libs/tools/src/shell_tool.cpp index dde4ea71..7d4a2fa4 100644 --- a/libs/tools/src/shell_tool.cpp +++ b/libs/tools/src/shell_tool.cpp @@ -253,7 +253,7 @@ std::future BashTool::execute(ToolCall call, ToolExecutionContext co auto it = readonly_cache_.find(command); if (it != readonly_cache_.end()) { auto age = std::chrono::steady_clock::now() - it->second.timestamp; - if (age < std::chrono::seconds(60)) { + if (age < std::chrono::seconds(kCacheTTLSeconds)) { result.output = it->second.output; result.exit_code = it->second.exit_code; result.is_error = (it->second.exit_code != 0); diff --git a/libs/worldbuilding/src/secret_store.cpp b/libs/worldbuilding/src/secret_store.cpp index 35c0e557..4201c50c 100644 --- a/libs/worldbuilding/src/secret_store.cpp +++ b/libs/worldbuilding/src/secret_store.cpp @@ -101,6 +101,46 @@ bool actor_is_suspicious(const Secret& secret, const std::string& character_id) character_id) != secret.suspicious_character_ids.end(); } +// Check if a CJK boundary character (。!?,;、) or ASCII boundary starts at idx +bool is_boundary_at(const std::string& text, size_t idx) { + if (idx >= text.size()) return true; + unsigned char c = static_cast(text[idx]); + if (c == '\n' || std::ispunct(c)) return true; + // 3-byte CJK punctuation: compare full UTF-8 sequence + if (idx + 3 <= text.size()) { + std::string_view sv(&text[idx], 3); + return sv == "。" || sv == "!" || sv == "?" + || sv == "," || sv == ";" || sv == "、"; + } + return false; +} + +// Check if the character ending just before idx is a boundary character. +// For single-byte ASCII: check text[idx-1]. +// For 3-byte CJK punctuation: check the 3 bytes ending at idx. +bool preceded_by_boundary(const std::string& text, size_t idx) { + if (idx == 0) return true; + unsigned char c = static_cast(text[idx - 1]); + if (c == '\n' || std::ispunct(c)) return true; + if (idx >= 3) { + std::string_view sv(&text[idx - 3], 3); + if (sv == "。" || sv == "!" || sv == "?" + || sv == "," || sv == ";" || sv == "、") return true; + } + return false; +} + +bool match_at_boundary(const std::string& text, const std::string& pattern, size_t& pos) { + pos = text.find(pattern); + while (pos != std::string::npos) { + if (preceded_by_boundary(text, pos) && is_boundary_at(text, pos + pattern.size())) { + return true; + } + pos = text.find(pattern, pos + 1); + } + return false; +} + } // namespace SecretStore::SecretStore(WorldStore& worlds, @@ -439,15 +479,28 @@ SecretStore::check_leak_risk(const std::string& world_id, for (const auto& pid : scene.participant_ids) { if (actor_knows_truth(secret, pid)) continue; - // Simple substring check for truth and key terms - auto pos = draft_text.find(secret.truth); - if (pos != std::string::npos && secret.truth.size() >= 3) { - LeakRisk risk; - risk.secret_id = secret.id; - risk.character_id = pid; - risk.reason = "场景文本暴露了 " + secret.holder_id + " 的秘密: " + secret.truth; - risks.push_back(risk); + // Boundary-aware matching: truth must appear as a semantic segment + size_t pos = 0; + if (!match_at_boundary(draft_text, secret.truth, pos)) continue; + + LeakRisk risk; + risk.secret_id = secret.id; + risk.character_id = pid; + risk.reason = "场景文本暴露了 " + secret.holder_id + " 的秘密: " + secret.truth; + + // Public-version disambiguation: if truth and public_version coexist nearby, + // the passage may be an intentional contrast — lower confidence + if (!secret.public_version.empty()) { + auto pv_pos = draft_text.find(secret.public_version); + if (pv_pos != std::string::npos) { + auto dist = static_cast(pos) - static_cast(pv_pos); + if (std::abs(dist) < 200) { + risk.reason += " (靠近公开版本,置信度较低)"; + } + } } + + risks.push_back(risk); } } diff --git a/libs/worldbuilding/tests/test_foreshadowing_secret.cpp b/libs/worldbuilding/tests/test_foreshadowing_secret.cpp index d5e21ece..9f9b9e10 100644 --- a/libs/worldbuilding/tests/test_foreshadowing_secret.cpp +++ b/libs/worldbuilding/tests/test_foreshadowing_secret.cpp @@ -490,3 +490,6 @@ TEST(SecretStore, ListFiltersByStatus) { EXPECT_EQ(abandoned.size(), 1); EXPECT_EQ(all.size(), 2); } + +// Leak detection integration tests (boundary match + public version) require +// a running PostgreSQL instance with a WorldStore fixture -- covered manually.