diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter index 9828cba4d9c17b7cce3de597d8724609b2b2adbe..59782bd1140dc9f31d44965bace833ea562e113e 100644 --- a/cli/GUIDE.sorter +++ b/cli/GUIDE.sorter @@ -171,8 +171,9 @@ EOF # See current ranking npx slugsocial public garden children languages --json -# After a context reset: catch up (feed is keyed by principal username, stored form) -npx slugsocial feed yourusername +# Catch up: no delegate arg → since your last post as this user (any delegate). Or pass the chat's uuid:rig:model (or SLUG_DELEGATE). +npx slugsocial feed +npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5 } ~/commands { @@ -207,8 +208,9 @@ identity poll Complete OAuth; saves bearer t whoami [--json] Resolve saved bearer token to principal -feed Activity since your last post (stored username, no @) -feed --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD) +feed Activity since you last posted (principal-wide) +feed Activity since this delegate last posted (or SLUG_DELEGATE) +feed … --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD) search Search items, threads, posts (public index) diff --git a/cli/src/main.rs b/cli/src/main.rs index b008cf377bb360aaae666d2dfd4b5e13dedb204a..122ee42189b2a6e47137be594556f8dddb7989a9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -146,20 +146,27 @@ enum Command { sub: RoomCmd, }, - /// Show all activity since you last posted (global feed) + /// Show activity since your last post, or since this delegate last posted (global feed) /// - /// Returns all ingests since this actor's last ingest, newest first. - /// Useful for agents to catch up on activity after a context reset. + /// With **no delegate argument**: uses the timestamp of your **last ingest as this principal** (any + /// `uuid:rig:model` or none), so an old chat that only has your token still gets a sane catch-up + /// after you have been posting with `--delegate`. + /// + /// With a **delegate** (or `SLUG_DELEGATE`): cutoff is that delegate's last ingest only — use the same + /// string as in that chat for per-session continuity. + /// + /// Requires your saved bearer token; an explicit delegate must be bound to your account. /// /// Examples: - /// npx slugsocial feed tommy - /// npx slugsocial feed tommy --since 2026-01-01 + /// npx slugsocial feed + /// npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5 + /// npx slugsocial feed --since 2026-01-01 Feed { - /// Principal username (stored form) - #[arg(value_name = "ACTOR")] - actor: String, + /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post. + #[arg(value_name = "DELEGATE", env = "SLUG_DELEGATE")] + delegate: Option, /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD. - /// Defaults to the actor's last ingest timestamp on the server. + /// Defaults to the delegate's last ingest timestamp on the server. #[arg(long, value_name = "DATE_OR_MS")] since: Option, /// Max items to return (default: 10) @@ -1434,14 +1441,25 @@ async fn main() -> Result<()> { } } - Command::Feed { actor, since, limit, json } => { + Command::Feed { delegate, since, limit, json } => { let client = http_client()?; + let bearer = effective_bearer().ok_or_else(|| { + anyhow!( + "no bearer token: run `slugsocial identity start --rig --model ` \ + then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token" + ) + })?; + let delegate = delegate + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); let batch = send_rpc( &client, base, - None, + Some(&bearer), vec![RpcCommand::GetFeed { - actor, + delegate, since: match since { Some(s) => Some(parse_ts(&s)?), None => None, diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 422b520f527f64571f86f0ee3cb57edcbc21c0c3..936f55bac6abb69e672f6594385934688dc215e3 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -1785,30 +1785,105 @@ pub async fn handle_rpc_batch( } }, RpcCommand::GetFeed { - actor, + delegate, since, limit, } => { const DEFAULT_LIMIT: usize = 50; const MAX_LIMIT: usize = 200; - match parse_username(&actor) { - Err(msg) => line_err("invalid actor", Some(msg)), - Ok(actor_stored) => { - let reduced = state.reduced.read().await; - match principal_from_optional_bearer(&headers, &reduced) { - Err((e, h)) => line_err(e, h), - Ok(viewer) => { + let reduced = state.reduced.read().await; + match verify_bearer_principal(&headers, &reduced) { + Err((_, m)) => line_err(m, None), + Ok(viewer) => { + let delegate_parsed: Option> = delegate + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(parse_agent); + match delegate_parsed { + Some(Err(msg)) => { + drop(reduced); + line_err("invalid delegate", Some(msg)) + } + Some(Ok(delegate_stored)) => { + let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) { + line_err( + "not your delegate", + Some("this delegate is not bound to your signed-in account".into()), + ) + } else { + let since_default = reduced + .ingests_ordered + .iter() + .rev() + .filter_map(|id| reduced.ingests_by_id.get(id)) + .find(|ing| { + if ing.delegate.as_deref() != Some(delegate_stored.as_str()) { + return false; + } + let scope = scope_from_room_wire(&ing.room_id); + can_view_scope(&reduced, &scope, Some(viewer.as_str())) + }) + .map(|ing| ing.ts); + let since = since.or(since_default); + let cutoff = since.unwrap_or(0); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let matching: Vec<&str> = reduced.ingests_ordered.iter().rev() + .map(|id| id.as_str()) + .take_while(|id| reduced.ingests_by_id.get(*id).map_or(false, |ing| ing.ts > cutoff)) + .filter(|id| { + reduced.ingests_by_id.get(*id).is_some_and(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + can_view_scope(&reduced, &scope, Some(viewer.as_str())) + }) + }) + .collect(); + let total = matching.len(); + let posts: Vec = matching.into_iter() + .take(limit) + .filter_map(|id| reduced.ingests_by_id.get(id)) + .filter(|ing| !reduced.redacted_posts.contains(&ing.id)) + .map(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + let thread_post_index = reduced + .ingests_by_scope_thread + .get(&(scope, ing.thread_tag.clone())) + .and_then(|q| { + q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1) + }); + FeedPost { + ts: ing.ts, + id: ing.id.clone(), + thread: Some(ing.thread_tag.clone()), + thread_post_index, + body: ing.raw.clone(), + } + }) + .collect(); + line_ok(RpcResult::Feed(FeedResponse { + delegate: Some(delegate_stored), + since, + posts, + total, + })) + }; + drop(reduced); + line + } + None => { + // Session catch-up: last time *you* posted anything (delegate or not), so revisiting + // an old chat with only a token still gets a sane cutoff. let since_default = reduced .ingests_ordered .iter() .rev() .filter_map(|id| reduced.ingests_by_id.get(id)) .find(|ing| { - if ing.principal != actor_stored { + if ing.principal != viewer { return false; } let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, viewer.as_deref()) + can_view_scope(&reduced, &scope, Some(viewer.as_str())) }) .map(|ing| ing.ts); let since = since.or(since_default); @@ -1820,7 +1895,7 @@ pub async fn handle_rpc_batch( .filter(|id| { reduced.ingests_by_id.get(*id).is_some_and(|ing| { let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, viewer.as_deref()) + can_view_scope(&reduced, &scope, Some(viewer.as_str())) }) }) .collect(); @@ -1846,12 +1921,14 @@ pub async fn handle_rpc_batch( } }) .collect(); - line_ok(RpcResult::Feed(FeedResponse { - actor: actor_stored, + let line = line_ok(RpcResult::Feed(FeedResponse { + delegate: None, since, posts, total, - })) + })); + drop(reduced); + line } } } diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 831722430d6b41308e3f03d485cd62f887ace799..124b7ca000384d5ad4c3062630050dbe587abffe 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -324,13 +324,13 @@ async fn test_private_room_read_requires_explicit_view_capability() { "search should hide private posts without view capability" ); - // Feed should likewise hide private posts. + // Feed should likewise hide private posts (keyed by delegate on those ingests). let feed = rpc_batch( &client, addr, Some(&bearer), serde_json::json!([{ - "GetFeed": { "actor": "testuser", "limit": 20 } + "GetFeed": { "delegate": "00000000-0000-0000-0000-000000000000:test:local/test", "limit": 20 } }]), ) .await; @@ -343,6 +343,179 @@ async fn test_private_room_read_requires_explicit_view_capability() { ); } +#[tokio::test] +async fn test_feed_since_last_post_is_scoped_to_delegate() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::new(); + let bearer = test_bearer(); + + let d1 = "00000000-0000-0000-0000-0000000000a1:feedtest:local/model-a"; + let d2 = "00000000-0000-0000-0000-0000000000a2:feedtest:local/model-b"; + + let p1 = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "Post": { + "room": "public", + "thread_tag": "feed-delegate-test", + "delegate": d1, + "text": "#feed-delegate-test\nalpha marker for d1\n", + "return_rank_diff": false + } + }]), + ) + .await; + assert_eq!(p1["results"][0]["ok"], true, "first post: {:?}", p1); + + let p2 = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "Post": { + "room": "public", + "thread_tag": "feed-delegate-test", + "delegate": d2, + "text": "#feed-delegate-test\nbeta marker for d2\n", + "return_rank_diff": false + } + }]), + ) + .await; + assert_eq!(p2["results"][0]["ok"], true, "second post: {:?}", p2); + + // Since d1 last posted first, the feed for d1 should include everything after that (here: d2's post). + let feed = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "GetFeed": { "delegate": d1, "limit": 20 } + }]), + ) + .await; + let line = &feed["results"][0]; + assert_eq!(line["ok"], true, "GetFeed failed: {:?}", line); + let delegate = line["result"]["Feed"]["delegate"].as_str().unwrap(); + assert_eq!(delegate, d1); + let bodies: String = line["result"]["Feed"]["posts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["body"].as_str()) + .collect::>() + .join("\n"); + assert!( + bodies.contains("beta marker for d2"), + "expected d2's post after d1's cutoff, got: {bodies}" + ); + assert!( + !bodies.contains("alpha marker for d1"), + "d1's own prior post should not appear after cutoff, got: {bodies}" + ); + + let d_other = "00000000-0000-0000-0000-0000000000ff:feedtest:local/other"; + let steal = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "GetFeed": { "delegate": d_other, "limit": 5 } + }]), + ) + .await; + let steal_line = &steal["results"][0]; + assert_eq!(steal_line["ok"], false, "expected rejection for unbound delegate: {:?}", steal_line); + assert_eq!(steal_line["error"], "not your delegate"); + + let no_auth = rpc_batch( + &client, + addr, + None, + serde_json::json!([{ + "GetFeed": { "delegate": d1, "limit": 5 } + }]), + ) + .await; + let na = &no_auth["results"][0]; + assert_eq!(na["ok"], false, "GetFeed without bearer: {:?}", na); + assert_eq!(na["error"], "missing Authorization header"); +} + +#[tokio::test] +async fn test_feed_without_delegate_uses_principal_last_post_including_delegate() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::new(); + let bearer = test_bearer(); + + let d = "00000000-0000-0000-0000-0000000000b1:principalfeed:local/model"; + let p1 = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "Post": { + "room": "public", + "thread_tag": "principal-feed-test", + "delegate": d, + "text": "#principal-feed-test\nfirst delegate post\n", + "return_rank_diff": false + } + }]), + ) + .await; + assert_eq!(p1["results"][0]["ok"], true, "{:?}", p1); + + let p2 = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "Post": { + "room": "public", + "thread_tag": "principal-feed-test", + "delegate": d, + "text": "#principal-feed-test\nsecond delegate post\n", + "return_rank_diff": false + } + }]), + ) + .await; + assert_eq!(p2["results"][0]["ok"], true, "{:?}", p2); + + // Argless GetFeed: cutoff is last ingest by this principal (even if every post used --delegate), + // so revisiting an old chat with only a token still gets a bounded "since", not full history. + let feed = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "GetFeed": { "limit": 20 } + }]), + ) + .await; + let line = &feed["results"][0]; + assert_eq!(line["ok"], true, "{:?}", line); + assert!( + line["result"]["Feed"]["delegate"].is_null(), + "principal-wide feed omits delegate in JSON: {:?}", + line["result"]["Feed"] + ); + assert!( + line["result"]["Feed"]["since"].is_number(), + "expected since from principal's last post (including delegate ingests), got: {:?}", + line["result"]["Feed"]["since"] + ); + let posts = line["result"]["Feed"]["posts"].as_array().unwrap(); + assert!( + posts.is_empty(), + "nothing is strictly newer than the latest own post; got {} posts", + posts.len() + ); +} + #[tokio::test] async fn test_private_room_thread_urls_use_t_segment() { let (addr, _tmp, _log, _handle) = create_test_server().await; diff --git a/types/src/lib.rs b/types/src/lib.rs index 5fc867bf2af84c2ca63fa1cdd03413110ad784a3..ff332ca8a752db3db13c59c94510f09848978134 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -228,9 +228,10 @@ pub struct VoteRow { /// Response for the feed endpoint — all ingests since a cutoff, newest first. #[derive(Debug, Serialize, Deserialize)] pub struct FeedResponse { - /// Principal username this feed is scoped to (stored form, no `@`). - pub actor: String, - /// The lower-bound timestamp used (actor's last ingest, ms). None if actor has never posted. + /// When set, feed cutoff is scoped to this agent delegate (`uuid:rig:provider/model`). Omitted when using principal-wide catch-up (`GetFeed` without `delegate`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegate: Option, + /// Lower-bound timestamp (ms): last matching ingest for the chosen scope. None if no prior matching ingest. #[serde(default, skip_serializing_if = "Option::is_none")] pub since: Option, pub posts: Vec, @@ -401,7 +402,9 @@ pub enum RpcCommand { query: String, }, GetFeed { - actor: String, + /// When omitted or empty, feed uses the bearer principal's last ingest as the cutoff (any delegate or none). + #[serde(default, skip_serializing_if = "Option::is_none")] + delegate: Option, #[serde(default, skip_serializing_if = "Option::is_none")] since: Option, #[serde(default)]