Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions libs/memory/include/merak/openai_embedding_provider.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,11 @@ class OpenAIEmbeddingProvider : public EmbeddingProvider {
std::future<std::vector<float>> embed(const std::string& text) override;
std::future<std::vector<std::vector<float>>> embed_batch(
const std::vector<std::string>& 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<float> embedding;
Expand Down
1 change: 1 addition & 0 deletions libs/memory/src/openai_embedding_provider.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
18 changes: 17 additions & 1 deletion libs/tools/include/merak/shell_tool.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
#pragma once
#include <merak/tool_base.hpp>
#include <string>
#include <map>
#include <mutex>
#include <chrono>

namespace merak::tools {

Expand All @@ -11,12 +14,25 @@ class BashTool : public Tool {
PermissionLevel permission() const override { return PermissionLevel::ask; }
std::future<ToolResult> execute(ToolCall call, ToolExecutionContext context = {}) override;
std::unique_ptr<Tool> clone() const override {
return std::make_unique<BashTool>(*this);
// Note: manually clone cache fields since std::mutex is non-copyable
auto cloned = std::make_unique<BashTool>();
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<std::string, CacheEntry> readonly_cache_;
mutable std::mutex cache_mutex_;
};

} // namespace merak::tools
20 changes: 6 additions & 14 deletions libs/tools/src/shell_tool.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string, CacheEntry> readonly_cache;
static std::mutex readonly_cache_mutex;
static constexpr int kCacheTTLSeconds = 60;

// ---------- Execute ----------
Expand Down Expand Up @@ -235,7 +227,7 @@ ToolMeta BashTool::meta() const {
}

std::future<ToolResult> 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;
Expand All @@ -257,9 +249,9 @@ std::future<ToolResult> BashTool::execute(ToolCall call, ToolExecutionContext co

// ——— Read-only command cache (60s TTL) ———
if (is_safe_readonly(command)) {
std::lock_guard<std::mutex> lock(readonly_cache_mutex);
auto it = readonly_cache.find(command);
if (it != readonly_cache.end()) {
std::lock_guard<std::mutex> 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)) {
result.output = it->second.output;
Expand Down Expand Up @@ -382,8 +374,8 @@ std::future<ToolResult> BashTool::execute(ToolCall call, ToolExecutionContext co

// ——— Store in read-only cache ———
if (is_safe_readonly(command)) {
std::lock_guard<std::mutex> lock(readonly_cache_mutex);
readonly_cache[command] = {result.output, exit_code, std::chrono::steady_clock::now()};
std::lock_guard<std::mutex> lock(cache_mutex_);
readonly_cache_[command] = {result.output, exit_code, std::chrono::steady_clock::now()};
}

result.duration_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ struct SceneWrapUp {
std::vector<LeakRisk> leak_risks;
ForeshadowStats chapter_foreshadow_stats;
std::vector<std::string> compressed_memories; // 新增: summary_id from auto-compression
std::vector<std::string> warnings; // non-critical errors from best-effort operations
};

class SceneOrchestrator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class VoiceAnalyzer {
group_voices(const std::vector<VoiceFingerprint>& 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<std::string, VoiceFingerprint> fingerprints_;
};

Expand Down
17 changes: 10 additions & 7 deletions libs/worldbuilding/src/scene_orchestrator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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());
}
}
}
Expand All @@ -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";
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 + " 语音指纹更新失败");
}
}
}
Expand All @@ -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()));
}
}
}
Expand All @@ -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 + " 记忆自动压缩失败");
}
}

Expand Down
69 changes: 61 additions & 8 deletions libs/worldbuilding/src/secret_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<unsigned char>(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<unsigned char>(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,
Expand Down Expand Up @@ -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<int>(pos) - static_cast<int>(pv_pos);
if (std::abs(dist) < 200) {
risk.reason += " (靠近公开版本,置信度较低)";
}
}
}

risks.push_back(risk);
}
}

Expand Down
4 changes: 4 additions & 0 deletions libs/worldbuilding/src/voice_analyzer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions libs/worldbuilding/tests/test_foreshadowing_secret.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading