Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
1472 lines
54 KiB
Rust
1472 lines
54 KiB
Rust
//! autoDream gating and execution logic.
|
|
//!
|
|
//! Determines whether a dream consolidation should fire based on
|
|
//! config gates, time elapsed, and session count. Provides prompt
|
|
//! construction, response processing, and lock lifecycle management
|
|
//! for the dream consolidation pass.
|
|
|
|
use std::path::Path;
|
|
use std::time::SystemTime;
|
|
|
|
use super::dream_lock::DreamLock;
|
|
use kigi_config_types::MemoryDreamConfig;
|
|
|
|
/// Result of the dream gate check.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum DreamGate {
|
|
/// All gates passed — dream should fire.
|
|
Open {
|
|
/// Sessions to consolidate (file stems for the prompt).
|
|
sessions: Vec<String>,
|
|
},
|
|
/// Dream is disabled by config.
|
|
Disabled,
|
|
/// Not enough time has passed since last consolidation.
|
|
TooSoon { hours_since: u64 },
|
|
/// Not enough sessions since last consolidation.
|
|
TooFewSessions { count: usize, required: u64 },
|
|
/// Lock acquisition or I/O failed.
|
|
Error(String),
|
|
}
|
|
|
|
/// Check all dream gates (cheapest first).
|
|
///
|
|
/// Gate order:
|
|
/// 1. Config: `dream.enabled` must be true
|
|
/// 2. Time: hours since last consolidation >= `dream.min_hours`
|
|
/// 3. Sessions: session count since last consolidation >= `dream.min_sessions`
|
|
///
|
|
/// Does NOT acquire the lock — callers should acquire after confirming `Open`.
|
|
pub fn check_dream_gates(
|
|
config: &MemoryDreamConfig,
|
|
lock: &DreamLock,
|
|
sessions_dir: &Path,
|
|
current_session_sid8: Option<&str>,
|
|
) -> DreamGate {
|
|
if !config.enabled {
|
|
return DreamGate::Disabled;
|
|
}
|
|
|
|
// Time gate
|
|
let last_at = match lock.last_consolidated_at() {
|
|
Ok(Some(t)) => t,
|
|
Ok(None) => SystemTime::UNIX_EPOCH,
|
|
Err(e) => return DreamGate::Error(e.to_string()),
|
|
};
|
|
let elapsed = SystemTime::now()
|
|
.duration_since(last_at)
|
|
.unwrap_or_default();
|
|
let hours_since = elapsed.as_secs() / 3600;
|
|
if hours_since < config.min_hours {
|
|
return DreamGate::TooSoon { hours_since };
|
|
}
|
|
|
|
// Session gate
|
|
let sessions =
|
|
match super::dream_lock::sessions_since(sessions_dir, last_at, current_session_sid8) {
|
|
Ok(s) => s,
|
|
Err(e) => return DreamGate::Error(e.to_string()),
|
|
};
|
|
if (sessions.len() as u64) < config.min_sessions {
|
|
return DreamGate::TooFewSessions {
|
|
count: sessions.len(),
|
|
required: config.min_sessions,
|
|
};
|
|
}
|
|
|
|
DreamGate::Open { sessions }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dream prompt, response processing, and execution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
use super::text_utils::{has_markdown_headers, is_no_reply};
|
|
|
|
const LOG: &str = "xai_memory";
|
|
|
|
pub const DREAM_SYSTEM_PROMPT: &str = "\
|
|
You are performing a dream \u{2014} a reflective pass over memory files. \
|
|
Synthesize recent session logs into durable, well-organized memories \
|
|
so future sessions orient quickly.
|
|
|
|
You will receive the contents of recent session logs. \
|
|
You may also receive an existing memory document \u{2014} merge it with new sessions \
|
|
rather than discarding prior knowledge. Your job:
|
|
|
|
1. **Merge** related information into coherent topic summaries
|
|
2. **Resolve** contradictions \u{2014} if a recent session disproves an older fact, keep only the current truth
|
|
3. **Convert** relative dates (\"yesterday\", \"last week\") to absolute dates
|
|
4. **Discard** ephemeral details:
|
|
- Greetings, meta-commentary, tool output noise
|
|
- Message counts and tool-usage statistics
|
|
- 'Current state' and 'Next steps' sections
|
|
- User preferences already in global memory (OS, shell, paths)
|
|
- Session metadata (dates, message counts)
|
|
5. **Preserve** decisions, rationale, architecture, preferences, and problem/solution pairs
|
|
|
|
Respond with a single markdown document. Use ## headers to separate topics. \
|
|
Each topic should be self-contained and useful to a future session that knows \
|
|
nothing about the current conversation.
|
|
|
|
If the session logs contain nothing worth persisting, respond with NO_REPLY.";
|
|
|
|
/// Outcome of a dream consolidation attempt.
|
|
#[derive(Debug)]
|
|
pub struct DreamResult {
|
|
pub status: DreamStatus,
|
|
/// Number of gate-eligible sessions (total sessions passed to the dream,
|
|
/// including those beyond the 32K input cap that were not actually read).
|
|
pub sessions_eligible: usize,
|
|
/// File stems that were actually deleted after a successful consolidation.
|
|
/// Empty for non-`Completed` statuses. The caller uses this to remove
|
|
/// the corresponding chunks from the search index — only stems that were
|
|
/// truly removed from disk should have their index entries purged.
|
|
pub cleaned_stems: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum DreamStatus {
|
|
/// Gates didn't pass \u{2014} no dream attempted.
|
|
Skipped(String),
|
|
/// Dream ran and produced consolidated output.
|
|
Completed { chars_written: usize },
|
|
/// Dream ran but model returned nothing useful.
|
|
NothingToConsolidate,
|
|
/// Dream failed.
|
|
Failed(String),
|
|
}
|
|
|
|
const MAX_DREAM_INPUT_CHARS: usize = 32_000;
|
|
|
|
/// Output of [`build_dream_user_message`]: the prompt text and the stems
|
|
/// that were actually read (within the size cap).
|
|
#[derive(Debug)]
|
|
pub struct DreamMessage {
|
|
/// The concatenated session content for the model prompt.
|
|
pub content: String,
|
|
/// File stems that were successfully read and included. Only these
|
|
/// sessions should be cleaned up after a successful consolidation —
|
|
/// stems beyond the [`MAX_DREAM_INPUT_CHARS`] cap are deliberately
|
|
/// excluded so their content is preserved for a future dream pass.
|
|
pub processed_stems: Vec<String>,
|
|
}
|
|
|
|
/// Returns `true` if the content is scaffold boilerplate that should not be
|
|
/// fed to the dream model as existing memory context.
|
|
///
|
|
/// A file is scaffold only if it is short (< 500 bytes trimmed) AND contains
|
|
/// a scaffold marker. Files with substantial content are never scaffold, even
|
|
/// if they contain leftover marker strings from the initial template.
|
|
pub(crate) fn is_scaffold_template(content: &str) -> bool {
|
|
const SCAFFOLD_MAX_LEN: usize = 500;
|
|
const MARKERS: &[&str] = &[
|
|
"Auto-populated by dream consolidation",
|
|
"Add project-specific knowledge here",
|
|
"Add any cross-project preferences here",
|
|
];
|
|
let trimmed = content.trim();
|
|
trimmed.len() < SCAFFOLD_MAX_LEN && MARKERS.iter().any(|marker| trimmed.contains(marker))
|
|
}
|
|
|
|
/// Build the user message for the dream model call from session log contents.
|
|
///
|
|
/// When `existing_memory` is provided and is not scaffold boilerplate,
|
|
/// it is prepended before session logs so the model can merge prior
|
|
/// knowledge with new sessions.
|
|
///
|
|
/// Reads each session file and concatenates their contents with separators.
|
|
/// Stops adding sessions once total size exceeds [`MAX_DREAM_INPUT_CHARS`].
|
|
/// Returns `None` if no session files could be read.
|
|
pub fn build_dream_user_message(
|
|
sessions_dir: &Path,
|
|
stems: &[String],
|
|
existing_memory: Option<&str>,
|
|
) -> Option<DreamMessage> {
|
|
let existing_len = existing_memory.map_or(0, str::len);
|
|
let mut buf = String::with_capacity(existing_len + stems.len().min(10) * 2000);
|
|
|
|
if let Some(mem) = existing_memory {
|
|
let trimmed = mem.trim();
|
|
if !trimmed.is_empty() && !is_scaffold_template(trimmed) {
|
|
buf.push_str("--- Existing Memory (merge with new sessions) ---\n\n");
|
|
let cap = MAX_DREAM_INPUT_CHARS / 2;
|
|
if trimmed.len() <= cap {
|
|
buf.push_str(trimmed);
|
|
} else {
|
|
let mut end = cap;
|
|
while end > 0 && !trimmed.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
buf.push_str(&trimmed[..end]);
|
|
tracing::warn!(
|
|
target: LOG,
|
|
original = trimmed.len(),
|
|
cap,
|
|
"DREAM_BUILD_MESSAGE: existing memory truncated"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut processed_stems = Vec::with_capacity(stems.len());
|
|
for stem in stems {
|
|
let path = sessions_dir.join(format!("{stem}.md"));
|
|
if let Ok(content) = std::fs::read_to_string(&path)
|
|
&& !content.trim().is_empty()
|
|
{
|
|
if !buf.is_empty() {
|
|
buf.push_str("\n\n");
|
|
}
|
|
buf.push_str("--- Session: ");
|
|
buf.push_str(stem);
|
|
buf.push_str(" ---\n\n");
|
|
buf.push_str(&content);
|
|
processed_stems.push(stem.clone());
|
|
|
|
if buf.len() >= MAX_DREAM_INPUT_CHARS {
|
|
tracing::warn!(
|
|
target: LOG,
|
|
sessions_read = processed_stems.len(),
|
|
total = stems.len(),
|
|
chars = buf.len(),
|
|
"DREAM_BUILD_MESSAGE: truncated input at {MAX_DREAM_INPUT_CHARS} chars"
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if processed_stems.is_empty() {
|
|
tracing::debug!(target: LOG, "DREAM_BUILD_MESSAGE: no readable session files");
|
|
return None;
|
|
}
|
|
tracing::info!(
|
|
target: LOG,
|
|
sessions_read = processed_stems.len(),
|
|
total = stems.len(),
|
|
chars = buf.len(),
|
|
"DREAM_BUILD_MESSAGE: built user message"
|
|
);
|
|
Some(DreamMessage {
|
|
content: buf,
|
|
processed_stems,
|
|
})
|
|
}
|
|
|
|
/// Output cap for dream responses. Hardcoded for v1; can be moved to
|
|
/// `MemoryDreamConfig` if operational tuning is needed.
|
|
const MAX_DREAM_CHARS: usize = 16_000;
|
|
|
|
/// Process the dream model's response.
|
|
///
|
|
/// Returns the processed content ready for writing, or `None` if:
|
|
/// - Response is empty/whitespace
|
|
/// - Response matches the `NO_REPLY` pattern
|
|
/// - Response lacks markdown heading structure
|
|
///
|
|
/// Dream output that passes the quality check below contains markdown
|
|
/// headers (`# ` or `## `). Since `write_long_term` writes content
|
|
/// directly without normalization, dream's markdown structure is
|
|
/// preserved as-is.
|
|
///
|
|
/// Truncates content exceeding [`MAX_DREAM_CHARS`].
|
|
pub fn process_dream_response(response: &str) -> Option<String> {
|
|
let trimmed = response.trim();
|
|
if trimmed.is_empty() {
|
|
tracing::info!(target: LOG, "DREAM_RESPONSE: empty");
|
|
return None;
|
|
}
|
|
|
|
if is_no_reply(trimmed) {
|
|
tracing::info!(target: LOG, "DREAM_RESPONSE: NO_REPLY");
|
|
return None;
|
|
}
|
|
|
|
if !has_markdown_headers(trimmed) {
|
|
tracing::info!(
|
|
target: LOG,
|
|
len = trimmed.len(),
|
|
"DREAM_RESPONSE: no markdown headers, rejected"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
let char_count = trimmed.chars().count();
|
|
let (content, accepted_chars) = if char_count > MAX_DREAM_CHARS {
|
|
tracing::warn!(
|
|
target: LOG,
|
|
original = char_count,
|
|
limit = MAX_DREAM_CHARS,
|
|
"DREAM_RESPONSE: truncated"
|
|
);
|
|
(
|
|
trimmed.chars().take(MAX_DREAM_CHARS).collect(),
|
|
MAX_DREAM_CHARS,
|
|
)
|
|
} else {
|
|
(trimmed.to_string(), char_count)
|
|
};
|
|
|
|
tracing::info!(
|
|
target: LOG,
|
|
chars = accepted_chars,
|
|
"DREAM_RESPONSE: accepted"
|
|
);
|
|
Some(content)
|
|
}
|
|
|
|
/// Minimum age (in seconds) a session file must have before cleanup will
|
|
/// delete it. Protects against removing files that a concurrent session
|
|
/// may still be actively appending to.
|
|
const CLEANUP_RECENCY_GUARD_SECS: u64 = 300; // 5 minutes
|
|
|
|
/// Delete session log files whose stems were processed during dream.
|
|
///
|
|
/// Returns the stems that were actually removed from disk. The caller
|
|
/// uses this list to purge the corresponding search-index entries —
|
|
/// stems skipped by the recency guard or that failed to delete are
|
|
/// excluded so their index chunks remain intact.
|
|
///
|
|
/// Logs warnings for individual deletion failures but never propagates
|
|
/// errors \u{2014} the consolidation has already succeeded at this point.
|
|
///
|
|
/// Files modified within the last [`CLEANUP_RECENCY_GUARD_SECS`] are
|
|
/// skipped to avoid deleting logs that a concurrent session is still
|
|
/// actively writing to.
|
|
fn clean_processed_sessions(sessions_dir: &Path, stems: &[String]) -> Vec<String> {
|
|
let mut cleaned = Vec::new();
|
|
let now = SystemTime::now();
|
|
for stem in stems {
|
|
let path = sessions_dir.join(format!("{stem}.md"));
|
|
|
|
// Recency guard: skip files modified very recently — a
|
|
// concurrent session may be actively appending.
|
|
if let Ok(meta) = std::fs::metadata(&path)
|
|
&& let Ok(mtime) = meta.modified()
|
|
&& now.duration_since(mtime).unwrap_or_default().as_secs() < CLEANUP_RECENCY_GUARD_SECS
|
|
{
|
|
tracing::debug!(
|
|
target: LOG,
|
|
path = %path.display(),
|
|
"DREAM_CLEANUP: skipping recently-modified session file"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
match std::fs::remove_file(&path) {
|
|
Ok(()) => cleaned.push(stem.clone()),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
// Already gone \u{2014} not an error, but don't count as removed.
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
target: LOG,
|
|
path = %path.display(),
|
|
error = %e,
|
|
"DREAM_CLEANUP: failed to delete session file"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if !cleaned.is_empty() {
|
|
tracing::info!(
|
|
target: LOG,
|
|
cleaned = cleaned.len(),
|
|
total = stems.len(),
|
|
"DREAM_CLEANUP: removed processed session files"
|
|
);
|
|
}
|
|
cleaned
|
|
}
|
|
|
|
/// Execute the dream lifecycle around a provided model response.
|
|
///
|
|
/// This is the "pure logic" half of dream execution \u{2014} the session actor
|
|
/// handles the actual model call and passes the response here.
|
|
///
|
|
/// Steps:
|
|
/// 1. Acquire lock
|
|
/// 2. Process response
|
|
/// 3. Overwrite workspace MEMORY.md
|
|
/// 4. Clean up processed session files (on success only)
|
|
/// 5. On success: leave lock mtime (records consolidation)
|
|
/// 6. On failure: rollback lock
|
|
pub fn execute_dream(
|
|
lock: &DreamLock,
|
|
storage: &super::storage::MemoryStorage,
|
|
response: &str,
|
|
sessions_eligible: usize,
|
|
stale_lock_secs: u64,
|
|
sessions_dir: &Path,
|
|
processed_stems: &[String],
|
|
) -> DreamResult {
|
|
let prior = match lock.try_acquire(stale_lock_secs) {
|
|
Ok(Some(prior)) => {
|
|
tracing::info!(target: LOG, "DREAM_EXECUTE: lock acquired");
|
|
prior
|
|
}
|
|
Ok(None) => {
|
|
tracing::info!(target: LOG, "DREAM_EXECUTE: lock held by another process, skipping");
|
|
return DreamResult {
|
|
status: DreamStatus::Skipped("lock held by another process".into()),
|
|
sessions_eligible: 0,
|
|
cleaned_stems: Vec::new(),
|
|
};
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(target: LOG, error = %e, "DREAM_EXECUTE: lock acquire failed");
|
|
return DreamResult {
|
|
status: DreamStatus::Failed(format!("lock acquire failed: {e}")),
|
|
sessions_eligible: 0,
|
|
cleaned_stems: Vec::new(),
|
|
};
|
|
}
|
|
};
|
|
|
|
let content = match process_dream_response(response) {
|
|
Some(c) => c,
|
|
None => {
|
|
tracing::info!(target: LOG, sessions_eligible, "DREAM_EXECUTE: nothing to consolidate");
|
|
return DreamResult {
|
|
status: DreamStatus::NothingToConsolidate,
|
|
sessions_eligible,
|
|
cleaned_stems: Vec::new(),
|
|
};
|
|
}
|
|
};
|
|
|
|
let chars_written = content.chars().count();
|
|
if let Err(e) = storage.write_long_term(super::storage::MemoryScope::Workspace, &content) {
|
|
let _ = lock.rollback(prior);
|
|
tracing::warn!(target: LOG, error = %e, "DREAM_EXECUTE: write failed, lock rolled back");
|
|
return DreamResult {
|
|
status: DreamStatus::Failed(format!("failed to write MEMORY.md: {e}")),
|
|
sessions_eligible,
|
|
cleaned_stems: Vec::new(),
|
|
};
|
|
}
|
|
|
|
// Consolidation succeeded — clean up the session files that were read.
|
|
let cleaned_stems = clean_processed_sessions(sessions_dir, processed_stems);
|
|
|
|
tracing::info!(
|
|
target: LOG,
|
|
chars_written,
|
|
sessions_eligible,
|
|
sessions_cleaned = cleaned_stems.len(),
|
|
"DREAM_EXECUTE: completed"
|
|
);
|
|
DreamResult {
|
|
status: DreamStatus::Completed { chars_written },
|
|
sessions_eligible,
|
|
cleaned_stems,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use filetime::FileTime;
|
|
use std::fs;
|
|
use std::time::Duration;
|
|
use tempfile::TempDir;
|
|
|
|
// Mirrors `dream_lock::tests::write_session` — kept local to avoid
|
|
// cross-module test coupling for a trivial 5-line helper.
|
|
fn write_session(dir: &Path, name: &str, age_secs: u64) {
|
|
fs::create_dir_all(dir).unwrap();
|
|
let path = dir.join(format!("{name}.md"));
|
|
fs::write(&path, "test").unwrap();
|
|
let t = SystemTime::now() - Duration::from_secs(age_secs);
|
|
filetime::set_file_mtime(&path, FileTime::from_system_time(t)).unwrap();
|
|
}
|
|
|
|
fn enabled_config() -> MemoryDreamConfig {
|
|
MemoryDreamConfig {
|
|
enabled: true,
|
|
min_hours: 24,
|
|
min_sessions: 5,
|
|
stale_lock_secs: 3600,
|
|
check_interval_secs: None,
|
|
}
|
|
}
|
|
|
|
fn set_consolidation_age(dir: &Path, age_secs: u64) {
|
|
let lock_path = dir.join(".dream-lock");
|
|
fs::write(&lock_path, "").unwrap();
|
|
let t = SystemTime::now() - Duration::from_secs(age_secs);
|
|
filetime::set_file_mtime(&lock_path, FileTime::from_system_time(t)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_config_returns_disabled() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
let config = MemoryDreamConfig {
|
|
enabled: false,
|
|
..enabled_config()
|
|
};
|
|
assert_eq!(
|
|
check_dream_gates(&config, &lock, &sessions, None),
|
|
DreamGate::Disabled
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn too_soon_when_recently_consolidated() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
|
|
lock.record_consolidation().unwrap();
|
|
|
|
let sessions = dir.path().join("sessions");
|
|
let config = enabled_config();
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
match result {
|
|
DreamGate::TooSoon { hours_since } => {
|
|
assert!(hours_since < config.min_hours);
|
|
}
|
|
other => panic!("expected TooSoon, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn too_few_sessions_when_under_threshold() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
set_consolidation_age(dir.path(), 48 * 3600);
|
|
|
|
for i in 0..3 {
|
|
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
|
|
}
|
|
|
|
let config = enabled_config();
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
assert_eq!(
|
|
result,
|
|
DreamGate::TooFewSessions {
|
|
count: 3,
|
|
required: 5,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn all_gates_pass_returns_open_with_sessions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
set_consolidation_age(dir.path(), 48 * 3600);
|
|
|
|
for i in 0..6 {
|
|
write_session(&sessions, &format!("2026-01-{i:02}-proj-aaa{i:05}"), 100);
|
|
}
|
|
|
|
let config = enabled_config();
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
match result {
|
|
DreamGate::Open { sessions } => {
|
|
assert_eq!(sessions.len(), 6);
|
|
assert!(sessions.windows(2).all(|w| w[0] <= w[1]));
|
|
}
|
|
other => panic!("expected Open, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn no_prior_consolidation_treats_as_epoch() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
for i in 0..5 {
|
|
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
|
|
}
|
|
|
|
let config = enabled_config();
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
match result {
|
|
DreamGate::Open { sessions } => assert_eq!(sessions.len(), 5),
|
|
other => panic!("expected Open, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn nonexistent_sessions_dir_returns_too_few() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("nonexistent");
|
|
|
|
let config = enabled_config();
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
assert_eq!(
|
|
result,
|
|
DreamGate::TooFewSessions {
|
|
count: 0,
|
|
required: 5,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn current_session_excluded_from_count() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
set_consolidation_age(dir.path(), 48 * 3600);
|
|
|
|
for i in 0..4 {
|
|
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
|
|
}
|
|
write_session(&sessions, "2026-01-05-proj-current1", 100);
|
|
|
|
let config = enabled_config();
|
|
let result = check_dream_gates(&config, &lock, &sessions, Some("current1"));
|
|
assert_eq!(
|
|
result,
|
|
DreamGate::TooFewSessions {
|
|
count: 4,
|
|
required: 5,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn min_hours_boundary_exact_is_too_soon() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
let config = MemoryDreamConfig {
|
|
min_hours: 24,
|
|
..enabled_config()
|
|
};
|
|
set_consolidation_age(dir.path(), 23 * 3600 + 59 * 60);
|
|
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
match result {
|
|
DreamGate::TooSoon { hours_since } => assert_eq!(hours_since, 23),
|
|
other => panic!("expected TooSoon, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn min_sessions_boundary_exact_passes() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
let config = MemoryDreamConfig {
|
|
min_sessions: 3,
|
|
..enabled_config()
|
|
};
|
|
for i in 0..3 {
|
|
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
|
|
}
|
|
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
match result {
|
|
DreamGate::Open { sessions } => assert_eq!(sessions.len(), 3),
|
|
other => panic!("expected Open, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn min_sessions_boundary_one_below_fails() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
let config = MemoryDreamConfig {
|
|
min_sessions: 3,
|
|
..enabled_config()
|
|
};
|
|
for i in 0..2 {
|
|
write_session(&sessions, &format!("2026-01-0{i}-proj-aaa{i:05}"), 100);
|
|
}
|
|
|
|
let result = check_dream_gates(&config, &lock, &sessions, None);
|
|
assert_eq!(
|
|
result,
|
|
DreamGate::TooFewSessions {
|
|
count: 2,
|
|
required: 3,
|
|
}
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// build_dream_user_message tests
|
|
// -------------------------------------------------------------------
|
|
|
|
fn write_session_content(dir: &Path, name: &str, content: &str) {
|
|
fs::create_dir_all(dir).unwrap();
|
|
fs::write(dir.join(format!("{name}.md")), content).unwrap();
|
|
}
|
|
|
|
/// Write a session file and back-date its mtime so it passes the
|
|
/// recency guard in `clean_processed_sessions`.
|
|
fn write_old_session_content(dir: &Path, name: &str, content: &str) {
|
|
write_session_content(dir, name, content);
|
|
let old = SystemTime::now() - Duration::from_secs(CLEANUP_RECENCY_GUARD_SECS + 60);
|
|
let path = dir.join(format!("{name}.md"));
|
|
filetime::set_file_mtime(&path, FileTime::from_system_time(old)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_with_valid_sessions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "sess-a", "Decision: use Rust");
|
|
write_session_content(&sessions, "sess-b", "Architecture: event-driven");
|
|
|
|
let stems = vec!["sess-a".to_string(), "sess-b".to_string()];
|
|
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
|
|
|
|
assert!(msg.content.contains("--- Session: sess-a ---"));
|
|
assert!(msg.content.contains("Decision: use Rust"));
|
|
assert!(msg.content.contains("--- Session: sess-b ---"));
|
|
assert!(msg.content.contains("Architecture: event-driven"));
|
|
assert_eq!(msg.processed_stems, vec!["sess-a", "sess-b"]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_skips_empty_sessions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "real", "Some content");
|
|
write_session_content(&sessions, "empty", " ");
|
|
|
|
let stems = vec!["real".to_string(), "empty".to_string()];
|
|
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
|
|
|
|
assert!(msg.content.contains("--- Session: real ---"));
|
|
assert!(!msg.content.contains("--- Session: empty ---"));
|
|
assert_eq!(msg.processed_stems, vec!["real"]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_returns_none_for_no_readable_files() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
fs::create_dir_all(&sessions).unwrap();
|
|
|
|
let stems = vec!["missing-a".to_string(), "missing-b".to_string()];
|
|
assert!(build_dream_user_message(&sessions, &stems, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_mixed_readable_and_missing() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "exists", "Important info");
|
|
|
|
let stems = vec!["exists".to_string(), "ghost".to_string()];
|
|
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
|
|
|
|
assert!(msg.content.contains("Important info"));
|
|
assert!(!msg.content.contains("ghost"));
|
|
assert_eq!(msg.processed_stems, vec!["exists"]);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// process_dream_response tests
|
|
// -------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn process_empty_response_returns_none() {
|
|
assert!(process_dream_response("").is_none());
|
|
assert!(process_dream_response(" ").is_none());
|
|
assert!(process_dream_response("\n\n").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn process_no_reply_returns_none() {
|
|
assert!(process_dream_response("NO_REPLY").is_none());
|
|
assert!(process_dream_response("no reply").is_none());
|
|
assert!(process_dream_response("No-Reply").is_none());
|
|
assert!(process_dream_response(" NO_REPLY ").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn process_valid_markdown_returns_content() {
|
|
let input = "## Topic A\n\nSome insight.\n\n## Topic B\n\nAnother insight.";
|
|
let result = process_dream_response(input).unwrap();
|
|
assert_eq!(result, input);
|
|
}
|
|
|
|
#[test]
|
|
fn process_h1_header_accepted() {
|
|
let input = "# Top Level\n\nSome content.";
|
|
assert_eq!(process_dream_response(input).unwrap(), input);
|
|
}
|
|
|
|
#[test]
|
|
fn process_no_headers_returns_none() {
|
|
assert!(process_dream_response("Just plain text without headers.").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn process_truncates_long_content() {
|
|
let header = "## Long\n\n";
|
|
let body = "x".repeat(MAX_DREAM_CHARS + 1000);
|
|
let input = format!("{header}{body}");
|
|
|
|
let result = process_dream_response(&input).unwrap();
|
|
assert_eq!(result.chars().count(), MAX_DREAM_CHARS);
|
|
assert!(result.starts_with("## Long"));
|
|
}
|
|
|
|
#[test]
|
|
fn process_exactly_at_limit_not_truncated() {
|
|
let header = "## H\n\n";
|
|
let remaining = MAX_DREAM_CHARS - header.len();
|
|
let body = "a".repeat(remaining);
|
|
let input = format!("{header}{body}");
|
|
|
|
let result = process_dream_response(&input).unwrap();
|
|
assert_eq!(result.chars().count(), MAX_DREAM_CHARS);
|
|
assert_eq!(result, input);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// execute_dream tests
|
|
// -------------------------------------------------------------------
|
|
|
|
use super::super::storage::MemoryStorage;
|
|
use std::path::PathBuf;
|
|
|
|
fn test_storage(tmp: &TempDir) -> (MemoryStorage, PathBuf) {
|
|
let global = tmp.path().join("memory");
|
|
let workspace = global.join("test-ws");
|
|
let storage = MemoryStorage::with_paths(global, workspace.clone());
|
|
(storage, workspace)
|
|
}
|
|
|
|
/// Helper: path for an empty sessions directory (no cleanup expected).
|
|
fn empty_sessions_dir(tmp: &TempDir) -> PathBuf {
|
|
let p = tmp.path().join("empty-sessions");
|
|
fs::create_dir_all(&p).unwrap();
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn execute_dream_valid_response_writes_memory() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, ws) = test_storage(&dir);
|
|
let sdir = empty_sessions_dir(&dir);
|
|
|
|
let response = "## Decisions\n\nWe chose Rust.\n\n## Architecture\n\nEvent-driven.";
|
|
let result = execute_dream(&lock, &storage, response, 5, 300, &sdir, &[]);
|
|
|
|
assert!(
|
|
matches!(result.status, DreamStatus::Completed { chars_written } if chars_written == response.chars().count())
|
|
);
|
|
assert_eq!(result.sessions_eligible, 5);
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
|
|
let memory = fs::read_to_string(ws.join("MEMORY.md")).unwrap();
|
|
assert!(memory.contains("We chose Rust."));
|
|
assert!(memory.contains("Event-driven."));
|
|
}
|
|
|
|
#[test]
|
|
fn execute_dream_empty_response_nothing_to_consolidate() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _) = test_storage(&dir);
|
|
let sdir = empty_sessions_dir(&dir);
|
|
|
|
let result = execute_dream(&lock, &storage, "", 3, 300, &sdir, &[]);
|
|
assert_eq!(result.status, DreamStatus::NothingToConsolidate);
|
|
assert_eq!(result.sessions_eligible, 3);
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn execute_dream_no_reply_nothing_to_consolidate() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _) = test_storage(&dir);
|
|
let sdir = empty_sessions_dir(&dir);
|
|
|
|
let result = execute_dream(&lock, &storage, "NO_REPLY", 2, 300, &sdir, &[]);
|
|
assert_eq!(result.status, DreamStatus::NothingToConsolidate);
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn execute_dream_lock_held_returns_skipped() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _) = test_storage(&dir);
|
|
let sdir = empty_sessions_dir(&dir);
|
|
|
|
// Acquire the lock (our own PID, non-stale)
|
|
lock.try_acquire(300).unwrap().unwrap();
|
|
|
|
let result = execute_dream(&lock, &storage, "## Topic\n\nContent", 5, 300, &sdir, &[]);
|
|
match &result.status {
|
|
DreamStatus::Skipped(reason) => {
|
|
assert!(reason.contains("lock"), "reason: {reason}");
|
|
}
|
|
other => panic!("expected Skipped, got {other:?}"),
|
|
}
|
|
assert_eq!(result.sessions_eligible, 0);
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn execute_dream_write_failure_rolls_back_lock() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let sdir = empty_sessions_dir(&dir);
|
|
|
|
// Point workspace dir at a path under a regular file, so
|
|
// create_dir_all inside append_to_memory will fail (works
|
|
// even when running as root, unlike chmod-based approaches).
|
|
let blocker = dir.path().join("memory").join("blocker");
|
|
fs::create_dir_all(blocker.parent().unwrap()).unwrap();
|
|
fs::write(&blocker, "I am a file").unwrap();
|
|
let workspace = blocker.join("impossible-subdir");
|
|
|
|
let storage = MemoryStorage::with_paths(dir.path().join("memory"), workspace);
|
|
|
|
let result = execute_dream(&lock, &storage, "## Topic\n\nContent", 3, 300, &sdir, &[]);
|
|
|
|
match &result.status {
|
|
DreamStatus::Failed(reason) => {
|
|
assert!(reason.contains("MEMORY.md"), "reason: {reason}");
|
|
}
|
|
other => panic!("expected Failed, got {other:?}"),
|
|
}
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
|
|
// Verify rollback: lock file should be gone (prior was None).
|
|
let lock_path = dir.path().join(".dream-lock");
|
|
assert!(
|
|
!lock_path.exists(),
|
|
"lock file should be deleted after rollback with no prior"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn execute_dream_overwrites_existing_memory() {
|
|
let dir = TempDir::new().unwrap();
|
|
let (storage, ws) = test_storage(&dir);
|
|
let sdir = empty_sessions_dir(&dir);
|
|
|
|
// Pre-populate MEMORY.md
|
|
fs::create_dir_all(&ws).unwrap();
|
|
fs::write(ws.join("MEMORY.md"), "## Existing\n\nOld content.").unwrap();
|
|
|
|
let lock = DreamLock::new(dir.path());
|
|
let response = "## New Topic\n\nFresh insight.";
|
|
let result = execute_dream(&lock, &storage, response, 2, 300, &sdir, &[]);
|
|
|
|
assert!(matches!(result.status, DreamStatus::Completed { .. }));
|
|
|
|
let memory = fs::read_to_string(ws.join("MEMORY.md")).unwrap();
|
|
// write_long_term overwrites: old content is replaced
|
|
assert!(!memory.contains("Old content."));
|
|
assert_eq!(memory.trim(), response);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// DREAM_SYSTEM_PROMPT sanity checks
|
|
// -------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn dream_system_prompt_has_required_content() {
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("dream"));
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("NO_REPLY"));
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("## headers"));
|
|
// Core instruction verbs that define dream behavior
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("Merge"));
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("Resolve"));
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("Discard"));
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("Preserve"));
|
|
assert!(DREAM_SYSTEM_PROMPT.contains("Convert"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_respects_input_size_cap() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
let big_content = "x".repeat(MAX_DREAM_INPUT_CHARS);
|
|
write_session_content(&sessions, "big", &big_content);
|
|
write_session_content(&sessions, "small", "should not appear");
|
|
|
|
let stems = vec!["big".to_string(), "small".to_string()];
|
|
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
|
|
|
|
assert!(msg.content.len() >= MAX_DREAM_INPUT_CHARS);
|
|
assert!(
|
|
!msg.content.contains("should not appear"),
|
|
"second session should be skipped after cap"
|
|
);
|
|
// Only the first stem should be in processed_stems — the second
|
|
// was beyond the 32K cap and must NOT be cleaned up.
|
|
assert_eq!(msg.processed_stems, vec!["big"]);
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_memory_content_preserves_headers() {
|
|
let input = "## Topic A\n\nContent A.\n\n## Topic B\n\nContent B.";
|
|
let normalized = super::super::storage::normalize_memory_content(input);
|
|
assert_eq!(normalized, input);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// Session cleanup tests
|
|
// -------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn cleanup_deletes_processed_sessions_on_completed() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _ws) = test_storage(&dir);
|
|
|
|
// Create session files that will be "processed" (old mtime to
|
|
// pass the recency guard).
|
|
let sessions = dir.path().join("sessions");
|
|
write_old_session_content(&sessions, "sess-a", "Content A");
|
|
write_old_session_content(&sessions, "sess-b", "Content B");
|
|
|
|
let processed = vec!["sess-a".to_string(), "sess-b".to_string()];
|
|
let response = "## Consolidated\n\nMerged content.";
|
|
let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);
|
|
|
|
assert!(matches!(result.status, DreamStatus::Completed { .. }));
|
|
assert_eq!(result.cleaned_stems.len(), 2);
|
|
|
|
// Both files should be gone
|
|
assert!(!sessions.join("sess-a.md").exists());
|
|
assert!(!sessions.join("sess-b.md").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_preserves_unprocessed_sessions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _ws) = test_storage(&dir);
|
|
|
|
// Create session files — only some will be "processed"
|
|
let sessions = dir.path().join("sessions");
|
|
write_old_session_content(&sessions, "processed", "Content");
|
|
write_old_session_content(&sessions, "unprocessed", "Kept content");
|
|
|
|
// Only mark "processed" as having been read
|
|
let processed = vec!["processed".to_string()];
|
|
let response = "## Consolidated\n\nMerged.";
|
|
let result = execute_dream(&lock, &storage, response, 1, 300, &sessions, &processed);
|
|
|
|
assert!(matches!(result.status, DreamStatus::Completed { .. }));
|
|
assert_eq!(result.cleaned_stems.len(), 1);
|
|
|
|
// Processed file should be gone, unprocessed should remain
|
|
assert!(!sessions.join("processed.md").exists());
|
|
assert!(sessions.join("unprocessed.md").exists());
|
|
assert_eq!(
|
|
fs::read_to_string(sessions.join("unprocessed.md")).unwrap(),
|
|
"Kept content"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_failure_does_not_affect_completed_status() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _ws) = test_storage(&dir);
|
|
|
|
let sessions = dir.path().join("sessions");
|
|
fs::create_dir_all(&sessions).unwrap();
|
|
|
|
// Create a directory where a .md file is expected — remove_file
|
|
// on a directory fails even as root, giving us a guaranteed
|
|
// cleanup failure without relying on chmod. Back-date the dir's
|
|
// mtime so it passes the recency guard.
|
|
let bad_path = sessions.join("bad-stem.md");
|
|
fs::create_dir_all(&bad_path).unwrap();
|
|
let old = SystemTime::now() - Duration::from_secs(CLEANUP_RECENCY_GUARD_SECS + 60);
|
|
filetime::set_file_mtime(&bad_path, FileTime::from_system_time(old)).unwrap();
|
|
|
|
// Also create a normal file that CAN be cleaned
|
|
write_old_session_content(&sessions, "good-stem", "Content");
|
|
|
|
let processed = vec!["bad-stem".to_string(), "good-stem".to_string()];
|
|
let response = "## Consolidated\n\nMerged.";
|
|
let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);
|
|
|
|
// Status must still be Completed despite the cleanup failure
|
|
assert!(matches!(result.status, DreamStatus::Completed { .. }));
|
|
// Only the good-stem file was cleaned; bad-stem failed
|
|
assert_eq!(result.cleaned_stems.len(), 1);
|
|
// The directory-as-file still exists (cleanup failure)
|
|
assert!(sessions.join("bad-stem.md").exists());
|
|
// The normal file was successfully removed
|
|
assert!(!sessions.join("good-stem.md").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_skips_recently_modified_files() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _ws) = test_storage(&dir);
|
|
|
|
let sessions = dir.path().join("sessions");
|
|
// This file has the current mtime (within recency guard window)
|
|
write_session_content(&sessions, "recent", "Still being written");
|
|
// This file is old enough to be cleaned
|
|
write_old_session_content(&sessions, "old", "Done");
|
|
|
|
let processed = vec!["recent".to_string(), "old".to_string()];
|
|
let response = "## Consolidated\n\nMerged.";
|
|
let result = execute_dream(&lock, &storage, response, 2, 300, &sessions, &processed);
|
|
|
|
assert!(matches!(result.status, DreamStatus::Completed { .. }));
|
|
// Only the old file should be cleaned; recent should be skipped.
|
|
// Crucially, cleaned_stems must contain ONLY "old" — if the
|
|
// caller used this list for index cleanup, "recent" must NOT
|
|
// have its index chunks removed since its file is still on disk.
|
|
assert_eq!(result.cleaned_stems, vec!["old"]);
|
|
assert!(
|
|
sessions.join("recent.md").exists(),
|
|
"recently-modified file must be preserved"
|
|
);
|
|
assert!(!sessions.join("old.md").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn no_cleanup_on_nothing_to_consolidate() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _ws) = test_storage(&dir);
|
|
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "kept", "Content");
|
|
|
|
let processed = vec!["kept".to_string()];
|
|
// NO_REPLY → NothingToConsolidate — should NOT clean up
|
|
let result = execute_dream(&lock, &storage, "NO_REPLY", 1, 300, &sessions, &processed);
|
|
|
|
assert_eq!(result.status, DreamStatus::NothingToConsolidate);
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
assert!(
|
|
sessions.join("kept.md").exists(),
|
|
"session file must be preserved on NothingToConsolidate"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_cleanup_on_failed_status() {
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
|
|
// Force a write failure with the blocker trick
|
|
let blocker = dir.path().join("memory").join("blocker");
|
|
fs::create_dir_all(blocker.parent().unwrap()).unwrap();
|
|
fs::write(&blocker, "I am a file").unwrap();
|
|
let workspace = blocker.join("impossible-subdir");
|
|
let storage = MemoryStorage::with_paths(dir.path().join("memory"), workspace);
|
|
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "kept", "Content");
|
|
|
|
let processed = vec!["kept".to_string()];
|
|
let result = execute_dream(
|
|
&lock,
|
|
&storage,
|
|
"## Topic\n\nContent",
|
|
1,
|
|
300,
|
|
&sessions,
|
|
&processed,
|
|
);
|
|
|
|
assert!(matches!(result.status, DreamStatus::Failed(_)));
|
|
assert_eq!(result.cleaned_stems.len(), 0);
|
|
assert!(
|
|
sessions.join("kept.md").exists(),
|
|
"session file must be preserved on Failed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_processed_stems_excludes_capped_sessions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
// First session fills the buffer past the cap
|
|
let big_content = "x".repeat(MAX_DREAM_INPUT_CHARS);
|
|
write_session_content(&sessions, "first", &big_content);
|
|
// Second session should NOT be processed (over cap)
|
|
write_session_content(&sessions, "second", "small content");
|
|
// Third session should NOT be processed either
|
|
write_session_content(&sessions, "third", "more content");
|
|
|
|
let stems = vec![
|
|
"first".to_string(),
|
|
"second".to_string(),
|
|
"third".to_string(),
|
|
];
|
|
let msg = build_dream_user_message(&sessions, &stems, None).unwrap();
|
|
|
|
assert_eq!(
|
|
msg.processed_stems,
|
|
vec!["first"],
|
|
"only the first session should be in processed_stems"
|
|
);
|
|
assert_eq!(msg.processed_stems.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn end_to_end_cap_boundary_cleanup() {
|
|
// Integration test: build_dream_user_message hits the 32K cap
|
|
// partway through the stems list, then execute_dream cleans up
|
|
// only the processed files while preserving unprocessed ones.
|
|
let dir = TempDir::new().unwrap();
|
|
let lock = DreamLock::new(dir.path());
|
|
let (storage, _ws) = test_storage(&dir);
|
|
|
|
let sessions = dir.path().join("sessions");
|
|
|
|
// Create 5 session files. The first 2 will fill past the cap;
|
|
// sessions 3-5 should survive cleanup.
|
|
let half_cap = MAX_DREAM_INPUT_CHARS / 2 + 500; // slightly over half
|
|
write_old_session_content(&sessions, "aaa-first", &"a".repeat(half_cap));
|
|
write_old_session_content(&sessions, "bbb-second", &"b".repeat(half_cap));
|
|
write_old_session_content(&sessions, "ccc-third", "small content 3");
|
|
write_old_session_content(&sessions, "ddd-fourth", "small content 4");
|
|
write_old_session_content(&sessions, "eee-fifth", "small content 5");
|
|
|
|
let all_stems: Vec<String> = vec![
|
|
"aaa-first",
|
|
"bbb-second",
|
|
"ccc-third",
|
|
"ddd-fourth",
|
|
"eee-fifth",
|
|
]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
|
|
// Phase 1: build_dream_user_message should cap after the first 2
|
|
let dream_msg = build_dream_user_message(&sessions, &all_stems, None).unwrap();
|
|
assert_eq!(
|
|
dream_msg.processed_stems.len(),
|
|
2,
|
|
"only first 2 sessions should fit within 32K cap"
|
|
);
|
|
assert_eq!(dream_msg.processed_stems[0], "aaa-first");
|
|
assert_eq!(dream_msg.processed_stems[1], "bbb-second");
|
|
|
|
// Phase 2: execute_dream with a valid response should clean up
|
|
// only the processed stems.
|
|
let response = "## Consolidated\n\nMerged from 2 sessions.";
|
|
let result = execute_dream(
|
|
&lock,
|
|
&storage,
|
|
response,
|
|
all_stems.len(),
|
|
300,
|
|
&sessions,
|
|
&dream_msg.processed_stems,
|
|
);
|
|
|
|
assert!(matches!(result.status, DreamStatus::Completed { .. }));
|
|
assert_eq!(result.cleaned_stems.len(), 2);
|
|
|
|
// Processed files deleted
|
|
assert!(!sessions.join("aaa-first.md").exists());
|
|
assert!(!sessions.join("bbb-second.md").exists());
|
|
|
|
// Unprocessed files preserved
|
|
assert!(sessions.join("ccc-third.md").exists());
|
|
assert!(sessions.join("ddd-fourth.md").exists());
|
|
assert!(sessions.join("eee-fifth.md").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn clean_processed_sessions_handles_already_missing_files() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
fs::create_dir_all(&sessions).unwrap();
|
|
|
|
// Only create one of two stems (old mtime to pass recency guard)
|
|
write_old_session_content(&sessions, "exists", "Content");
|
|
|
|
// Pass both — "missing" doesn't exist, should not error or count
|
|
let cleaned =
|
|
clean_processed_sessions(&sessions, &["exists".to_string(), "missing".to_string()]);
|
|
|
|
assert_eq!(
|
|
cleaned.len(),
|
|
1,
|
|
"only the existing file should count as cleaned"
|
|
);
|
|
assert_eq!(cleaned[0], "exists");
|
|
assert!(!sessions.join("exists.md").exists());
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// is_scaffold_template tests
|
|
// -------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn scaffold_detects_old_workspace_template() {
|
|
assert!(is_scaffold_template(
|
|
"# Project Memory\n\n## Project Context\n\n<!-- Add project-specific knowledge here -->\n",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_detects_new_workspace_template() {
|
|
assert!(is_scaffold_template(
|
|
"# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_detects_global_template() {
|
|
assert!(is_scaffold_template(
|
|
"# Global Memory\n\n## Preferences\n\n<!-- Add any cross-project preferences here -->\n",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_rejects_real_content() {
|
|
assert!(!is_scaffold_template(
|
|
"## Decisions\n\nWe chose Rust.\n\n## Architecture\n\nEvent-driven.",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_boundary_499_bytes_is_scaffold() {
|
|
let marker = "Add project-specific knowledge here";
|
|
let pad_len = 499 - marker.len();
|
|
let content = format!("{}{}", "x".repeat(pad_len), marker);
|
|
assert_eq!(content.trim().len(), 499);
|
|
assert!(
|
|
is_scaffold_template(&content),
|
|
"499-byte content with marker must be classified as scaffold"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_boundary_500_bytes_is_not_scaffold() {
|
|
let marker = "Add project-specific knowledge here";
|
|
let pad_len = 500 - marker.len();
|
|
let content = format!("{}{}", "x".repeat(pad_len), marker);
|
|
assert_eq!(content.trim().len(), 500);
|
|
assert!(
|
|
!is_scaffold_template(&content),
|
|
"500-byte content with marker must NOT be classified as scaffold"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_rejects_large_content_with_leftover_marker() {
|
|
let real_content = "x".repeat(600);
|
|
let content_with_marker = format!(
|
|
"# Project Memory\n\n<!-- Add project-specific knowledge here -->\n\n## Decisions\n\n{}",
|
|
real_content
|
|
);
|
|
assert!(
|
|
!is_scaffold_template(&content_with_marker),
|
|
"large file with leftover scaffold marker must NOT be classified as scaffold"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_rejects_large_workspace_template_with_real_content() {
|
|
let real_content = "a ".repeat(300);
|
|
let content = format!(
|
|
"# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n\n## Architecture\n\n{}",
|
|
real_content
|
|
);
|
|
assert!(
|
|
!is_scaffold_template(&content),
|
|
"workspace template with substantial appended content must not be scaffold"
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------
|
|
// build_dream_user_message with existing memory tests
|
|
// -------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn build_message_prepends_existing_memory() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "sess-a", "Decision: use Rust");
|
|
|
|
let stems = vec!["sess-a".to_string()];
|
|
let existing = "## Prior\n\nOld knowledge.";
|
|
let msg = build_dream_user_message(&sessions, &stems, Some(existing)).unwrap();
|
|
|
|
assert!(msg.content.starts_with("--- Existing Memory"));
|
|
assert!(msg.content.contains("Old knowledge."));
|
|
assert!(msg.content.contains("Decision: use Rust"));
|
|
let mem_pos = msg.content.find("Old knowledge.").unwrap();
|
|
let sess_pos = msg.content.find("Decision: use Rust").unwrap();
|
|
assert!(mem_pos < sess_pos, "existing memory must precede sessions");
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_skips_scaffold_existing_memory() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "sess-a", "Decision: use Rust");
|
|
|
|
let stems = vec!["sess-a".to_string()];
|
|
let scaffold = "# Project Memory\n\n<!-- Add project-specific knowledge here -->\n";
|
|
let msg = build_dream_user_message(&sessions, &stems, Some(scaffold)).unwrap();
|
|
|
|
assert!(!msg.content.contains("Existing Memory"));
|
|
assert!(msg.content.contains("Decision: use Rust"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_includes_large_memory_with_scaffold_marker() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
write_session_content(&sessions, "sess-a", "Decision: use Rust");
|
|
|
|
let stems = vec!["sess-a".to_string()];
|
|
let real_content = "x".repeat(600);
|
|
let memory = format!(
|
|
"# Project Memory\n\n<!-- Add project-specific knowledge here -->\n\n## Architecture\n\n{}",
|
|
real_content
|
|
);
|
|
let msg = build_dream_user_message(&sessions, &stems, Some(&memory)).unwrap();
|
|
|
|
assert!(
|
|
msg.content.contains("Existing Memory"),
|
|
"large memory with leftover scaffold marker must be included"
|
|
);
|
|
assert!(msg.content.contains(&real_content));
|
|
}
|
|
|
|
#[test]
|
|
fn build_message_returns_none_with_existing_memory_but_no_sessions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let sessions = dir.path().join("sessions");
|
|
fs::create_dir_all(&sessions).unwrap();
|
|
|
|
let stems = vec!["missing".to_string()];
|
|
let existing = "## Prior\n\nOld knowledge.";
|
|
assert!(
|
|
build_dream_user_message(&sessions, &stems, Some(existing)).is_none(),
|
|
"should return None when no sessions are readable, even with existing memory"
|
|
);
|
|
}
|
|
}
|