diff --git a/cube-notes-mcp.py b/cube-notes-mcp.py index 7519ac5..27d474e 100644 --- a/cube-notes-mcp.py +++ b/cube-notes-mcp.py @@ -67,6 +67,20 @@ def tool_schemas(): "coord": {"type": "string"}, }, "required": ["coord"]}, }, + { + "name": "project_resume", + "description": "Set a resume marker for a project (where it left off).", + "inputSchema": {"type": "object", "properties": { + "name": {"type": "string"}, "text": {"type": "string"}, + }, "required": ["name", "text"]}, + }, + { + "name": "project_context", + "description": "Compact 'where we are' summary for a project (latest notes + resume marker) — for context injection.", + "inputSchema": {"type": "object", "properties": { + "name": {"type": "string"}, + }, "required": ["name"]}, + }, ] @@ -116,6 +130,15 @@ def dispatch(name, args): if name == "project_show": c = (args.get("coord") or "").strip() return run_cube(["project", "show", c]) if c else "error: missing 'coord'" + if name == "project_resume": + nm = (args.get("name") or "").strip() + tx = (args.get("text") or "").strip() + if not nm or not tx: + return "error: project_resume needs name + text" + return run_cube(["project", "resume", nm, tx]) + if name == "project_context": + nm = (args.get("name") or "").strip() + return run_cube(["project", "context", nm]) if nm else "error: missing 'name'" return f"error: unknown tool {name}" diff --git a/cubesys/src/notes.rs b/cubesys/src/notes.rs index 8041241..13cee15 100644 --- a/cubesys/src/notes.rs +++ b/cubesys/src/notes.rs @@ -546,7 +546,53 @@ fn filter_from_flags(flags: &[(String, String)]) -> NoteFilter { f } -/// Process a `project` command line: `project list` or `project show `. +/// Set a "resume" marker for a project. Stored as a note tagged +/// `category = "resume"` and associated to the project, so a new session can +/// ask "where did this project leave off?" and get it back. +pub fn project_resume( + store: &ConcurrentStore, + name: &str, + text: &str, + session: &str, +) -> Result { + let subject = format!("resume: {name}"); + store_note(store, session, &subject, text, Some(name), Some("resume")) +} + +/// Build a compact "project state" summary for context injection: the project +/// name, its note count, the resume marker (if any), and the latest notes with +/// date/category. A new session reads this to know where a project left off. +pub fn project_context(store: &ConcurrentStore, name: &str) -> String { + let Some(coord) = find_project(store, name) else { + return format!("(no project '{name}')"); + }; + let notes = project_walk(store, coord); + let mut out = String::new(); + out.push_str(&format!("Project: {name}\n")); + out.push_str(&format!(" {} note(s); thread {}.\n", notes.len(), coord_str(coord))); + if let Some(r) = notes + .iter() + .find(|n| category_of(n.header.doc_type.as_deref().unwrap_or("")) == Some("resume")) + { + out.push_str(&format!( + " RESUME: {}\n", + String::from_utf8_lossy(&r.body).trim() + )); + } + out.push_str(" Latest:\n"); + for n in notes.iter().take(10) { + let subj = n.header.title.as_deref().unwrap_or(""); + let when = n.header.created_at.map(|t| date_string(t)).unwrap_or_else(|| "?".into()); + let cat = category_of(n.header.doc_type.as_deref().unwrap_or("")) + .map(|c| format!("[{c}] ")) + .unwrap_or_default(); + out.push_str(&format!(" {when} {cat}{subj}\n")); + } + out +} + +/// Process a `project` command line: `project list`, `project show `, +/// `project resume `, `project context `. pub fn project_command(store: &ConcurrentStore, line: &str) -> Result { let body = line .splitn(2, char::is_whitespace) @@ -576,7 +622,26 @@ pub fn project_command(store: &ConcurrentStore, line: &str) -> Result Ok(format!("project {cs} -> (no project record)")), } } - _ => Err(format!("project: unknown subcommand '{head}' (want list|show)")), + "resume" => { + let (_, pos) = split_flags(rest); + let mut words = pos.splitn(2, char::is_whitespace); + let name = words.next().unwrap_or("").to_string(); + let text = words.next().unwrap_or("").trim().to_string(); + if name.is_empty() || text.is_empty() { + return Err("project resume needs ".to_string()); + } + let session = default_session(); + let coord = project_resume(store, &name, &text, &session)?; + Ok(format!("resume set for '{name}' at {} (session {})", coord_str(coord), session)) + } + "context" => { + let name = rest.trim(); + if name.is_empty() { + return Err("project context needs ".to_string()); + } + Ok(project_context(store, name)) + } + _ => Err(format!("project: unknown subcommand '{head}' (want list|show|resume|context)")), } } @@ -675,4 +740,15 @@ mod tests { assert_eq!(date_to_day("2026-09-08"), Some(20_704)); assert_eq!(date_string(20_704 * 86_400), "2026-09-08"); } + + #[test] + fn project_resume_and_context() { + let store = ConcurrentStore::memory(); + store_note(&store, "s", "design", "widget design", Some("p"), Some("design")).unwrap(); + project_resume(&store, "p", "at the design review", "s").unwrap(); + let ctx = project_context(&store, "p"); + assert!(ctx.contains("Project: p")); + assert!(ctx.contains("RESUME: at the design review")); + assert!(ctx.contains("[design]")); + } }