Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion crates/uteke-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ pub enum Commands {
/// Room ID to link this memory to (collaborative context)
#[arg(long)]
room: Option<String>,
/// 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<String>,
/// Source provenance: URL, file path, or identifier (#348)
Expand Down
24 changes: 24 additions & 0 deletions crates/uteke-cli/src/commands/remember.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
99 changes: 99 additions & 0 deletions crates/uteke-cli/tests/author_flag.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
59 changes: 58 additions & 1 deletion crates/uteke-core/src/import_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ impl crate::Uteke {
created_at: m.created_at,
source: m.source,
namespace: m.namespace,
author: m.author,
})
.collect();

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, Option<String>> = 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
Expand Down
12 changes: 12 additions & 0 deletions crates/uteke-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool, Error> {
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<usize, Error> {
let updated = self.store.recompute_importance()?;
Expand Down
4 changes: 4 additions & 0 deletions crates/uteke-core/src/memory/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

fn default_metadata() -> serde_json::Value {
Expand Down
Loading
Loading