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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,15 @@ Both syntaxes use the strict regex `[a-z][a-z0-9_]*` for NAME.
~/.loopal/settings.json Global settings
~/.loopal/LOOPAL.md Global instructions (injected into system prompt)
~/.loopal/classifier.md Optional custom Classifier-mode system prompt
~/.loopal/sessions/{id}/memory.db Per-session memory graph SQLite (derived from .loopal/memory/*.md)
<project>/.loopal/settings.json Project settings
<project>/.loopal/classifier.md Project-level Classifier prompt override
<project>/.loopal/settings.local.json Local overrides (gitignored)
<project>/.loopal/memory/*.md User-tracked source-of-truth memory notes (NOT runtime data)
```

**Per-session derived data**: `memory.db` lives under `~/.loopal/sessions/{session_id}/`, NEVER under `.loopal/memory/`. The `.loopal/memory/` directory is user SSOT (`.md` source notes only). Derived index is rebuilt from source on each session start; no cross-session reuse.

`classifier.md` is loaded in the same global → project → local order as settings, but with **replace semantics** (highest-priority non-empty layer wins; not concatenated). Absent on every layer ⇒ the built-in default prompt is used.

Environment variable overrides use `LOOPAL_` prefix. Key settings:
Expand Down
7 changes: 7 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ crate.spec(package = "agent-client-protocol-schema", version = "0.11")
crate.spec(package = "strum", version = "0.28", features = ["derive"])
crate.spec(package = "derive_more", version = "2", features = ["from", "display"])

# --- Memory Graph / Embedded SQLite ---
crate.spec(package = "rusqlite", version = "0.32", features = ["bundled", "chrono"])
crate.spec(package = "notify", version = "6")
crate.spec(package = "notify-debouncer-mini", version = "0.4")
crate.spec(package = "unicode-segmentation", version = "1")
crate.spec(package = "flate2", version = "1")

# --- Testing ---
crate.spec(package = "tempfile", version = "3")
crate.spec(package = "wiremock", version = "0.6")
Expand Down
834 changes: 601 additions & 233 deletions MODULE.bazel.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/loopal-agent-server/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ rust_library(
"@crates//:tokio",
"@crates//:tokio-util",
"@crates//:tracing",
"@crates//:uuid",
],
proc_macro_deps = ["@crates//:async-trait"],
)
Expand All @@ -61,6 +62,7 @@ rust_test(
"//crates/loopal-ipc",
"//crates/loopal-kernel",
"//crates/loopal-mcp",
"//crates/loopal-memory",
"//crates/loopal-protocol",
"//crates/loopal-provider-api",
"//crates/loopal-runtime",
Expand Down
8 changes: 3 additions & 5 deletions crates/loopal-agent-server/src/agent_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub async fn build_with_frontend(ctx: AgentSetupContext<'_>) -> anyhow::Result<A
session_dir_override,
hub,
decision_context,
session_id,
} = ctx;
let router = build_model_router(&config.settings);
let model = router
Expand All @@ -42,12 +43,9 @@ pub async fn build_with_frontend(ctx: AgentSetupContext<'_>) -> anyhow::Result<A
let (session, resume_turns) = if let Some(ref sid) = start.resume {
session_manager.resume_session(sid)?
} else {
(session_manager.create_session(cwd, &model)?, Vec::new())
let s = session_manager.create_session_with_id(cwd, &model, session_id)?;
(s, Vec::new())
};
// fork-context arrives as wire-format Vec<Message> from the parent;
// convert to a synthetic Turn (SystemNote Injection) so the sub-agent's
// first LLM call sees the parent history. TurnStore is wire-build SSOT —
// stuffing fork messages only into the projected view leaves wire empty.
let mut initial_turns = resume_turns;
if let Some(fork_turn) = build_fork_synthetic_turn(start) {
initial_turns.insert(0, fork_turn);
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent-server/src/agent_setup_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ pub struct AgentSetupContext<'a> {
pub session_dir_override: Option<&'a std::path::Path>,
pub hub: &'a crate::session_hub::SessionHub,
pub decision_context: loopal_runtime::frontend::DecisionContext,
pub session_id: &'a str,
}

impl<'a> AgentSetupContext<'a> {
Expand All @@ -53,6 +54,7 @@ impl<'a> AgentSetupContext<'a> {
session_dir_override: Option<&'a std::path::Path>,
hub: &'a crate::session_hub::SessionHub,
decision_context: loopal_runtime::frontend::DecisionContext,
session_id: &'a str,
) -> Self {
Self {
cwd,
Expand All @@ -66,6 +68,7 @@ impl<'a> AgentSetupContext<'a> {
session_dir_override,
hub,
decision_context,
session_id,
}
}
}
1 change: 1 addition & 0 deletions crates/loopal-agent-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod ipc_handlers;
mod mcp_settle;
mod memory_adapter;
mod memory_consolidation;
mod memory_init;
mod mock_loader;
#[doc(hidden)]
pub mod params;
Expand Down
4 changes: 2 additions & 2 deletions crates/loopal-agent-server/src/memory_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use tracing::{info, warn};

use loopal_agent::shared::AgentShared;
use loopal_agent::spawn::{SpawnParams, SpawnTarget, spawn_agent, wait_agent};
use loopal_memory::{MEMORY_AGENT_PROMPT, MemoryProcessor};
use loopal_memory::{MEMORY_AGENT_PROMPT, MemoryProcessor, PROJECT_MEMORY_DIR};
use loopal_tool_api::MemoryChannel;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -139,7 +139,7 @@ pub fn build_memory_channel(
// Check if consolidation is due
if settings.memory.consolidation_interval_days > 0
&& loopal_memory::consolidation::needs_consolidation(
&shared.cwd.join(".loopal/memory"),
&shared.cwd.join(PROJECT_MEMORY_DIR),
settings.memory.consolidation_interval_days,
)
{
Expand Down
6 changes: 3 additions & 3 deletions crates/loopal-agent-server/src/memory_consolidation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use tracing::{info, warn};

use loopal_agent::shared::AgentShared;
use loopal_agent::spawn::{SpawnParams, SpawnTarget, spawn_agent, wait_agent};
use loopal_memory::MEMORY_CONSOLIDATION_PROMPT;
use loopal_memory::{MEMORY_CONSOLIDATION_PROMPT, PROJECT_MEMORY_DIR};

use super::memory_adapter::ServerMemoryProcessor;

Expand All @@ -15,7 +15,7 @@ use super::memory_adapter::ServerMemoryProcessor;
/// Runs in the background (non-blocking). Uses a `.consolidation_lock` file
/// as an optimistic lock to prevent concurrent consolidations.
pub fn trigger_consolidation(shared: &Arc<AgentShared>, model: &str) {
let memory_dir = shared.cwd.join(".loopal/memory");
let memory_dir = shared.cwd.join(PROJECT_MEMORY_DIR);

let lock_path = match loopal_memory::consolidation::try_acquire_lock(&memory_dir) {
Some(path) => path,
Expand All @@ -28,7 +28,7 @@ pub fn trigger_consolidation(shared: &Arc<AgentShared>, model: &str) {
let shared = shared.clone();
let model = model.to_string();
tokio::spawn(async move {
let memory_dir = shared.cwd.join(".loopal/memory");
let memory_dir = shared.cwd.join(PROJECT_MEMORY_DIR);
let today = loopal_memory::date::today_str();
let name = ServerMemoryProcessor::make_agent_name("memory-consolidation");
let prompt = format!("{MEMORY_CONSOLIDATION_PROMPT}\n\nToday: {today}");
Expand Down
86 changes: 86 additions & 0 deletions crates/loopal-agent-server/src/memory_init.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;

use loopal_config::MemoryConfig;
use loopal_kernel::Kernel;
use loopal_memory::{
MemorySubsystem, PROJECT_MEMORY_DIR, PROJECT_MEMORY_EVENTS_DIR, ensure_gitignore,
};
use tokio::sync::{Mutex, OnceCell};

type SubsystemCell = OnceCell<Arc<MemorySubsystem>>;
type Registry = Mutex<HashMap<String, Arc<SubsystemCell>>>;

static MEMORY_SUBSYSTEMS: OnceLock<Registry> = OnceLock::new();

pub async fn init_project_memory(
kernel: &mut Kernel,
cwd: &Path,
session_id: &str,
memory_config: &MemoryConfig,
) {
let memory_dir = cwd.join(PROJECT_MEMORY_DIR);
if !memory_dir.exists() {
tracing::debug!(
path = %memory_dir.display(),
"no project memory directory; skipping memory_recall init"
);
return;
}

let session_dir = match loopal_config::session_dir(session_id) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, session_id, "session_dir resolution failed; memory_recall disabled");
return;
}
};

let events_dir = cwd.join(PROJECT_MEMORY_EVENTS_DIR);

if let Err(e) = ensure_gitignore(cwd) {
tracing::warn!(error = %e, "memory: ensure_gitignore failed; events may end up in git");
}

let registry = MEMORY_SUBSYSTEMS.get_or_init(|| Mutex::new(HashMap::new()));
let cell = {
let mut guard = registry.lock().await;
guard
.entry(session_id.to_string())
.or_insert_with(|| Arc::new(OnceCell::new()))
.clone()
};

let gc_compress = memory_config.gc_compress_after_days;
let gc_archive = memory_config.gc_archive_after_days;
let init_result = cell
.get_or_try_init(|| async {
MemorySubsystem::bootstrap(
memory_dir.clone(),
session_dir.clone(),
events_dir.clone(),
session_id.to_string(),
gc_compress,
gc_archive,
)
.await
.map_err(|e| {
tracing::warn!(error = %e, "memory subsystem bootstrap failed; recall tool disabled");
})
})
.await;

let subsystem = match init_result {
Ok(s) => s.clone(),
Err(()) => return,
};

loopal_agent::tools::register_memory_recall(kernel, subsystem.graph());
tracing::debug!(
memory_dir = %memory_dir.display(),
session_id,
"memory_recall tool registered"
);
}
15 changes: 15 additions & 0 deletions crates/loopal-agent-server/src/params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub struct StartParams {
pub fork_context: Option<serde_json::Value>,
}

#[allow(clippy::too_many_arguments)]
pub async fn build_kernel_from_config(
config: &ResolvedConfig,
production: bool,
Expand All @@ -43,8 +44,10 @@ pub async fn build_kernel_from_config(
>,
cwd: std::path::PathBuf,
agent_name: String,
session_id: String,
) -> anyhow::Result<Arc<Kernel>> {
let mut settings = config.settings.clone();
let cwd_for_memory = cwd.clone();
let secret_client: Option<Arc<dyn loopal_secret_client::SecretClient>> =
hub_connection.map(|conn| {
Arc::new(loopal_secret_client::HubSecretClient::new(
Expand All @@ -54,6 +57,7 @@ pub async fn build_kernel_from_config(
if let Some(client) = secret_client.as_ref() {
expand_provider_secrets(&mut settings, client.as_ref()).await;
}
let settings_memory = settings.memory.clone();
let mut kernel = Kernel::new(settings)?;
if let Some(client) = secret_client {
kernel.set_secret_client(client);
Expand Down Expand Up @@ -81,6 +85,17 @@ pub async fn build_kernel_from_config(
}
}
loopal_agent::tools::register_all(&mut kernel);
// reason: 所有 depth 都注册 memory_recall — sub-agent 的 system prompt 也注入了
// memory-guidance.md "always use memory_recall" 指令,若仅 root 注册会让 sub-agent
// 看到指令但找不到工具。memory.db 落在 ~/.loopal/sessions/{id}/memory.db,是
// per-session 派生数据;不与 .loopal/memory/*.md(user SSOT)混居。
crate::memory_init::init_project_memory(
&mut kernel,
&cwd_for_memory,
&session_id,
&settings_memory,
)
.await;
let kernel = Arc::new(kernel);

if production {
Expand Down
7 changes: 7 additions & 0 deletions crates/loopal-agent-server/src/session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ pub(crate) async fn start_session(
} else {
"sub".to_string()
};
let preset_session_id = start
.resume
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let kernel = if is_production {
crate::params::build_kernel_from_config(
&config,
Expand All @@ -54,6 +58,7 @@ pub(crate) async fn start_session(
Some(connection.clone()),
cwd.clone(),
agent_name,
preset_session_id.clone(),
)
.await?
} else {
Expand All @@ -68,6 +73,7 @@ pub(crate) async fn start_session(
None,
cwd.clone(),
"test".to_string(),
preset_session_id.clone(),
)
.await?
}
Expand Down Expand Up @@ -118,6 +124,7 @@ pub(crate) async fn start_session(
session_dir_override.as_deref(),
hub,
decision_context,
&preset_session_id,
))
.await?;
let agent_params = setup.params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ async fn build_or_panic(
None,
std::path::PathBuf::from("."),
"test".to_string(),
"test-session".to_string(),
)
.await
.expect("build_kernel_from_config")
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-agent-server/tests/suite/hub_harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ pub async fn build_hub_harness_with(
Some(fixture.path()),
&hub,
loopal_runtime::frontend::DecisionContext::with_cwd("/tmp/test"),
"harness-session",
),
)
.await
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ fn build_shared(fixture: &TestFixture) -> Arc<AgentShared> {
async fn trigger_consolidation_skips_when_fresh_lock_exists() {
let fixture = TestFixture::new();
let shared = build_shared(&fixture);
let memory_dir = shared.cwd.join(".loopal/memory");
let memory_dir = shared.cwd.join(loopal_memory::PROJECT_MEMORY_DIR);
std::fs::create_dir_all(&memory_dir).unwrap();

// Pre-create a fresh lock (timestamp = now). trigger_consolidation must
Expand Down Expand Up @@ -85,7 +85,7 @@ async fn trigger_consolidation_skips_when_fresh_lock_exists() {
async fn trigger_consolidation_acquires_lock_when_free() {
let fixture = TestFixture::new();
let shared = build_shared(&fixture);
let memory_dir = shared.cwd.join(".loopal/memory");
let memory_dir = shared.cwd.join(loopal_memory::PROJECT_MEMORY_DIR);

let lock_path = memory_dir.join(".consolidation_lock");
assert!(
Expand Down Expand Up @@ -124,7 +124,7 @@ async fn trigger_consolidation_skips_then_unlocked_caller_succeeds() {
// dir again and acquires freshly.
let fixture = TestFixture::new();
let shared = build_shared(&fixture);
let memory_dir = shared.cwd.join(".loopal/memory");
let memory_dir = shared.cwd.join(loopal_memory::PROJECT_MEMORY_DIR);
let lock_path = memory_dir.join(".consolidation_lock");

trigger_consolidation(&shared, "test-model");
Expand Down
3 changes: 3 additions & 0 deletions crates/loopal-agent-server/tests/suite/prompt_post_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async fn append_runtime_sections_lists_configured_servers_even_when_not_yet_read
None,
std::path::PathBuf::from("."),
"test".to_string(),
"test-session".to_string(),
)
.await
.expect("build");
Expand Down Expand Up @@ -91,6 +92,7 @@ async fn append_runtime_sections_omits_status_when_no_servers_configured() {
None,
std::path::PathBuf::from("."),
"test".to_string(),
"test-session".to_string(),
)
.await
.expect("build");
Expand Down Expand Up @@ -120,6 +122,7 @@ async fn append_runtime_sections_shows_failed_status_for_dead_binary() {
None,
std::path::PathBuf::from("."),
"test".to_string(),
"test-session".to_string(),
)
.await
.expect("build");
Expand Down
2 changes: 1 addition & 1 deletion crates/loopal-agent/src/tools/collaboration/agent_spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ pub(super) async fn action_spawn(
Ok(text) => {
if let Some(ch) = memory_channel {
for suggestion in
loopal_memory::extraction::extract_memory_suggestions(&text)
loopal_memory::agent_output::extract_memory_suggestions(&text)
{
let _ = ch.try_send(suggestion);
}
Expand Down
Loading
Loading