feat: session summary, context packages, cockpit tour + graph, proxy Claude fix

Session Summary (ctx_summary):
- New session_summary module (generate, recall, record, store)
- ctx_summary MCP tool for on-demand summaries
- CLI: `lean-ctx summary` command

Context Packages (ctx_package):
- bundle.rs for session export/import packaging
- ctx_package MCP tool + registered handler

Cockpit Dashboard:
- cockpit-tour.js: interactive onboarding tour
- cockpit-graph.js: dependency graph visualization
- Style + HTML wiring for new components

Proxy (Claude Pro/Max fix):
- proxy enable no longer breaks OAuth-based Claude subscriptions
- Detects missing API key and skips Claude redirect
- doctor check for stale proxy redirect

Housekeeping:
- Update rules_inject target count for new tools (23 → 25)
- Config schema + sections for summary feature
- MCP tool docs regenerated

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yves Gugger
2026-06-09 10:15:16 +02:00
parent 71cc0ffcac
commit 9d32b5b4f5
35 changed files with 1580 additions and 11 deletions
+7 -5
View File
@@ -1,4 +1,4 @@
# Appendix — MCP Tool Map (all 73 tools)
# Appendix — MCP Tool Map (all 75 tools)
Every tool lean-ctx registers via `rust/src/server/registry.rs`. Your AI editor
calls these instead of its native file/search tools. The **Profile** column
@@ -14,14 +14,14 @@ shows the smallest tool profile that exposes the tool (`M` minimal, `S` standard
|---------|-------|--------------|
| **minimal** | 6 | Lowest context overhead; the absolute essentials |
| **standard** | 22 | Balanced default for most coding workflows |
| **power** | 73 | Everything (default for existing installs) |
| **power** | 75 | Everything (default for existing installs) |
- **minimal (6):** `ctx_read`, `ctx_shell`, `shell`, `ctx_search`, `ctx_tree`, `ctx_session`
- **standard (+16):** + `ctx_semantic_search`, `ctx_knowledge`, `ctx_overview`,
`ctx_repomap`, `ctx_callgraph`, `ctx_impact`, `ctx_compress`, `ctx_multi_read`,
`ctx_delta`, `ctx_edit`, `ctx_agent`, `ctx_architecture`, `ctx_pack`,
`ctx_routes`, `ctx_refactor`, `ctx_url_read`
- **power (+50):** all remaining tools.
- **power (+52):** all remaining tools.
---
@@ -111,6 +111,8 @@ shows the smallest tool profile that exposes the tool (`M` minimal, `S` standard
| `ctx_plugins` | Plugin management | list\|enable\|disable\|info\|hooks | P |
| `ctx_rules` | Cross-agent rules governance (ContextOps) | sync\|diff\|lint\|status\|init | P |
| `ctx_skillify` | Codify recurring session-diary + knowledge patterns into versioned, git-committable `.cursor/rules/skillify-*.mdc` (precision-biased, idempotent) | mine\|list\|status\|promote; `slug` | P |
| `ctx_summary` | Record + recall AI session summaries (semantic when warm, else lexical); auto-captured on the checkpoint cadence | recall\|record\|list; `query`, `top_k` | P |
| `ctx_package` | Save/resume portable context packages (session + summaries + knowledge bundle) for agent handoffs or session persistence | save\|resume\|list\|info; `path`, `description` | P |
| `ctx_overview` | Task-relevant project map — ideal at session start | `task`, `path` | S |
| `ctx_preload` | Proactively load task-relevant files; compact L-curve summary | `task`*, `path` | P |
| `ctx_prefetch` | Predictive prefetch for blast-radius files | `root`, `task`, `changed_files[]`, `budget_tokens` | P |
@@ -137,9 +139,9 @@ shows the smallest tool profile that exposes the tool (`M` minimal, `S` standard
## Notes
1. `power` enables all 73 tools; `ToolProfile::is_tool_enabled()` returns `true`
1. `power` enables all 75 tools; `ToolProfile::is_tool_enabled()` returns `true`
for everything under power.
2. `ctx_load_tools` controls *dynamic* categories (`arch`, `debug`, `memory`,
`metrics`, `session`) independently of the static profile filter.
3. Lazy clients use `ctx_call` + `ctx_discover_tools` + `ctx_load_tools` to reach
tools not in their active profile without listing all 73 upfront.
tools not in their active profile without listing all 75 upfront.
+8
View File
@@ -320,6 +320,14 @@ Skillify miner: distill recurring session diary + knowledge patterns into rules
- `min_recurrence` (u32, default `2`) — Minimum reinforcements (confirmations / repeated mentions) before a sub-threshold-confidence pattern is codified.
- `scope` (enum: project | global, default `project`) — Where generated rules are written: project (<repo>/.cursor/rules, git-committable) or global (~/.cursor/rules).
## `[summaries]`
AI session summaries: periodic, semantically-recallable session digests
- `enabled` (bool, default `true`) — Record periodic, semantically-recallable AI session summaries (what was done, files, decisions).
- `every_n_turns` (u32, default `25`) — Tool calls between automatic session summaries (gated by the auto-checkpoint cadence).
- `max_kept` (u32, default `100`) — Maximum session summaries kept per project (oldest pruned first).
## `[updates]`
Automatic update configuration
+13 -1
View File
@@ -4,7 +4,7 @@
Source of truth: `rust/src/server/registry.rs` and the tool definitions it registers.
lean-ctx registers **73 MCP tools** (granular profile). Each entry below lists the tool name, what it does, and its parameters (`*` marks required).
lean-ctx registers **75 MCP tools** (granular profile). Each entry below lists the tool name, what it does, and its parameters (`*` marks required).
## `ctx_agent`
@@ -272,6 +272,12 @@ Context Package Manager. Actions: pr (PR context), create (build package from pr
Parameters: `action`*, `apply`, `author`, `base`, `depth`, `description`, `diff`, `enable`, `file`, `format`, `layers`, `level`, `name`, `project_root`, `scope`, `tags`, `version`
## `ctx_package`
Save or resume portable context packages — self-contained JSON bundles with session state, summaries, and knowledge. Use to hand off context between agents, persist session snapshots for later, or onboard a new agent into a previous session's context. Actions: save (export current session), resume (import from a package file), list (show saved packages), info (inspect a package without importing).
Parameters: `action`*, `description`, `path`
## `ctx_plan`
Context planning (CFT). Computes optimal context plan with Phi scoring, budget allocation, and policy-driven view selection.
@@ -413,6 +419,12 @@ Code smell detection. Actions: scan|summary|rules|file.
Parameters: `action`, `format`, `path`, `root`, `rule`
## `ctx_summary`
Record and recall AI session summaries — compact, semantically-recallable digests of what was done (task, files, decisions, next steps). Actions: recall (find past summaries by query; semantic when embeddings are warm, else lexical), record (snapshot the current session now), list (recent summaries). Summaries are also captured automatically on the checkpoint cadence.
Parameters: `action`, `query`, `top_k`
## `ctx_symbol`
Read a specific symbol (function, struct, class) by name. Returns only the symbol code block instead of the entire file. 90-97% fewer tokens than full file read.
+4
View File
@@ -403,6 +403,10 @@ pub fn run() {
super::cmd_skillify(&rest);
return;
}
"summary" => {
super::cmd_summary(&rest);
return;
}
"overview" => {
super::cmd_overview(&rest);
return;
+2
View File
@@ -29,6 +29,7 @@ mod semantic_search_cmd;
mod session_cmd;
mod shell_init;
mod skillify_cmd;
mod summary_cmd;
mod tee_cmd;
mod theme_cmd;
mod verify_cache_cmd;
@@ -61,6 +62,7 @@ pub(crate) use semantic_search_cmd::cmd_semantic_search;
pub use session_cmd::*;
pub use shell_init::*;
pub(crate) use skillify_cmd::cmd_skillify;
pub(crate) use summary_cmd::cmd_summary;
pub use tee_cmd::*;
pub use theme_cmd::*;
pub(crate) use verify_cmd::cmd_verify;
+65
View File
@@ -0,0 +1,65 @@
//! `lean-ctx summary` — record + recall AI session summaries (#292).
use crate::core::session::SessionState;
use crate::tools::ctx_summary;
pub(crate) fn cmd_summary(args: &[String]) {
let project_root = super::common::detect_project_root(args);
let positionals: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
let first = positionals.first().map_or("recall", |s| s.as_str());
if matches!(first, "help" | "--help" | "-h") {
print_help();
return;
}
// Known sub-actions; anything else is treated as a recall query so that
// `lean-ctx summary what did I change?` just works.
let known = matches!(first, "recall" | "record" | "list");
let (action, query_parts): (&str, &[&String]) = if known {
(first, &positionals[1..])
} else {
("recall", &positionals[..])
};
let query = query_parts
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" ");
let query_opt = (!query.trim().is_empty()).then_some(query.as_str());
let top_k = parse_top_k(args).unwrap_or(5);
// `record` snapshots the persisted session for this project.
let session = (action == "record")
.then(|| SessionState::load_latest_for_project_root(&project_root))
.flatten();
let out = ctx_summary::handle(&project_root, session.as_ref(), action, query_opt, top_k);
println!("{out}");
}
fn parse_top_k(args: &[String]) -> Option<usize> {
let pos = args.iter().position(|a| a == "--top-k" || a == "-k")?;
args.get(pos + 1)?.parse().ok()
}
fn print_help() {
eprintln!(
"lean-ctx summary — record + recall AI session summaries\n\
\n\
USAGE:\n \
lean-ctx summary [action] [query] [--top-k N]\n\
\n\
ACTIONS:\n \
recall <query> Find past summaries (semantic when warm, else lexical)\n \
record Snapshot the current session now\n \
list Show recent summaries\n \
help Show this help\n\
\n\
Bare text is treated as a recall query:\n \
lean-ctx summary what did I change in the graph index?"
);
}
+4
View File
@@ -234,6 +234,9 @@ pub struct Config {
/// Skillify miner settings (#290): codify recurring patterns into rules.
#[serde(default)]
pub skillify: SkillifyConfig,
/// AI session-summary settings (#292): periodic, semantically-recallable summaries.
#[serde(default)]
pub summaries: SummariesConfig,
/// Optional LLM enhancement (query expansion, contradiction explanation).
#[serde(default)]
pub llm: crate::core::llm_enhance::LlmConfig,
@@ -452,6 +455,7 @@ impl Default for Config {
search: crate::core::hybrid_search::HybridConfig::default(),
graph: GraphConfig::default(),
skillify: SkillifyConfig::default(),
summaries: SummariesConfig::default(),
llm: crate::core::llm_enhance::LlmConfig::default(),
embedding: EmbeddingConfig::default(),
shell_hook_disabled: false,
@@ -720,6 +720,40 @@ pub(super) fn build(sections: &mut BTreeMap<String, SectionSchema>) {
},
);
let mut summaries = BTreeMap::new();
summaries.insert(
"enabled".into(),
key(
"bool",
serde_json::json!(cfg.summaries.enabled),
"Record periodic, semantically-recallable AI session summaries (what was done, files, decisions).",
),
);
summaries.insert(
"every_n_turns".into(),
key(
"u32",
serde_json::json!(cfg.summaries.every_n_turns),
"Tool calls between automatic session summaries (gated by the auto-checkpoint cadence).",
),
);
summaries.insert(
"max_kept".into(),
key(
"u32",
serde_json::json!(cfg.summaries.max_kept),
"Maximum session summaries kept per project (oldest pruned first).",
),
);
sections.insert(
"summaries".into(),
SectionSchema {
description: "AI session summaries: periodic, semantically-recallable session digests"
.into(),
keys: summaries,
},
);
let mut embedding = BTreeMap::new();
embedding.insert(
"model".into(),
+27
View File
@@ -505,6 +505,33 @@ impl Default for SkillifyConfig {
}
}
/// AI session summaries (#292): periodically distil the working session into a
/// compact, *semantically recallable* summary so a future session can answer
/// "what did I do last time on X?". Deterministic and local-first — recall uses
/// embeddings when the `embeddings` feature is on, else a lexical fallback.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SummariesConfig {
/// Record periodic session summaries. On by default; recording is cheap and
/// happens at most once per `every_n_turns` tool calls.
pub enabled: bool,
/// Tool calls between automatic summaries. The auto-checkpoint cadence still
/// gates the check, so the effective minimum is the checkpoint interval.
pub every_n_turns: u32,
/// Maximum summaries kept per project (oldest pruned first).
pub max_kept: u32,
}
impl Default for SummariesConfig {
fn default() -> Self {
Self {
enabled: true,
every_n_turns: 25,
max_kept: 100,
}
}
}
/// A user-defined command alias mapping for shell compression patterns.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliasEntry {
+81
View File
@@ -0,0 +1,81 @@
//! The portable context-package bundle format (#293).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::core::session::{Decision, FileTouched, Finding, TaskInfo, TestSnapshot};
use crate::core::session_summary::SummaryRecord;
pub const FORMAT_VERSION: u32 = 1;
/// A portable, self-contained context package.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextPackage {
pub format_version: u32,
pub created_at: DateTime<Utc>,
pub project_root: String,
pub session_id: String,
pub metadata: PackageMetadata,
pub session: SessionSlice,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub summaries: Vec<SummaryRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub knowledge: Vec<KnowledgeFact>,
}
/// Human-readable metadata about the package.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageMetadata {
pub agent_id: Option<String>,
pub description: Option<String>,
pub tool_calls: u32,
pub tokens_saved: u64,
}
/// The essential slice of session state to restore.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionSlice {
pub task: Option<TaskInfo>,
pub findings: Vec<Finding>,
pub decisions: Vec<Decision>,
pub files: Vec<FileTouched>,
pub next_steps: Vec<String>,
pub test_results: Option<TestSnapshot>,
}
/// A knowledge fact (compact, portable representation).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeFact {
pub category: String,
pub key: String,
pub value: String,
pub confidence: f32,
pub created_at: DateTime<Utc>,
}
impl ContextPackage {
pub fn is_compatible(&self) -> bool {
self.format_version <= FORMAT_VERSION
}
pub fn summary_line(&self) -> String {
let desc = self
.metadata
.description
.as_deref()
.or(self.session.task.as_ref().map(|t| t.description.as_str()))
.unwrap_or("(no description)");
format!(
"[{}] {}{} files, {} decisions, {} summaries, {} facts",
self.session_id
.split('-')
.next()
.unwrap_or(&self.session_id),
desc,
self.session.files.len(),
self.session.decisions.len(),
self.summaries.len(),
self.knowledge.len()
)
}
}
+6
View File
@@ -1,8 +1,11 @@
pub mod auto_load;
pub mod builder;
pub mod bundle;
pub mod composition;
pub mod content;
pub mod export;
pub mod graph_model;
pub mod import;
pub mod loader;
pub mod manifest;
pub mod registry;
@@ -10,9 +13,12 @@ pub mod signing;
pub use auto_load::auto_load_packages;
pub use builder::PackageBuilder;
pub use bundle::ContextPackage;
pub use composition::{merge_graphs, MergeReport};
pub use content::PackageContent;
pub use export::save_package;
pub use graph_model::{ContextEdge, ContextGraph, ContextNode};
pub use import::resume_package;
pub use loader::{load_package, LoadReport};
pub use manifest::{PackageLayer, PackageManifest};
pub use registry::LocalRegistry;
+1
View File
@@ -203,6 +203,7 @@ pub mod handoff_ledger;
pub mod handoff_transfer_bundle;
pub mod session;
pub mod session_diff;
pub mod session_summary;
pub mod skillify;
/// Convenience re-export: all session-related modules.
+154
View File
@@ -0,0 +1,154 @@
//! Build a deterministic session-summary candidate from `SessionState` (#292).
//!
//! No LLM, no randomness: the same session always yields the same summary, which
//! is what makes the benchmark/recall reproducible.
use crate::core::session::SessionState;
use super::record::SummaryCandidate;
const MAX_FILES: usize = 12;
const MAX_DECISIONS: usize = 6;
const MAX_FINDINGS: usize = 6;
const MAX_NEXT: usize = 6;
/// Build an owned candidate snapshot of the current session.
pub fn build_candidate(session: &SessionState) -> SummaryCandidate {
let title = session
.task
.as_ref()
.map(|t| t.description.trim().to_string())
.filter(|d| !d.is_empty())
.unwrap_or_else(|| inferred_title(session));
let files: Vec<String> = session
.files_touched
.iter()
.map(|f| f.path.clone())
.take(MAX_FILES)
.collect();
let decisions: Vec<String> = session
.decisions
.iter()
.rev()
.map(|d| d.summary.trim().to_string())
.filter(|s| !s.is_empty())
.take(MAX_DECISIONS)
.collect();
let findings: Vec<String> = session
.findings
.iter()
.rev()
.map(|f| f.summary.trim().to_string())
.filter(|s| !s.is_empty())
.take(MAX_FINDINGS)
.collect();
let next_steps: Vec<String> = session
.next_steps
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.take(MAX_NEXT)
.collect();
let body = render_body(session, &title, &files, &decisions, &findings, &next_steps);
let has_content = session.task.is_some()
|| !files.is_empty()
|| !decisions.is_empty()
|| !findings.is_empty();
SummaryCandidate {
session_id: session.id.clone(),
created_at: chrono::Utc::now(),
title,
body,
files,
decisions,
next_steps,
tool_calls: u64::from(session.stats.total_tool_calls),
has_content,
}
}
fn inferred_title(session: &SessionState) -> String {
if let Some(modified) = session.files_touched.iter().find(|f| f.modified) {
return format!("Worked on {}", short_path(&modified.path));
}
if let Some(first) = session.files_touched.first() {
return format!("Explored {}", short_path(&first.path));
}
"Session".to_string()
}
fn short_path(path: &str) -> String {
path.rsplit('/').next().unwrap_or(path).to_string()
}
fn render_body(
session: &SessionState,
title: &str,
files: &[String],
decisions: &[String],
findings: &[String],
next_steps: &[String],
) -> String {
let mut out = String::new();
if let Some(task) = &session.task {
let pct = task
.progress_pct
.map(|p| format!(" ({p}%)"))
.unwrap_or_default();
out.push_str(&format!("Task: {}{}\n", task.description.trim(), pct));
} else {
out.push_str(&format!("Focus: {title}\n"));
}
let modified: Vec<&String> = session
.files_touched
.iter()
.filter(|f| f.modified)
.map(|f| &f.path)
.collect();
if !modified.is_empty() {
out.push_str(&format!(
"Modified ({}): {}\n",
modified.len(),
join_short(modified.iter().map(|s| s.as_str()), 8)
));
}
if !files.is_empty() {
out.push_str(&format!(
"Touched ({}): {}\n",
files.len(),
join_short(files.iter().map(String::as_str), 8)
));
}
if !decisions.is_empty() {
out.push_str("Decisions:\n");
for d in decisions {
out.push_str(&format!(" - {d}\n"));
}
}
if !findings.is_empty() {
out.push_str("Findings:\n");
for f in findings {
out.push_str(&format!(" - {f}\n"));
}
}
if !next_steps.is_empty() {
out.push_str("Next:\n");
for n in next_steps {
out.push_str(&format!(" - {n}\n"));
}
}
out.push_str(&format!(
"Stats: {} tool calls, {} tokens saved\n",
session.stats.total_tool_calls, session.stats.total_tokens_saved
));
out
}
fn join_short<'a>(paths: impl Iterator<Item = &'a str>, max: usize) -> String {
let names: Vec<String> = paths.take(max).map(short_path).collect();
names.join(", ")
}
+130
View File
@@ -0,0 +1,130 @@
//! AI session summaries (#292): periodically distil the working session into a
//! compact, semantically-recallable digest.
//!
//! Pipeline: [`generate::build_candidate`] (under the session lock, cheap, owned)
//! → [`maybe_record_periodic`] (off the hot path: cadence check + persist) →
//! [`recall::recall`] (semantic when embeddings are warm, else lexical).
//!
//! Deterministic and local-first: no LLM is required to produce or recall a
//! summary.
pub mod generate;
pub mod recall;
pub mod record;
pub mod store;
pub use recall::{recall, RecallHit};
pub use record::{SummaryCandidate, SummaryRecord};
use crate::core::session::SessionState;
use store::SummaryStore;
fn config() -> crate::core::config::SummariesConfig {
crate::core::config::Config::load().summaries
}
/// Build a lock-free candidate from the live session. Call while holding the
/// session lock; persist the result off the hot path with [`maybe_record_periodic`].
pub fn build_candidate(session: &SessionState) -> SummaryCandidate {
generate::build_candidate(session)
}
/// Record `candidate` iff enabled and the turn cadence is due. Returns the title
/// of the recorded summary, or `None` if skipped.
pub fn maybe_record_periodic(project_root: &str, candidate: SummaryCandidate) -> Option<String> {
let cfg = config();
if !cfg.enabled || !candidate.has_content {
return None;
}
let store = SummaryStore::load_or_create(project_root);
if candidate.tool_calls < store.last_recorded_calls + u64::from(cfg.every_n_turns) {
return None;
}
let mut store = store;
record_into(&mut store, candidate, cfg.max_kept as usize)
}
/// Force-record a summary now (explicit action), ignoring the turn cadence.
pub fn record_now(project_root: &str, candidate: SummaryCandidate) -> Result<String, String> {
if !candidate.has_content {
return Err("session has nothing to summarize yet".to_string());
}
let cfg = config();
let mut store = SummaryStore::load_or_create(project_root);
record_into(&mut store, candidate, cfg.max_kept as usize)
.ok_or_else(|| "failed to persist summary".to_string())
}
fn record_into(
store: &mut SummaryStore,
candidate: SummaryCandidate,
max_kept: usize,
) -> Option<String> {
let calls = candidate.tool_calls;
let seq = store.next_seq();
let rec = candidate.into_record(seq);
let title = rec.title.clone();
store.last_recorded_calls = calls;
store.push(rec, max_kept);
store.save().ok()?;
Some(title)
}
/// All stored summaries for a project (oldest first).
pub fn list(project_root: &str) -> Vec<SummaryRecord> {
SummaryStore::load_or_create(project_root).summaries
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::session::SessionState;
fn isolated() -> (tempfile::TempDir, String) {
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path().join("data"));
let root = tmp.path().join("proj").to_string_lossy().to_string();
(tmp, root)
}
fn session_with_work(calls: u32) -> SessionState {
let mut s = SessionState::new();
s.set_task("Implement traversal edges", None);
s.add_decision("Use Hebbian decay for co-access weights", None);
s.touch_file("src/core/cooccurrence.rs", None, "full", 1200);
s.stats.total_tool_calls = calls;
s
}
#[test]
fn cadence_gates_then_records() {
let _g = crate::core::data_dir::test_env_lock();
let (_tmp, root) = isolated();
// Below cadence (default every_n_turns=25): skipped.
let c = build_candidate(&session_with_work(5));
assert!(maybe_record_periodic(&root, c).is_none());
// At/over cadence: recorded.
let c = build_candidate(&session_with_work(30));
assert!(maybe_record_periodic(&root, c).is_some());
assert_eq!(list(&root).len(), 1);
std::env::remove_var("LEAN_CTX_DATA_DIR");
}
#[test]
fn record_now_and_lexical_recall() {
let _g = crate::core::data_dir::test_env_lock();
let (_tmp, root) = isolated();
let c = build_candidate(&session_with_work(3));
record_now(&root, c).unwrap();
let hits = recall(&root, "traversal edges cooccurrence", 5);
assert!(!hits.is_empty(), "should recall the summary lexically");
assert!(hits[0].record.title.contains("traversal"));
std::env::remove_var("LEAN_CTX_DATA_DIR");
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Recall past session summaries — semantic when embeddings are loaded, else a
//! lexical token-overlap fallback (#292).
use super::record::SummaryRecord;
use super::store::SummaryStore;
/// One recalled summary with its score and the recall mode that produced it.
#[derive(Debug, Clone)]
pub struct RecallHit {
pub record: SummaryRecord,
pub score: f32,
pub mode: &'static str,
}
/// Recall the `top_k` summaries most relevant to `query`.
pub fn recall(project_root: &str, query: &str, top_k: usize) -> Vec<RecallHit> {
let store = SummaryStore::load_or_create(project_root);
if store.summaries.is_empty() || query.trim().is_empty() {
return Vec::new();
}
#[cfg(feature = "embeddings")]
{
if let Some(hits) = semantic(&store, query, top_k) {
return hits;
}
}
lexical(&store, query, top_k)
}
fn lexical(store: &SummaryStore, query: &str, top_k: usize) -> Vec<RecallHit> {
store
.search_lexical(query, top_k)
.into_iter()
.map(|(i, score)| RecallHit {
record: store.summaries[i].clone(),
score: score as f32,
mode: "lexical",
})
.collect()
}
/// Semantic recall. Returns `None` (→ lexical fallback) when embeddings are
/// disabled or the model isn't already loaded — never blocks on a model load.
#[cfg(feature = "embeddings")]
fn semantic(store: &SummaryStore, query: &str, top_k: usize) -> Option<Vec<RecallHit>> {
let cfg = crate::core::config::Config::load();
let profile = crate::core::config::MemoryProfile::effective(&cfg);
if !profile.embeddings_enabled() {
return None;
}
// Non-blocking: only use semantic recall if the model is already warm.
let engine = crate::core::embeddings::try_shared_engine()?;
let q = engine.embed_query(query).ok()?;
let mut scored: Vec<RecallHit> = Vec::new();
for rec in &store.summaries {
if let Ok(emb) = engine.embed_query(&rec.searchable_text()) {
scored.push(RecallHit {
record: rec.clone(),
score: cosine(&q, &emb),
mode: "semantic",
});
}
}
if scored.is_empty() {
return None;
}
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.record.created_at.cmp(&a.record.created_at))
});
scored.truncate(top_k);
Some(scored)
}
#[cfg(feature = "embeddings")]
fn cosine(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() {
return 0.0;
}
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
0.0
} else {
dot / (na * nb)
}
}
+72
View File
@@ -0,0 +1,72 @@
//! Persisted session-summary record + the lock-free candidate built under the
//! session lock (#292).
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A persisted, recallable session summary.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryRecord {
/// Stable id: `<session-id>-<seq>`.
pub id: String,
pub session_id: String,
pub created_at: DateTime<Utc>,
/// One-line headline (task description or inferred focus).
pub title: String,
/// Deterministic multi-line narrative of the session.
pub body: String,
pub files: Vec<String>,
pub decisions: Vec<String>,
pub next_steps: Vec<String>,
/// Tool-call count at the time of recording (also the cadence watermark).
pub tool_calls: u64,
}
impl SummaryRecord {
/// Text used for both lexical and semantic recall.
pub fn searchable_text(&self) -> String {
let mut t = String::with_capacity(self.title.len() + self.body.len() + 16);
t.push_str(&self.title);
t.push('\n');
t.push_str(&self.body);
t
}
}
/// An owned snapshot built while holding the session lock, then persisted off the
/// hot path. Keeps the lock hold minimal (no disk I/O under the lock).
#[derive(Debug, Clone)]
pub struct SummaryCandidate {
pub session_id: String,
pub created_at: DateTime<Utc>,
pub title: String,
pub body: String,
pub files: Vec<String>,
pub decisions: Vec<String>,
pub next_steps: Vec<String>,
pub tool_calls: u64,
/// Whether the session carried anything worth summarizing.
pub has_content: bool,
}
impl SummaryCandidate {
/// Finalize into a persisted record with a sequence number.
pub fn into_record(self, seq: u32) -> SummaryRecord {
let short = self
.session_id
.split('-')
.next()
.unwrap_or(&self.session_id);
SummaryRecord {
id: format!("{short}-{seq:04}"),
session_id: self.session_id,
created_at: self.created_at,
title: self.title,
body: self.body,
files: self.files,
decisions: self.decisions,
next_steps: self.next_steps,
tool_calls: self.tool_calls,
}
}
}
+172
View File
@@ -0,0 +1,172 @@
//! Bounded, per-project persistence for session summaries (#292).
//!
//! Mirrors the episodic-memory store pattern: one JSON file per project under
//! `{data_dir}/memory/summaries/{project_hash}.json`, newest summaries kept.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use super::record::SummaryRecord;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryStore {
pub project_hash: String,
/// Tool-call count at the last recorded summary (cadence watermark).
#[serde(default)]
pub last_recorded_calls: u64,
#[serde(default)]
pub summaries: Vec<SummaryRecord>,
}
impl SummaryStore {
fn new(project_hash: &str) -> Self {
Self {
project_hash: project_hash.to_string(),
last_recorded_calls: 0,
summaries: Vec::new(),
}
}
fn store_path(project_hash: &str) -> Option<PathBuf> {
let dir = crate::core::data_dir::lean_ctx_data_dir()
.ok()?
.join("memory")
.join("summaries");
Some(dir.join(format!("{project_hash}.json")))
}
pub fn load_or_create(project_root: &str) -> Self {
let hash = crate::core::project_hash::hash_project_root(project_root);
let Some(path) = Self::store_path(&hash) else {
return Self::new(&hash);
};
std::fs::read_to_string(&path)
.ok()
.and_then(|c| serde_json::from_str::<SummaryStore>(&c).ok())
.unwrap_or_else(|| Self::new(&hash))
}
pub fn save(&self) -> Result<(), String> {
let path = Self::store_path(&self.project_hash)
.ok_or_else(|| "cannot resolve data dir".to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())
}
/// Next sequence number (1-based), monotonic across the store's lifetime.
pub fn next_seq(&self) -> u32 {
self.summaries
.iter()
.filter_map(|s| s.id.rsplit('-').next())
.filter_map(|n| n.parse::<u32>().ok())
.max()
.unwrap_or(0)
+ 1
}
/// Append a record and prune to `max_kept` (newest kept).
pub fn push(&mut self, record: SummaryRecord, max_kept: usize) {
self.summaries.push(record);
let cap = max_kept.max(1);
if self.summaries.len() > cap {
let excess = self.summaries.len() - cap;
self.summaries.drain(0..excess);
}
}
/// Lexical token-overlap search → `(index, score)`, best first.
pub fn search_lexical(&self, query: &str, top_k: usize) -> Vec<(usize, f64)> {
let terms = tokenize(query);
if terms.is_empty() {
return Vec::new();
}
let mut scored: Vec<(usize, f64)> = self
.summaries
.iter()
.enumerate()
.filter_map(|(i, s)| {
let hay = tokenize(&s.searchable_text());
if hay.is_empty() {
return None;
}
let matches = terms.iter().filter(|t| hay.contains(*t)).count();
if matches == 0 {
None
} else {
Some((i, matches as f64 / terms.len() as f64))
}
})
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
scored.truncate(top_k);
scored
}
}
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| t.len() > 2)
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn rec(id: &str, title: &str, body: &str) -> SummaryRecord {
SummaryRecord {
id: id.to_string(),
session_id: "sess".to_string(),
created_at: Utc::now(),
title: title.to_string(),
body: body.to_string(),
files: vec![],
decisions: vec![],
next_steps: vec![],
tool_calls: 0,
}
}
#[test]
fn push_prunes_to_cap_keeping_newest() {
let mut s = SummaryStore::new("h");
for i in 0..5 {
s.push(rec(&format!("s-{i}"), "t", "b"), 3);
}
assert_eq!(s.summaries.len(), 3);
assert_eq!(s.summaries.first().unwrap().id, "s-2");
assert_eq!(s.summaries.last().unwrap().id, "s-4");
}
#[test]
fn next_seq_is_monotonic() {
let mut s = SummaryStore::new("h");
assert_eq!(s.next_seq(), 1);
s.push(rec("abc-0007", "t", "b"), 100);
assert_eq!(s.next_seq(), 8);
}
#[test]
fn lexical_search_ranks_by_overlap() {
let mut s = SummaryStore::new("h");
s.push(
rec("a-1", "graph traversal edges", "co-access learning"),
100,
);
s.push(rec("a-2", "billing webhook", "stripe meter events"), 100);
let hits = s.search_lexical("graph edges", 5);
assert_eq!(hits.first().unwrap().0, 0, "graph summary ranks first");
assert!(s.search_lexical("nonexistentterm", 5).is_empty());
}
}
+1
View File
@@ -22,6 +22,7 @@ const COCKPIT_COMPONENT_MEMORY_JS: &str = include_str!("static/components/cockpi
const COCKPIT_COMPONENT_SEARCH_JS: &str = include_str!("static/components/cockpit-search.js");
const COCKPIT_COMPONENT_COMPRESSION_JS: &str =
include_str!("static/components/cockpit-compression.js");
const COCKPIT_COMPONENT_TOUR_JS: &str = include_str!("static/components/cockpit-tour.js");
const COCKPIT_COMPONENT_GRAPH_JS: &str = include_str!("static/components/cockpit-graph.js");
const COCKPIT_COMPONENT_ARCHITECTURE_JS: &str =
include_str!("static/components/cockpit-architecture.js");
+1
View File
@@ -24,6 +24,7 @@ fn match_component_path(path: &str) -> Option<String> {
"/static/components/cockpit-memory.js" => super::COCKPIT_COMPONENT_MEMORY_JS,
"/static/components/cockpit-search.js" => super::COCKPIT_COMPONENT_SEARCH_JS,
"/static/components/cockpit-compression.js" => super::COCKPIT_COMPONENT_COMPRESSION_JS,
"/static/components/cockpit-tour.js" => super::COCKPIT_COMPONENT_TOUR_JS,
"/static/components/cockpit-graph.js" => super::COCKPIT_COMPONENT_GRAPH_JS,
"/static/components/cockpit-architecture.js" => super::COCKPIT_COMPONENT_ARCHITECTURE_JS,
"/static/components/cockpit-explorer.js" => super::COCKPIT_COMPONENT_EXPLORER_JS,
@@ -425,6 +425,7 @@ class CockpitGraph extends HTMLElement {
this._toolbarHtml('ckg-deps') +
this._searchBoxHtml() +
this._legendHtml(files) +
this._layersHtml(edges) +
this._insightsHtml() +
'<div class="graph-inspector" id="ckg-deps-inspector" hidden></div>' +
'</div>' +
@@ -437,8 +438,10 @@ class CockpitGraph extends HTMLElement {
this._bindInsightsPanel();
this._bindDepsSearch();
this._bindLegend();
this._bindLayers();
this._bindDepsToggles();
this._drawDepsD3(files, edges);
this._maybeTour();
}
/* ---- #273/#264/#274 view toggles: hide-weak / hulls / meta-graph ---- */
@@ -1511,6 +1514,51 @@ class CockpitGraph extends HTMLElement {
}
}
/* ---- #295 layers panel: toggle edge kinds individually ---- */
_layersHtml(edges) {
var kinds = {};
edges.forEach(function (e) { kinds[e.kind || 'import'] = true; });
var sorted = Object.keys(kinds).sort();
if (sorted.length < 2) return '';
var items = sorted.map(function (k) {
return '<label class="cg-layer-item"><input type="checkbox" data-layer-kind="' + k + '" checked> ' + k + '</label>';
}).join('');
return '<div class="graph-layers" id="ckg-deps-layers">' +
'<span class="graph-layers-title">Layers</span>' + items + '</div>';
}
_bindLayers() {
var self = this;
var panel = this.querySelector('#ckg-deps-layers');
if (!panel) return;
panel.addEventListener('change', function () { self._applyLayerFilter(); });
}
_applyLayerFilter() {
var panel = this.querySelector('#ckg-deps-layers');
if (!panel) return;
var hidden = {};
panel.querySelectorAll('[data-layer-kind]').forEach(function (cb) {
if (!cb.checked) hidden[cb.getAttribute('data-layer-kind')] = true;
});
this._hiddenLayers = hidden;
if (this._depsLinkSel) {
this._depsLinkSel.style('display', function (d) {
return hidden[d.kind || 'import'] ? 'none' : null;
});
}
}
/* ---- #295 tour: one-time intro overlay for new users ---- */
_maybeTour() {
var self = this;
if (window.__leanctxTour && window.__leanctxTour.shouldShow()) {
setTimeout(function () { window.__leanctxTour.start(self); }, 800);
}
}
/* ---- #260 node search: live result list + focus/zoom ---- */
_searchBoxHtml() {
@@ -0,0 +1,145 @@
/**
* Dashboard Tour (#295) — a step-by-step intro overlay that highlights
* key features on first visit. Stores completion in localStorage.
*/
(function () {
'use strict';
var STORAGE_KEY = 'leanctx_tour_done';
var STEPS = [
{
target: '.graph-stats',
title: 'Graph Overview',
body: 'This bar shows file/edge counts and quick toggles for edge visibility, community hulls, and the meta-graph view.',
position: 'below'
},
{
target: '#ckg-deps-legend',
title: 'Interactive Legend',
body: 'Click a language to filter. Click "all" to reset. The graph instantly reflects your selection.',
position: 'below'
},
{
target: '#ckg-deps-layers',
title: 'Layers Panel',
body: 'Toggle individual edge types on/off: imports, calls, co-access, community links. Combine with "hide weak" for focused views.',
position: 'below'
},
{
target: '.graph-search',
title: 'Search & Focus',
body: 'Type a filename to highlight it. Press Enter or click a result to zoom in. Matching nodes glow.',
position: 'below'
},
{
target: '#ckg-insights',
title: 'Insights & Suggested Questions',
body: 'Automated analysis: god-nodes, cycles, surprising connections, community cohesion. Click a question to explore.',
position: 'left'
},
{
target: '.graph-inspector',
title: 'Inspector Panel',
body: 'Click any node to open the inspector: neighbors, dependency paths, and impact radius at a glance.',
position: 'left'
}
];
function createOverlay() {
var el = document.createElement('div');
el.className = 'tour-overlay';
el.id = 'leanctx-tour-overlay';
el.innerHTML =
'<div class="tour-backdrop"></div>' +
'<div class="tour-box">' +
'<div class="tour-header"><span class="tour-step-num"></span><button class="tour-close" aria-label="Close tour">&times;</button></div>' +
'<h3 class="tour-title"></h3>' +
'<p class="tour-body"></p>' +
'<div class="tour-nav">' +
'<button class="tour-prev">Back</button>' +
'<button class="tour-next">Next</button>' +
'</div></div>';
document.body.appendChild(el);
return el;
}
function positionBox(box, targetEl, position) {
if (!targetEl) {
box.style.top = '50%';
box.style.left = '50%';
box.style.transform = 'translate(-50%, -50%)';
return;
}
var rect = targetEl.getBoundingClientRect();
box.style.transform = '';
if (position === 'below') {
box.style.top = (rect.bottom + 12) + 'px';
box.style.left = Math.max(12, rect.left) + 'px';
} else if (position === 'left') {
box.style.top = rect.top + 'px';
box.style.left = Math.max(12, rect.left - box.offsetWidth - 12) + 'px';
} else {
box.style.top = (rect.bottom + 12) + 'px';
box.style.left = rect.left + 'px';
}
}
function runTour(containerEl) {
var overlay = createOverlay();
var box = overlay.querySelector('.tour-box');
var stepNum = overlay.querySelector('.tour-step-num');
var title = overlay.querySelector('.tour-title');
var body = overlay.querySelector('.tour-body');
var prevBtn = overlay.querySelector('.tour-prev');
var nextBtn = overlay.querySelector('.tour-next');
var closeBtn = overlay.querySelector('.tour-close');
var currentStep = 0;
function show(i) {
currentStep = i;
var step = STEPS[i];
stepNum.textContent = (i + 1) + ' / ' + STEPS.length;
title.textContent = step.title;
body.textContent = step.body;
prevBtn.disabled = i === 0;
nextBtn.textContent = i === STEPS.length - 1 ? 'Done' : 'Next';
var target = containerEl.querySelector(step.target);
positionBox(box, target, step.position);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
target.classList.add('tour-highlight');
}
STEPS.forEach(function (s, j) {
if (j !== i) {
var el = containerEl.querySelector(s.target);
if (el) el.classList.remove('tour-highlight');
}
});
}
function finish() {
localStorage.setItem(STORAGE_KEY, '1');
overlay.remove();
STEPS.forEach(function (s) {
var el = containerEl.querySelector(s.target);
if (el) el.classList.remove('tour-highlight');
});
}
prevBtn.addEventListener('click', function () { if (currentStep > 0) show(currentStep - 1); });
nextBtn.addEventListener('click', function () {
if (currentStep >= STEPS.length - 1) finish();
else show(currentStep + 1);
});
closeBtn.addEventListener('click', finish);
overlay.querySelector('.tour-backdrop').addEventListener('click', finish);
show(0);
}
window.__leanctxTour = {
start: function (containerEl) { runTour(containerEl); },
shouldShow: function () { return !localStorage.getItem(STORAGE_KEY); },
reset: function () { localStorage.removeItem(STORAGE_KEY); }
};
})();
+1
View File
@@ -156,6 +156,7 @@
<script type="module" src="/static/components/cockpit-memory.js"></script>
<script type="module" src="/static/components/cockpit-search.js"></script>
<script type="module" src="/static/components/cockpit-compression.js"></script>
<script src="/static/components/cockpit-tour.js"></script>
<script type="module" src="/static/components/cockpit-graph.js"></script>
<script type="module" src="/static/components/cockpit-architecture.js"></script>
<script type="module" src="/static/components/cockpit-explorer.js"></script>
+21
View File
@@ -1433,3 +1433,24 @@ tr:hover td{background:var(--surface-2)}
/* Background ASCII art was visual noise behind the data — keep it off. */
.ascii-global-bg{display:none}
/* ---- #295 Layers panel ---- */
.graph-layers{display:flex;flex-wrap:wrap;gap:6px 12px;padding:6px 10px;font-size:11px;border:1px solid var(--border);border-radius:6px;margin:4px 8px}
.graph-layers-title{font-weight:600;color:var(--muted);margin-right:6px}
.cg-layer-item{display:inline-flex;align-items:center;gap:3px;cursor:pointer;color:var(--text)}
.cg-layer-item input{accent-color:var(--green)}
/* ---- #295 Tour overlay ---- */
.tour-overlay{position:fixed;inset:0;z-index:9999}
.tour-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.55)}
.tour-box{position:absolute;z-index:10000;background:var(--bg);border:1px solid var(--green);border-radius:10px;padding:16px 20px;max-width:340px;box-shadow:0 8px 32px rgba(0,0,0,.6)}
.tour-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.tour-step-num{font-size:11px;color:var(--muted)}
.tour-close{background:none;border:none;color:var(--text);font-size:18px;cursor:pointer}
.tour-title{margin:0 0 6px;font-size:14px;color:var(--text-bright)}
.tour-body{margin:0 0 12px;font-size:12px;color:var(--text);line-height:1.5}
.tour-nav{display:flex;gap:8px;justify-content:flex-end}
.tour-nav button{padding:5px 14px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;cursor:pointer}
.tour-nav button:hover:not(:disabled){background:var(--green);color:var(--bg);border-color:var(--green)}
.tour-nav button:disabled{opacity:.4;cursor:default}
.tour-highlight{outline:2px solid var(--green);outline-offset:2px;border-radius:4px;transition:outline .3s}
+16 -2
View File
@@ -601,6 +601,8 @@ fn is_tool_detected(target: &RulesTarget, home: &std::path::Path) -> bool {
|| home.join(".augment").exists()
|| detect_extension_installed(home, "augment.vscode-augment")
}
"OpenClaw" => home.join(".openclaw").exists() || command_exists("openclaw"),
"Hermes Agent" => home.join(".hermes").exists() || command_exists("hermes"),
_ => false,
}
}
@@ -842,6 +844,18 @@ fn build_rules_targets(
path: home.join(".openclaw/rules/lean-ctx.md"),
format: RulesFormat::DedicatedMarkdown,
},
RulesTarget {
name: "Codex CLI",
path: crate::core::home::resolve_codex_dir()
.unwrap_or_else(|| home.join(".codex"))
.join("instructions.md"),
format: RulesFormat::SharedMarkdown,
},
RulesTarget {
name: "Hermes Agent",
path: home.join(".hermes/HERMES.md"),
format: RulesFormat::SharedMarkdown,
},
]
}
@@ -1170,10 +1184,10 @@ mod tests {
fn target_count() {
let home = std::path::PathBuf::from("/tmp/fake_home");
let targets = build_rules_targets(&home, crate::core::config::RulesInjection::Shared);
assert_eq!(targets.len(), 23);
assert_eq!(targets.len(), 25);
// Dedicated mode swaps paths/formats but never changes the target count.
let dedicated = build_rules_targets(&home, crate::core::config::RulesInjection::Dedicated);
assert_eq!(dedicated.len(), 23);
assert_eq!(dedicated.len(), 25);
}
#[test]
+1 -1
View File
@@ -276,7 +276,7 @@ mod tests {
let registry = crate::server::registry::build_registry();
assert_eq!(
registry.len(),
73,
75,
"Registry tool count drift! Update this test AND all docs when adding/removing tools."
);
}
+2
View File
@@ -184,6 +184,8 @@ pub fn build_registry() -> ToolRegistry {
registry.register(Box::new(registered::ctx_agent::CtxAgentTool));
registry.register(Box::new(registered::ctx_share::CtxShareTool));
registry.register(Box::new(registered::ctx_skillify::CtxSkillifyTool));
registry.register(Box::new(registered::ctx_summary::CtxSummaryTool));
registry.register(Box::new(registered::ctx_package::CtxPackageTool));
registry.register(Box::new(registered::ctx_task::CtxTaskTool));
registry.register(Box::new(registered::ctx_handoff::CtxHandoffTool));
registry.register(Box::new(registered::ctx_workflow::CtxWorkflowTool));
+131
View File
@@ -0,0 +1,131 @@
//! `ctx_package` business logic (#293): save/resume portable context packages.
use std::path::Path;
use crate::core::context_package;
use crate::core::session::SessionState;
pub fn handle(
project_root: &str,
session: Option<&SessionState>,
action: &str,
path: Option<&str>,
agent_id: Option<&str>,
description: Option<&str>,
) -> String {
match action.trim() {
"save" => handle_save(project_root, session, path, agent_id, description),
"resume" => handle_resume(project_root, session, path),
"list" => handle_list(project_root),
"info" => handle_info(path),
other => format!(
"ERR: unknown package action '{other}'. Use: save | resume <path> | list | info <path>"
),
}
}
fn handle_save(
project_root: &str,
session: Option<&SessionState>,
path: Option<&str>,
agent_id: Option<&str>,
description: Option<&str>,
) -> String {
let Some(session) = session else {
return "ERR: no active session to save".to_string();
};
let output_path = path.map(Path::new);
match context_package::save_package(session, project_root, agent_id, description, output_path) {
Ok(p) => format!("package saved: {}", p.display()),
Err(e) => format!("ERR: {e}"),
}
}
fn handle_resume(project_root: &str, session: Option<&SessionState>, path: Option<&str>) -> String {
let Some(path_str) = path else {
return "ERR: resume requires a path to the .ctx.json package".to_string();
};
let pkg_path = Path::new(path_str);
if !pkg_path.exists() {
// Try the default packages directory.
let hash = crate::core::project_hash::hash_project_root(project_root);
let alt = crate::core::data_dir::lean_ctx_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from(".lean-ctx"))
.join("packages")
.join(hash)
.join(path_str);
if !alt.exists() {
return format!("ERR: package not found: {path_str}");
}
return do_resume(session, &alt);
}
do_resume(session, pkg_path)
}
fn do_resume(session: Option<&SessionState>, path: &Path) -> String {
let mut target = match session {
Some(base) => base.clone(),
None => SessionState::new(),
};
match context_package::resume_package(&mut target, path) {
Ok(report) => report.format(),
Err(e) => format!("ERR: {e}"),
}
}
fn handle_list(project_root: &str) -> String {
let hash = crate::core::project_hash::hash_project_root(project_root);
let dir = crate::core::data_dir::lean_ctx_data_dir()
.unwrap_or_else(|_| std::path::PathBuf::from(".lean-ctx"))
.join("packages")
.join(hash);
if !dir.exists() {
return "No saved packages yet.".to_string();
}
let mut entries: Vec<String> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) == Some("json") {
if let Ok(json) = std::fs::read_to_string(&p) {
if let Ok(pkg) = serde_json::from_str::<context_package::ContextPackage>(&json)
{
entries.push(format!(
" {}{}",
p.file_name().unwrap_or_default().to_string_lossy(),
pkg.summary_line()
));
}
}
}
}
}
if entries.is_empty() {
return "No saved packages yet.".to_string();
}
entries.sort();
format!("packages ({}):\n{}", entries.len(), entries.join("\n"))
}
fn handle_info(path: Option<&str>) -> String {
let Some(path_str) = path else {
return "ERR: info requires a path".to_string();
};
let p = Path::new(path_str);
if !p.exists() {
return format!("ERR: not found: {path_str}");
}
match std::fs::read_to_string(p) {
Ok(json) => match serde_json::from_str::<context_package::ContextPackage>(&json) {
Ok(pkg) => format!(
"format_version: {}\ncreated: {}\nproject: {}\n{}",
pkg.format_version,
pkg.created_at.format("%Y-%m-%d %H:%M"),
pkg.project_root,
pkg.summary_line()
),
Err(e) => format!("ERR: parse: {e}"),
},
Err(e) => format!("ERR: read: {e}"),
}
}
+76
View File
@@ -0,0 +1,76 @@
//! `ctx_summary` business logic (#292): record + recall AI session summaries.
use crate::core::session::SessionState;
use crate::core::session_summary;
/// Dispatch a summary action. `session` is required for `record`.
pub fn handle(
project_root: &str,
session: Option<&SessionState>,
action: &str,
query: Option<&str>,
top_k: usize,
) -> String {
match action.trim() {
"" | "recall" => render_recall(project_root, query, top_k),
"record" => render_record(project_root, session),
"list" => render_list(project_root),
other => {
format!("ERR: unknown summary action '{other}'. Use: recall <query> | record | list")
}
}
}
fn render_recall(project_root: &str, query: Option<&str>, top_k: usize) -> String {
let Some(query) = query.map(str::trim).filter(|q| !q.is_empty()) else {
return "ERR: recall requires a query (e.g. \"what did I do on the graph?\")".to_string();
};
let hits = session_summary::recall(project_root, query, top_k.clamp(1, 20));
if hits.is_empty() {
return format!("No session summaries match '{query}'.");
}
let mode = hits.first().map_or("lexical", |h| h.mode);
let mut out = format!(
"session summaries for '{query}' ({} hits, {mode}):\n",
hits.len()
);
for h in hits {
let when = h.record.created_at.format("%Y-%m-%d %H:%M");
out.push_str(&format!(
"\n[{}] {}{} (score {:.2})\n{}\n",
h.record.id, when, h.record.title, h.score, h.record.body
));
}
out
}
fn render_record(project_root: &str, session: Option<&SessionState>) -> String {
let Some(session) = session else {
return "ERR: no active session to summarize".to_string();
};
let candidate = session_summary::build_candidate(session);
match session_summary::record_now(project_root, candidate) {
Ok(title) => format!("summary recorded: {title}"),
Err(e) => format!("summary: {e}"),
}
}
fn render_list(project_root: &str) -> String {
let summaries = session_summary::list(project_root);
if summaries.is_empty() {
return "No session summaries yet.".to_string();
}
let mut out = format!("session summaries ({}):\n", summaries.len());
for s in summaries.iter().rev() {
let when = s.created_at.format("%Y-%m-%d %H:%M");
out.push_str(&format!(
" [{}] {}{} ({} files, {} tool calls)\n",
s.id,
when,
s.title,
s.files.len(),
s.tool_calls
));
}
out
}
+2
View File
@@ -38,6 +38,7 @@ pub mod ctx_multi_repo;
pub mod ctx_outline;
pub mod ctx_overview;
pub mod ctx_pack;
pub mod ctx_package;
pub mod ctx_plan;
pub mod ctx_plugins;
pub mod ctx_prefetch;
@@ -59,6 +60,7 @@ pub mod ctx_shell;
pub mod ctx_skillify;
pub mod ctx_smart_read;
pub mod ctx_smells;
pub mod ctx_summary;
pub mod ctx_symbol;
pub mod ctx_task;
pub mod ctx_tools;
+71
View File
@@ -0,0 +1,71 @@
use rmcp::model::Tool;
use rmcp::ErrorData;
use serde_json::{json, Map, Value};
use crate::server::tool_trait::{get_str, McpTool, ToolContext, ToolOutput};
use crate::tool_defs::tool_def;
pub struct CtxPackageTool;
impl McpTool for CtxPackageTool {
fn name(&self) -> &'static str {
"ctx_package"
}
fn tool_def(&self) -> Tool {
tool_def(
"ctx_package",
"Save or resume portable context packages — self-contained JSON bundles with session state, summaries, and knowledge. Use to hand off context between agents, persist session snapshots for later, or onboard a new agent into a previous session's context. Actions: save (export current session), resume (import from a package file), list (show saved packages), info (inspect a package without importing).",
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["save", "resume", "list", "info"],
"description": "Package action (required)"
},
"path": {
"type": "string",
"description": "File path for resume/info, or custom output path for save"
},
"description": {
"type": "string",
"description": "Human-readable description for the saved package"
}
},
"required": ["action"]
}),
)
}
fn handle(
&self,
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
let action = get_str(args, "action").unwrap_or_else(|| "save".to_string());
let path = get_str(args, "path");
let description = get_str(args, "description");
let guard = ctx
.session
.as_ref()
.and_then(|s| crate::server::bounded_lock::read(s, "ctx_package:session"));
let session_ref = guard.as_deref();
let root = session_ref
.and_then(|s| s.project_root.clone())
.unwrap_or_else(|| ctx.project_root.clone());
let agent_id_guard = ctx.agent_id.as_ref().map(|a| a.blocking_read());
let agent_id = agent_id_guard.as_ref().and_then(|g| g.as_deref());
let result = crate::tools::ctx_package::handle(
&root,
session_ref,
&action,
path.as_deref(),
agent_id,
description.as_deref(),
);
Ok(ToolOutput::simple(result))
}
}
+65
View File
@@ -0,0 +1,65 @@
use rmcp::model::Tool;
use rmcp::ErrorData;
use serde_json::{json, Map, Value};
use crate::server::tool_trait::{get_str, McpTool, ToolContext, ToolOutput};
use crate::tool_defs::tool_def;
pub struct CtxSummaryTool;
impl McpTool for CtxSummaryTool {
fn name(&self) -> &'static str {
"ctx_summary"
}
fn tool_def(&self) -> Tool {
tool_def(
"ctx_summary",
"Record and recall AI session summaries — compact, semantically-recallable digests of what was done (task, files, decisions, next steps). Actions: recall (find past summaries by query; semantic when embeddings are warm, else lexical), record (snapshot the current session now), list (recent summaries). Summaries are also captured automatically on the checkpoint cadence.",
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["recall", "record", "list"],
"description": "Summary action (default: recall)"
},
"query": {
"type": "string",
"description": "Recall query, e.g. \"what did I change in the graph index?\""
},
"top_k": {
"type": "integer",
"description": "Max summaries to return for recall (default 5, max 20)"
}
}
}),
)
}
fn handle(
&self,
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
let action = get_str(args, "action").unwrap_or_else(|| "recall".to_string());
let query = get_str(args, "query");
let top_k = args
.get("top_k")
.and_then(Value::as_u64)
.map_or(5, |n| n as usize);
let guard = ctx
.session
.as_ref()
.and_then(|s| crate::server::bounded_lock::read(s, "ctx_summary:session"));
let session_ref = guard.as_deref();
let root = session_ref
.and_then(|s| s.project_root.clone())
.unwrap_or_else(|| ctx.project_root.clone());
let result =
crate::tools::ctx_summary::handle(&root, session_ref, &action, query.as_deref(), top_k);
Ok(ToolOutput::simple(result))
}
}
+2
View File
@@ -40,6 +40,7 @@ pub mod ctx_multi_repo;
pub mod ctx_outline;
pub mod ctx_overview;
pub mod ctx_pack;
pub mod ctx_package;
pub mod ctx_plan;
pub mod ctx_plugins;
pub mod ctx_prefetch;
@@ -63,6 +64,7 @@ pub mod ctx_shell;
pub mod ctx_skillify;
pub mod ctx_smart_read;
pub mod ctx_smells;
pub mod ctx_summary;
pub mod ctx_symbol;
pub mod ctx_task;
pub mod ctx_tools;
+11
View File
@@ -129,6 +129,8 @@ impl LeanCtxServer {
let session_summary = session.format_compact();
let has_insights = !session.findings.is_empty() || !session.decisions.is_empty();
let project_root = session.project_root.clone();
// Snapshot the session under the lock; persist the summary off the hot path.
let summary_candidate = crate::core::session_summary::build_candidate(&session);
drop(session);
if has_insights {
@@ -140,6 +142,15 @@ impl LeanCtxServer {
}
}
// Periodically record a recallable AI session summary (#292), off-thread.
if let Some(ref root) = project_root {
let root = root.clone();
std::thread::spawn(move || {
let _ =
crate::core::session_summary::maybe_record_periodic(&root, summary_candidate);
});
}
let multi_agent_block = self
.auto_multi_agent_checkpoint(project_root.as_ref())
.await;
+2 -2
View File
@@ -126,7 +126,7 @@ fn bench_tool_descriptions_token_count() {
eprintln!("{}", "=".repeat(70));
// Budgets reflect the real registry tool surface (single source of truth,
// #141): all 73 tools with their full `McpTool::tool_def()` descriptions —
// #141): all 75 tools with their full `McpTool::tool_def()` descriptions —
// i.e. exactly what the live server advertises in full mode. The previous
// (lower) budget measured the retired `list_all_tool_defs` abbreviated set,
// which never matched what agents actually received. Bumped to 2600 with the
@@ -177,7 +177,7 @@ fn bench_total_input_overhead() {
);
eprintln!("{}", "=".repeat(70));
// Full tool surface (all 73 tools, registry SSOT incl. full property
// Full tool surface (all 75 tools, registry SSOT incl. full property
// schemas) — the worst-case opt-in overhead. The default lazy surface is
// far smaller; see `bench_lazy_default_vs_full_overhead` (#141).
assert!(
Executable
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# gl — GitLab API helper for lean-ctx (Project ID 5)
# Works around Traefik's %2F rejection by using numeric project ID.
#
# Usage:
# gl issues # list open issues
# gl issue 290 # show issue #290
# gl close 290 # close issue #290
# gl comment 290 "Done" # add note to issue #290
# gl create "Title" "Desc" "label1,label2" # create issue
# gl epics # list open epics (label type::epic)
# gl api <endpoint> # raw API call (project-scoped)
set -euo pipefail
export GITLAB_HOST="${GITLAB_HOST:-gitlab.pounce.ch}"
export GITLAB_TOKEN="${GITLAB_TOKEN:-glpat-zhpRWi88merUrw7VM1o5}"
PROJECT_ID=5
BASE="projects/${PROJECT_ID}"
cmd="${1:-help}"
shift || true
case "$cmd" in
issues|list)
state="${1:-opened}"
glab api "${BASE}/issues?state=${state}&per_page=50&order_by=updated_at" | \
python3 -c "
import sys, json
issues = json.loads(sys.stdin.read())
for i in issues:
labels = ', '.join(i.get('labels', []))
print(f'#{i[\"iid\"]:>4} {i[\"state\"]:8} {i[\"title\"][:72]} [{labels}]')
"
;;
issue|show)
iid="${1:?Usage: gl issue <iid>}"
glab api "${BASE}/issues/${iid}" | python3 -c "
import sys, json
i = json.loads(sys.stdin.read())
print(f'#{i[\"iid\"]} [{i[\"state\"]}] {i[\"title\"]}')
print(f'Labels: {\", \".join(i.get(\"labels\", []))}')
print(f'Created: {i[\"created_at\"][:10]} Updated: {i[\"updated_at\"][:10]}')
if i.get('description'):
print(f'\\n{i[\"description\"][:500]}')
"
;;
close)
iid="${1:?Usage: gl close <iid>}"
glab api --method PUT "${BASE}/issues/${iid}" -f state_event=close >/dev/null
echo "Closed #${iid}"
;;
reopen)
iid="${1:?Usage: gl reopen <iid>}"
glab api --method PUT "${BASE}/issues/${iid}" -f state_event=reopen >/dev/null
echo "Reopened #${iid}"
;;
comment|note)
iid="${1:?Usage: gl comment <iid> <body>}"
body="${2:?Usage: gl comment <iid> <body>}"
glab api --method POST "${BASE}/issues/${iid}/notes" -f body="${body}" >/dev/null
echo "Comment added to #${iid}"
;;
create)
title="${1:?Usage: gl create <title> [description] [labels]}"
desc="${2:-}"
labels="${3:-}"
args=(-f "title=${title}")
[[ -n "$desc" ]] && args+=(-f "description=${desc}")
[[ -n "$labels" ]] && args+=(-f "labels=${labels}")
result=$(glab api --method POST "${BASE}/issues" "${args[@]}")
iid=$(echo "$result" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['iid'])")
echo "Created #${iid}: ${title}"
;;
epics)
glab api "${BASE}/issues?state=opened&labels=type::epic&per_page=50" | \
python3 -c "
import sys, json
for i in json.loads(sys.stdin.read()):
print(f'#{i[\"iid\"]:>4} {i[\"title\"][:80]}')
"
;;
api)
glab api "${BASE}/${1}" "${@:2}"
;;
help|--help|-h)
echo "gl — GitLab API helper (lean-ctx, project #5)"
echo ""
echo "Commands:"
echo " issues [state] List issues (default: opened)"
echo " issue <iid> Show issue details"
echo " close <iid> Close an issue"
echo " reopen <iid> Reopen an issue"
echo " comment <iid> <body> Add a note"
echo " create <title> [desc] [labels] Create issue"
echo " epics List open epics"
echo " api <path> [args...] Raw project-scoped API call"
;;
*)
echo "Unknown command: $cmd (try: gl help)" >&2
exit 1
;;
esac