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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -403,6 +403,10 @@ pub fn run() {
|
||||
super::cmd_skillify(&rest);
|
||||
return;
|
||||
}
|
||||
"summary" => {
|
||||
super::cmd_summary(&rest);
|
||||
return;
|
||||
}
|
||||
"overview" => {
|
||||
super::cmd_overview(&rest);
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?"
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(", ")
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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">×</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); }
|
||||
};
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user