diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ca7b4b..fdc1c0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **`author` on every write path (#1427, phase 2)** - a memory records who wrote it (the agent, user or tool name), with or without a room. `POST /remember`, MCP `uteke_remember` and `uteke remember --author` accept it (free text, trimmed, 1 to 128 characters; an invalid value is a 400, a tool error or a CLI error before anything is stored). A room write stamps the named writer on the memory as well as on the room link; the `anonymous` / `user` fallback of the room link is not an author. `author` is returned on the memory object by recall, search, list and get (omitted when unset), echoed by `POST /remember`, and kept by export and import. Stores migrate to schema v22 on first open (see Internal below): back up a production store first. Filtering by author is not part of this phase. Contract: uteke-cloud#219 (cloud already has the field). + ### Fixed - **`UTEKE_HOME` pointing at another store no longer routes to a running server (#1445)** - with `[server] enabled = true` and `uteke-serve` running, `UTEKE_HOME=/tmp/iso uteke remember ...` was sent to the server and landed in the server's store instead of `/tmp/iso`. The server now reports which store it serves on the new `GET /store` (LAB route, token required when auth is on): `{"id": "<16 hex digits>"}`, a SHA-256 prefix of the store's canonical path, never the path itself. Before routing to a loopback server the CLI compares that id with the store it resolved and runs locally on a mismatch (with a warning on stderr). The probe sends `UTEKE_AUTH_TOKEN` like any routed command; without it an auth-enabled server refuses the command with 401, so nothing lands in the wrong store. A server without the route (404), an unreachable one or a non-loopback one keeps the previous routing. `docs/core-contract.json` gains `/store` in the `lab` list only. diff --git a/crates/uteke-cli/src/cli.rs b/crates/uteke-cli/src/cli.rs index e371f959..84ccc53b 100644 --- a/crates/uteke-cli/src/cli.rs +++ b/crates/uteke-cli/src/cli.rs @@ -78,7 +78,9 @@ pub enum Commands { /// Room ID to link this memory to (collaborative context) #[arg(long)] room: Option, - /// Author attribution when storing in a room + /// Who wrote this memory: agent, user or tool name (1 to 128 + /// characters). Recorded on the memory with or without --room; with + /// --room it is also the room author (default there: anonymous) #[arg(long)] author: Option, /// Source provenance: URL, file path, or identifier (#348) diff --git a/crates/uteke-cli/src/commands/remember.rs b/crates/uteke-cli/src/commands/remember.rs index e7e1c656..0420fc77 100644 --- a/crates/uteke-cli/src/commands/remember.rs +++ b/crates/uteke-cli/src/commands/remember.rs @@ -75,6 +75,14 @@ pub(crate) fn run( uteke_core::memory::types::validate_author_type(at) .map_err(|e| format!("Invalid --author-type: {e}"))?; } + // #1427 phase 2: the author is the memory's own, with or without a room; + // validated (and trimmed) before any write for the same reason. + let author: Option<&str> = author + .map(|a| { + uteke_core::memory::types::validate_author(a) + .map_err(|e| format!("Invalid --author: {e}")) + }) + .transpose()?; // Read from stdin when content argument is "-" (#620). // Standard Unix convention: pipe content via echo "text" | uteke remember - --tags "test" @@ -157,6 +165,11 @@ pub(crate) fn run( return Err(format!("Failed to set author_type: {e}")); } } + if let Some(a) = author { + uteke + .set_author(&id, Some(a)) + .map_err(|e| format!("Failed to set author: {e}"))?; + } if cli.json { let mut obj = serde_json::json!({ "id": id, @@ -174,6 +187,9 @@ pub(crate) fn run( map.insert("metadata".to_string(), meta.clone()); } } + if let Some(a) = author { + obj["author"] = serde_json::json!(a); + } println!("{obj}"); } else { output::print_remember_human(&id); @@ -226,6 +242,11 @@ pub(crate) fn run( return Err(format!("Failed to set author_type: {e}")); } } + if let Some(a) = author { + uteke + .set_author(&id, Some(a)) + .map_err(|e| format!("Failed to set author: {e}"))?; + } if cli.json { let mut obj = serde_json::json!({ "id": id, @@ -255,6 +276,9 @@ pub(crate) fn run( map.insert("metadata".to_string(), serde_json::Value::Object(m)); } } + if let Some(a) = author { + obj["author"] = serde_json::json!(a); + } println!("{obj}"); } else { output::print_remember_human(&id); diff --git a/crates/uteke-cli/tests/author_flag.rs b/crates/uteke-cli/tests/author_flag.rs new file mode 100644 index 00000000..38c8670f --- /dev/null +++ b/crates/uteke-cli/tests/author_flag.rs @@ -0,0 +1,99 @@ +//! `uteke remember --author` without a room (#1427 phase 2): the author is +//! recorded on the memory, shown by `get` and `list`, and an invalid one fails +//! before anything is stored. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +struct Store { + dir: PathBuf, +} + +impl Store { + fn new(name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("uteke_author_flag_{name}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("home")).unwrap(); + // Server routing off, so every command runs against the local store. + std::fs::write(dir.join("home/uteke.toml"), "[server]\nenabled = false\n").unwrap(); + Self { dir } + } + + fn run(&self, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_uteke")) + .arg("--store") + .arg(self.dir.join("store")) + .args(args) + .env("UTEKE_HOME", self.dir.join("home")) + .env("UTEKE_NO_UPDATE_CHECK", "1") + // No embedder: memories are stored FTS-only, commands still run. + .env("ORT_LIB_PATH", "/nonexistent/uteke-test/libonnxruntime.so") + .env_remove("UTEKE_AUTH_TOKEN") + .output() + .expect("run uteke") + } + + fn json(&self, args: &[&str]) -> serde_json::Value { + let out = self.run(args); + assert!( + out.status.success(), + "uteke {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).unwrap_or_else(|e| { + panic!( + "uteke {args:?} printed no JSON ({e}): {}", + String::from_utf8_lossy(&out.stdout) + ) + }) + } +} + +impl Drop for Store { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +#[test] +fn author_is_recorded_without_a_room_and_shown_by_get_and_list() { + let store = Store::new("recorded"); + let stored = store.json(&[ + "--json", + "remember", + "attributed note", + "--author", + " claude-code ", + ]); + assert_eq!(stored["author"], "claude-code", "{stored}"); + let id = stored["id"].as_str().unwrap().to_string(); + + let memory = store.json(&["--json", "get", &id]); + assert_eq!(memory["author"], "claude-code", "{memory}"); + + let plain = store.json(&["--json", "remember", "plain note"]); + assert!(plain.get("author").is_none(), "{plain}"); + + let listed = store.json(&["--json", "list"]); + let authors: Vec<&serde_json::Value> = listed + .as_array() + .expect("list prints an array") + .iter() + .filter(|m| m["author"] == "claude-code") + .collect(); + assert_eq!(authors.len(), 1, "{listed}"); +} + +#[test] +fn an_invalid_author_fails_before_anything_is_stored() { + let store = Store::new("invalid"); + for bad in ["", " ", &"y".repeat(129)] { + let out = store.run(&["--json", "remember", "must not be stored", "--author", bad]); + assert!(!out.status.success(), "{bad:?} was accepted"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("--author"), "{err}"); + } + let listed = store.json(&["--json", "list"]); + assert_eq!(listed.as_array().unwrap().len(), 0, "{listed}"); +} diff --git a/crates/uteke-core/src/import_export.rs b/crates/uteke-core/src/import_export.rs index 3f16c01a..3ee2c953 100644 --- a/crates/uteke-core/src/import_export.rs +++ b/crates/uteke-core/src/import_export.rs @@ -25,6 +25,7 @@ impl crate::Uteke { created_at: m.created_at, source: m.source, namespace: m.namespace, + author: m.author, }) .collect(); @@ -132,7 +133,12 @@ impl crate::Uteke { source: Some(format!("import:{}", entry.source.unwrap_or_default())), source_type: "import".to_string(), author_type: "agent".to_string(), - author: None, + // A bad value in a file is dropped, it must not fail the import. + author: entry + .author + .as_deref() + .and_then(|a| crate::memory::types::validate_author(a).ok()) + .map(str::to_string), }; // Write-ahead: vector index first (can be rolled back), then SQLite. @@ -218,13 +224,64 @@ mod tests { created_at: chrono::Utc::now(), source: None, namespace: "default".to_string(), + author: None, }; let json = serde_json::to_string(&entry).unwrap(); + assert!( + !json.contains("author"), + "an unset author is not serialized: {json}" + ); let restored: ExportEntry = serde_json::from_str(&json).unwrap(); assert_eq!(restored.content, "hello world"); assert_eq!(restored.tags.len(), 1); } + /// #1427 phase 2: the author survives export and import; an invalid author + /// in a file is dropped without failing the row; old files have none. + #[test] + fn test_author_survives_export_import() { + let src = crate::test_support::open_keyword(); + let a = src.remember("attributed note", &[], None, None).unwrap(); + src.remember("plain note", &[], None, None).unwrap(); + src.set_author(&a, Some("claude-code")).unwrap(); + let exported = src.export(None).unwrap(); + assert!( + exported.contains("\"author\":\"claude-code\""), + "{exported}" + ); + assert_eq!( + exported.matches("\"author\"").count(), + 1, + "only the attributed row" + ); + + let dst = crate::test_support::open_keyword(); + assert_eq!(dst.import(&exported, None).unwrap().imported, 2); + let authors: std::collections::BTreeMap> = dst + .list(None, 10, 0, None) + .unwrap() + .into_iter() + .map(|m| (m.content, m.author)) + .collect(); + assert_eq!(authors["attributed note"].as_deref(), Some("claude-code")); + assert_eq!(authors["plain note"], None); + + // An invalid author in a file is dropped, the row is still imported. + let long = "x".repeat(200); + let file = format!( + "{{\"content\":\"bad author\",\"author\":\"{long}\"}}\n{{\"content\":\"blank author\",\"author\":\" \"}}\n{{\"content\":\"old row\"}}" + ); + let other = crate::test_support::open_keyword(); + assert_eq!(other.import(&file, None).unwrap().imported, 3); + assert!( + other + .list(None, 10, 0, None) + .unwrap() + .iter() + .all(|m| m.author.is_none()) + ); + } + /// #1036: export must carry per-row namespace; import must reconstruct /// namespaces on round-trip (seed K namespaces → export → fresh store → /// import → all rows present with original namespaces). Also verifies diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index 6e04cbf9..eaf00019 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -1444,6 +1444,18 @@ impl Uteke { self.store.set_author(id, author) } + /// Stamp the writer of a room write on the memory itself (#1427 phase 2), + /// when it is a valid author. The room link accepts any text, so an author + /// the memory field rejects (empty, over 128 characters, control + /// characters) leaves the memory unattributed instead of failing a write + /// that already succeeded. Returns whether the author was recorded. + pub fn set_author_if_valid(&self, id: &str, author: &str) -> Result { + if crate::memory::types::validate_author(author).is_err() { + return Ok(false); + } + self.store.set_author(id, Some(author)) + } + /// Recalculate importance scores for all memories. pub fn recompute_importance(&self) -> Result { let updated = self.store.recompute_importance()?; diff --git a/crates/uteke-core/src/memory/types.rs b/crates/uteke-core/src/memory/types.rs index c21aec96..06038323 100644 --- a/crates/uteke-core/src/memory/types.rs +++ b/crates/uteke-core/src/memory/types.rs @@ -440,6 +440,10 @@ pub struct ExportEntry { /// falls back to the caller-provided namespace for those. #[serde(default = "default_namespace")] pub namespace: String, + /// Who wrote the memory (#1427 phase 2). Absent on rows from older export + /// files and on unattributed memories; an invalid value is dropped on import. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, } fn default_metadata() -> serde_json::Value { diff --git a/crates/uteke-core/src/remember_request.rs b/crates/uteke-core/src/remember_request.rs index 597120f8..ab90db64 100644 --- a/crates/uteke-core/src/remember_request.rs +++ b/crates/uteke-core/src/remember_request.rs @@ -17,13 +17,14 @@ //! | valid_from/valid_until | honoured (into metadata) | not accepted | not sent | //! | detect_contradiction | honoured | not accepted | forwarded when set | //! | source/source_type/author_type | honoured | not accepted | forwarded | -//! | room/author | rejected as unknown keys | `room` routes to room write | sent (server rejects) | +//! | author | honoured, validated (1-128) | honoured, also room author | forwarded | +//! | room | rejected as unknown key | routes to room write | sent (server rejects) | //! | timestamp | rejected as unknown key | not accepted | sent (server rejects) | //! | namespace omitted | core default | core default | `"default"` always sent | //! //! Room remember is a separate operation and is not part of this module. -use crate::memory::types::{MemoryType, validate_author_type}; +use crate::memory::types::{MemoryType, validate_author, validate_author_type}; use serde_json::{Map, Value}; use std::fmt; @@ -79,7 +80,8 @@ pub struct RememberInput { pub author_type: Option, /// Room id (MCP routes the write to a room; the CLI forwards it). pub room: Option, - /// Room author (MCP) / forwarded by the CLI. + /// Who wrote the memory (#1427 phase 2): the memory's own `author`, also + /// the room link's author when `room` is set. pub author: Option, /// CLI `--timestamp`. Only [`Self::to_http_body`] reads it. pub timestamp: Option, @@ -92,9 +94,10 @@ impl RememberInput { /// when set (`type` only when non-empty, `detect_contradiction` only when /// true, `metadata` only when a non-empty object). `entity`, `category`, /// `valid_from` and `valid_until` are not part of this encoding: the CLI - /// folds entity/category into `metadata`. `timestamp`, `room` and `author` - /// are forwarded even though the server rejects them as unknown keys - /// today; that is current behaviour, listed as an owner decision. + /// folds entity/category into `metadata`. `timestamp` and `room` are + /// forwarded even though the server rejects them as unknown keys today; + /// that is current behaviour, listed as an owner decision. `author` is + /// accepted by the server since #1427 phase 2. pub fn to_http_body(&self) -> Value { let mut body = serde_json::json!({ "content": self.content, @@ -244,6 +247,14 @@ impl RememberRequest { if let Some(at) = input.author_type.as_deref() { validate_author_type(at).map_err(|e| RememberRequestError(e.to_string()))?; } + // `author` (#1427 phase 2): free text, trimmed. With a room it is also + // the room link's author; without one it is the memory's own. + let author = input + .author + .as_deref() + .map(|a| validate_author(a).map(str::to_string)) + .transpose() + .map_err(|e| RememberRequestError(e.to_string()))?; if policy.validate_type && let Some(t) = input.memory_type.as_deref() && MemoryType::from_str_opt(t).is_none() @@ -268,7 +279,7 @@ impl RememberRequest { source_type: input.source_type, author_type: input.author_type, room: input.room, - author: input.author, + author, }) } @@ -424,6 +435,46 @@ mod tests { assert!(r.detect_contradiction); } + /// #1427 phase 2: `author` is validated before any write, under every + /// policy, and trimmed; the error order is content, author_type, author, type. + #[test] + fn author_is_validated_and_trimmed() { + for policy in [RememberPolicy::HTTP, RememberPolicy::MCP] { + let mut i = input("x"); + i.author = Some(" claude-code ".into()); + assert_eq!( + RememberRequest::decode(i, policy) + .unwrap() + .author + .as_deref(), + Some("claude-code") + ); + for bad in ["", " ", "tab\there", &"y".repeat(129)] { + let mut i = input("x"); + i.author = Some(bad.to_string()); + let e = RememberRequest::decode(i, policy).unwrap_err().to_string(); + assert!(e.starts_with("Validation error: author"), "{bad:?}: {e}"); + } + let mut i = input("x"); + i.author = Some("y".repeat(128)); + assert!( + RememberRequest::decode(i, policy).is_ok(), + "128 is the bound" + ); + } + let mut i = input("x"); + i.author_type = Some("robot".into()); + i.author = Some("".into()); + i.memory_type = Some("nope".into()); + let e = RememberRequest::decode(i, RememberPolicy::HTTP) + .unwrap_err() + .to_string(); + assert!( + e.contains("author_type"), + "author_type is reported before author: {e}" + ); + } + #[test] fn http_body_minimal_and_full() { let mut i = input("hi"); diff --git a/crates/uteke-mcp/src/lib.rs b/crates/uteke-mcp/src/lib.rs index 6b32e486..a31016bd 100644 --- a/crates/uteke-mcp/src/lib.rs +++ b/crates/uteke-mcp/src/lib.rs @@ -356,7 +356,7 @@ fn tool_remember() -> Value { "namespace": { "type": "string", "description": "DEPRECATED: namespaces will be removed in a future 0.x release; prefer rooms, tags and author. Namespace for isolation (default: 'default'); a non-default value makes the result carry a deprecation warning." }, "type": { "type": "string", "description": "Memory type: fact, procedure, preference, decision, context, note, insight, reference, event (default: fact)" }, "room": { "type": "string", "description": "Room ID for collaborative memory (optional)" }, - "author": { "type": "string", "description": "Author attribution when storing in a room (default: anonymous)" } + "author": { "type": "string", "description": "Who wrote this memory: the agent, user or tool name (1 to 128 characters). Recorded on the memory with or without a room; in a room it is also the room author (default there: anonymous)." } }, "required": ["content"] } @@ -1252,6 +1252,14 @@ fn exec_remember(uteke: &Uteke, args: &Value) -> Result { .remember_typed_detailed(content, &tags, None, namespace, memory_type) .map_err(|e| format!("Failed: {e}"))? }; + // The named writer is the memory's own author, with or without a room + // (#1427 phase 2); already validated and trimmed when the arguments were + // decoded. The "anonymous" fallback of the room link is not an author. + if let Some(a) = req.author.as_deref() { + uteke + .set_author(&outcome.id, Some(a)) + .map_err(|e| format!("Failed to set author: {e}"))?; + } let mut text = format!("✓ Stored memory with ID: {}", outcome.id); if let Some(ref warn) = outcome.warning { @@ -4152,3 +4160,71 @@ mod isolation_tests { } } } + +/// `author` over MCP (#1427 phase 2): `uteke_remember` records it on the memory +/// with or without a room, and rejects an invalid one before writing. +#[cfg(test)] +mod author_tests { + use super::*; + use serde_json::{Value, json}; + + fn remember(u: &Uteke, args: Value) -> (bool, String) { + let raw = json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "uteke_remember", "arguments": args} + }) + .to_string(); + let resp: Value = serde_json::from_str(&handle_jsonrpc(u, &raw).unwrap()).unwrap(); + let result = &resp["result"]; + let is_error = result["isError"].as_bool().unwrap_or(false) || resp.get("error").is_some(); + // A tool error comes back either as an error result or as a JSON-RPC error. + let text = result["content"][0]["text"] + .as_str() + .or_else(|| resp["error"]["message"].as_str()) + .unwrap_or_default(); + (is_error, text.to_string()) + } + + fn stored_id(text: &str) -> String { + text.split("ID: ") + .nth(1) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .to_string() + } + + #[test] + fn remember_records_the_author_with_or_without_a_room() { + let u = Uteke::open_with_backend(":memory:", None).unwrap(); + let author_of = |text: &str| u.get_by_id(&stored_id(text)).unwrap().unwrap().author; + + let (err, text) = remember(&u, json!({"content": "no room", "author": " hermes "})); + assert!(!err, "{text}"); + assert_eq!(author_of(&text).as_deref(), Some("hermes")); + + let (err, text) = remember( + &u, + json!({"content": "in a room", "room": "r1", "author": "alice"}), + ); + assert!(!err, "{text}"); + assert_eq!(author_of(&text).as_deref(), Some("alice")); + + // A room write that names no author leaves the memory unattributed. + let (err, text) = remember(&u, json!({"content": "unnamed", "room": "r1"})); + assert!(!err, "{text}"); + assert_eq!(author_of(&text), None); + } + + #[test] + fn an_invalid_author_is_refused_before_anything_is_stored() { + let u = Uteke::open_with_backend(":memory:", None).unwrap(); + for bad in ["", " ", "line\nbreak", &"y".repeat(129)] { + let (err, text) = remember(&u, json!({"content": "must not be stored", "author": bad})); + assert!(err, "{bad:?} was accepted: {text}"); + assert!(text.contains("author"), "{text}"); + } + assert_eq!(u.count(None).unwrap(), 0, "nothing is stored"); + } +} diff --git a/crates/uteke-server/src/handlers/author_tests.rs b/crates/uteke-server/src/handlers/author_tests.rs new file mode 100644 index 00000000..54a5bb98 --- /dev/null +++ b/crates/uteke-server/src/handlers/author_tests.rs @@ -0,0 +1,152 @@ +//! `author` on every write path over HTTP (#1427 phase 2): `POST /remember` +//! takes it without a room, `POST /room/remember` stamps it on the memory too, +//! and it comes back on the memory object. + +use super::*; +use serde_json::{Value, json}; +use tiny_http::TestRequest; + +struct App { + uteke: Mutex, +} + +impl App { + fn new() -> Self { + Self { + uteke: Mutex::new( + Uteke::open_with_backend(":memory:", None).expect("open without embedder"), + ), + } + } + + fn call(&self, method: Method, url: &str, body: Option) -> (u16, Value) { + let mut req: Request = match body { + Some(b) => { + let leaked: &'static str = Box::leak(b.to_string().into_boxed_str()); + TestRequest::new() + .with_method(method) + .with_path(url) + .with_body(leaked) + .into() + } + None => TestRequest::new().with_method(method).with_path(url).into(), + }; + let ctx = ReqCtx { + auth_token_hash: None, + read_only_token_hash: None, + cors_origins: Vec::new(), + recall_config: None, + extraction_config: None, + host_guard: Default::default(), + store_id: String::new(), + }; + let resp = route(&self.uteke, &ctx, &mut req); + let status = resp.status_code().0; + let bytes = resp.into_reader().into_inner(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + + fn author_of(&self, id: &str) -> Value { + let (status, memory) = self.call(Method::Get, &format!("/memory?id={id}"), None); + assert_eq!(status, 200, "{memory}"); + memory["author"].clone() + } +} + +#[test] +fn remember_takes_an_author_without_a_room_and_returns_it() { + let app = App::new(); + let (status, body) = app.call( + Method::Post, + "/remember", + Some(json!({"content": "attributed note", "author": " claude-code "})), + ); + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["author"], "claude-code", + "the response echoes the trimmed author" + ); + let id = body["id"].as_str().unwrap(); + assert_eq!(app.author_of(id), "claude-code", "GET /memory returns it"); + + // No author: nothing recorded, nothing echoed, nothing serialized. + let (_, plain) = app.call( + Method::Post, + "/remember", + Some(json!({"content": "plain note"})), + ); + assert!(plain.get("author").is_none(), "{plain}"); + assert_eq!(app.author_of(plain["id"].as_str().unwrap()), Value::Null); + + // It also reaches /list. + let (_, listed) = app.call(Method::Post, "/list", Some(json!({"limit": 10}))); + let authors: Vec = listed + .as_array() + .unwrap() + .iter() + .map(|m| m["author"].clone()) + .collect(); + assert_eq!( + authors.iter().filter(|a| *a == "claude-code").count(), + 1, + "{listed}" + ); +} + +#[test] +fn an_invalid_author_is_a_400_and_stores_nothing() { + let app = App::new(); + for bad in [ + json!(""), + json!(" "), + json!("tab\there"), + json!("y".repeat(129)), + ] { + let (status, body) = app.call( + Method::Post, + "/remember", + Some(json!({"content": "must not be stored", "author": bad})), + ); + assert_eq!(status, 400, "{bad}: {body}"); + assert!(body.to_string().contains("author"), "{bad}: {body}"); + } + let (_, listed) = app.call(Method::Post, "/list", Some(json!({"limit": 10}))); + assert_eq!( + listed.as_array().unwrap().len(), + 0, + "an invalid author stores nothing" + ); +} + +#[test] +fn room_remember_stamps_the_writer_on_the_memory() { + let app = App::new(); + let (status, named) = app.call( + Method::Post, + "/room/remember", + Some(json!({"room_id": "r1", "content": "from alice", "author": "alice"})), + ); + assert_eq!(status, 200, "{named}"); + assert_eq!(app.author_of(named["id"].as_str().unwrap()), "alice"); + + // No author named: the room link falls back to "user", the memory has none. + let (_, unnamed) = app.call( + Method::Post, + "/room/remember", + Some(json!({"room_id": "r1", "content": "from nobody"})), + ); + assert_eq!(app.author_of(unnamed["id"].as_str().unwrap()), Value::Null); + + // An author the room link accepts but the memory field rejects is not an + // error: the write succeeded, the memory stays unattributed. + let (status, long) = app.call( + Method::Post, + "/room/remember", + Some(json!({"room_id": "r1", "content": "long author", "author": "z".repeat(200)})), + ); + assert_eq!(status, 200, "{long}"); + assert_eq!(app.author_of(long["id"].as_str().unwrap()), Value::Null); +} diff --git a/crates/uteke-server/src/handlers/memory.rs b/crates/uteke-server/src/handlers/memory.rs index 1cf72443..b9301243 100644 --- a/crates/uteke-server/src/handlers/memory.rs +++ b/crates/uteke-server/src/handlers/memory.rs @@ -66,26 +66,35 @@ pub(super) fn post_remember(c: &mut Call) -> Response>> { return ctx.error_response_for(req, 400, e.to_string()); } } + // Set the memory's own author after storage (#1427 phase 2). + // Already validated and trimmed at decode. + if let Some(a) = req_data.author.as_deref() { + if let Err(e) = uteke.set_author(&id, Some(a)) { + error!("Failed to set author for {id}: {e}"); + } + } // Echo author_type so clients can confirm what was // recorded (#1106) — data was already stored correctly, // the response just omitted the field. Default mirrors // the schema default (author_type DEFAULT 'agent', #1083). // #1273: honest embedding status — never a bare success // when the vector write failed. - ctx.ok_response_for( - req, - &serde_json::json!({ - "id": id, - "author_type": req_data.author_type.clone().unwrap_or_else(|| "agent".to_string()), - "embedding_written": outcome.embedding_written, - "warning": uteke_core::namespace_deprecation::merge_warning( - outcome.warning, - uteke_core::namespace_deprecation::namespace_write_warning( - ns(&req_data.namespace), - ), + let mut body = serde_json::json!({ + "id": id, + "author_type": req_data.author_type.clone().unwrap_or_else(|| "agent".to_string()), + "embedding_written": outcome.embedding_written, + "warning": uteke_core::namespace_deprecation::merge_warning( + outcome.warning, + uteke_core::namespace_deprecation::namespace_write_warning( + ns(&req_data.namespace), ), - }), - ) + ), + }); + // Echo the author when one was recorded (#1427 phase 2). + if let Some(a) = &req_data.author { + body["author"] = serde_json::json!(a); + } + ctx.ok_response_for(req, &body) } Err(e) => { error!("Internal error: {e}"); diff --git a/crates/uteke-server/src/handlers/mod.rs b/crates/uteke-server/src/handlers/mod.rs index 306fcc0e..680d7106 100644 --- a/crates/uteke-server/src/handlers/mod.rs +++ b/crates/uteke-server/src/handlers/mod.rs @@ -24,6 +24,8 @@ use crate::types::*; const API_LATEST: &str = "v2"; const API_VERSIONS: &[&str] = &["v1", "v2"]; +#[cfg(test)] +mod author_tests; mod docs; mod graph; mod health; diff --git a/crates/uteke-server/src/handlers/rooms.rs b/crates/uteke-server/src/handlers/rooms.rs index b330fe18..81370add 100644 --- a/crates/uteke-server/src/handlers/rooms.rs +++ b/crates/uteke-server/src/handlers/rooms.rs @@ -443,20 +443,29 @@ pub(super) fn post_room_remember(c: &mut Call) -> Response>> { &req_data.room_id, author, ) { - Ok(outcome) => ctx.ok_response_for( - req, - &serde_json::json!({ - "id": outcome.id, - "room_id": req_data.room_id, - "embedding_written": outcome.embedding_written, - "warning": uteke_core::namespace_deprecation::merge_warning( - outcome.warning, - uteke_core::namespace_deprecation::namespace_write_warning( - ns(&req_data.namespace), + Ok(outcome) => { + // The named writer is also the memory's own author (#1427 + // phase 2); the "user" fallback of the room link is not. + if let Some(a) = req_data.author.as_deref() + && let Err(e) = uteke.set_author_if_valid(&outcome.id, a) + { + error!("Failed to set author for {}: {e}", outcome.id); + } + ctx.ok_response_for( + req, + &serde_json::json!({ + "id": outcome.id, + "room_id": req_data.room_id, + "embedding_written": outcome.embedding_written, + "warning": uteke_core::namespace_deprecation::merge_warning( + outcome.warning, + uteke_core::namespace_deprecation::namespace_write_warning( + ns(&req_data.namespace), + ), ), - ), - }), - ), + }), + ) + } Err(e) => { error!("room/remember error: {e}"); let status = match e { diff --git a/crates/uteke-server/src/types.rs b/crates/uteke-server/src/types.rs index 0ed1d8ac..51c6f1bb 100644 --- a/crates/uteke-server/src/types.rs +++ b/crates/uteke-server/src/types.rs @@ -216,6 +216,10 @@ pub struct RememberRequest { /// Author type: "human" | "agent" (#1083). Defaults to "agent" when omitted. #[serde(default)] pub author_type: Option, + /// Who wrote the memory (#1427 phase 2): free text, trimmed, 1 to 128 + /// characters. Optional, with or without a room; returned on the memory. + #[serde(default)] + pub author: Option, } impl RememberRequest { @@ -237,12 +241,12 @@ impl RememberRequest { source_type: self.source_type.clone(), author_type: self.author_type.clone(), room: None, - author: None, + author: self.author.clone(), timestamp: None, } } - /// Decode under the HTTP policy: content/tags, `author_type` and `type` + /// Decode under the HTTP policy: content/tags, `author_type`, `author` and `type` /// validated before any write, `type` mirrored into metadata. pub fn decode(&self) -> Result { uteke_core::RememberRequest::decode(self.to_input(), uteke_core::RememberPolicy::HTTP) @@ -1189,7 +1193,8 @@ mod remember_request_cross_surface_tests { let h = http(json!({"content": "x", "entity": "e", "source": "s"})).unwrap(); assert_eq!(h.metadata, Some(json!({"entity": "e"}))); assert_eq!(h.source.as_deref(), Some("s")); - // MCP ignores them; `room` and `author` are MCP-only (HTTP: unknown key). + // MCP ignores them; `room` is MCP-only (HTTP: unknown key). `author` is + // accepted by both (#1427 phase 2). let m = mcp(json!({"content": "x", "entity": "e", "source": "s", "room": "r", "author": "a"})) .unwrap(); @@ -1197,6 +1202,14 @@ mod remember_request_cross_surface_tests { assert_eq!(m.source, None); assert_eq!(m.room.as_deref(), Some("r")); assert_eq!(m.author.as_deref(), Some("a")); + assert_eq!( + http(json!({"content": "x", "author": " a "})) + .unwrap() + .author + .as_deref(), + Some("a"), + "HTTP trims the author" + ); assert!(http(json!({"content": "x", "room": "r"})).is_err()); } @@ -1230,21 +1243,34 @@ mod remember_request_cross_surface_tests { } #[test] - fn cli_room_author_and_timestamp_are_rejected_by_the_server_today() { - for field in ["room", "author", "timestamp"] { + fn cli_room_and_timestamp_are_rejected_by_the_server_today() { + for field in ["room", "timestamp"] { let mut input = uteke_core::RememberInput { content: "x".into(), ..uteke_core::RememberInput::default() }; match field { "room" => input.room = Some("r".into()), - "author" => input.author = Some("a".into()), _ => input.timestamp = Some("t".into()), } let err = http(input.to_http_body()).unwrap_err(); assert!(err.contains(field), "{field}: {err}"); } } + + /// #1427 phase 2: the CLI forwards `--author` and the server accepts it. + #[test] + fn cli_author_is_accepted_by_the_server() { + let input = uteke_core::RememberInput { + content: "x".into(), + author: Some("claude-code".into()), + ..uteke_core::RememberInput::default() + }; + assert_eq!( + http(input.to_http_body()).unwrap().author.as_deref(), + Some("claude-code") + ); + } } /// #1343 (`list` slice): the HTTP and MCP surfaces decode `list` through the diff --git a/docs/api-reference.md b/docs/api-reference.md index dbdbd1f5..2d313b10 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -774,6 +774,8 @@ Invalid values return HTTP 400. | | Field | Type | Required | Description | |-------|------|----------|-------------| +| `author` | any | No | Who wrote the memory (#1427 phase 2): free text, trimmed, 1 to 128 +characters. Optional, with or without a room; returned on the memory. | | `author_type` | any | No | Author type: "human" | "agent" (#1083). Defaults to "agent" when omitted. | | `category` | any | No | Category — stored as metadata key "category". | | `content` | `string` | Yes | | diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c8f1cfcb..a2e43499 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -103,7 +103,7 @@ uteke remember "User prefers dark mode" --tags pref --namespace my-agent | `--source-type ` | Source type: user, url, file, import, derived, system (default: user) | | `--detect-contradiction` | Detect conflicting memories (default threshold: 0.65) | | `--room ` | Link memory to a room (collaborative context) | -| `--author ` | Author attribution when storing in a room | +| `--author ` | Who wrote the memory (1 to 128 characters). Recorded on the memory with or without `--room`; with `--room` it is also the room author (default there: `anonymous`) | | `--json` | Output stored memory as JSON | ## uteke recall