cubesys/cube-notes-mcp: project resume + context. 'cube project resume <name> <text>' sets a resume marker (note category=resume, associated to the project); 'cube project context <name>' emits a compact 'where we are' summary (latest notes + resume marker) for context injection into a new session. MCP tools project_resume/project_context added. 5 notes tests pass.

This commit is contained in:
2026-09-08 05:34:58 -04:00
parent 18f8eb9b4a
commit 8c66478a42
2 changed files with 101 additions and 2 deletions
+23
View File
@@ -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}"
+78 -2
View File
@@ -546,7 +546,53 @@ fn filter_from_flags(flags: &[(String, String)]) -> NoteFilter {
f
}
/// Process a `project` command line: `project list` or `project show <C.Z.Y.X>`.
/// 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<Czyx, String> {
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 <C.Z.Y.X>`,
/// `project resume <name> <text>`, `project context <name>`.
pub fn project_command(store: &ConcurrentStore, line: &str) -> Result<String, String> {
let body = line
.splitn(2, char::is_whitespace)
@@ -576,7 +622,26 @@ pub fn project_command(store: &ConcurrentStore, line: &str) -> Result<String, St
None => 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 <name> <text>".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 <name>".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]"));
}
}