diff --git a/packages/browseros-agent/apps/claw-app/components/audit/FilterBar.tsx b/packages/browseros-agent/apps/claw-app/components/audit/FilterBar.tsx index 4c495e878c..50c39d0e0c 100644 --- a/packages/browseros-agent/apps/claw-app/components/audit/FilterBar.tsx +++ b/packages/browseros-agent/apps/claw-app/components/audit/FilterBar.tsx @@ -192,7 +192,7 @@ export function FilterBar({ setLocalSearch(e.target.value)} - placeholder="search sessions..." + placeholder="search sessions, summaries..." // pr-7 reserves space for the inline clear button so the // text never sits under the icon. className="h-8 w-64 rounded-9 border-none bg-card pr-7 pl-8 font-mono text-[13px] text-ink shadow-xs placeholder:text-ink-3 focus-visible:ring-0" diff --git a/packages/browseros-agent/apps/claw-app/components/audit/TaskHeader.tsx b/packages/browseros-agent/apps/claw-app/components/audit/TaskHeader.tsx index e3e27ae8f1..9953da559b 100644 --- a/packages/browseros-agent/apps/claw-app/components/audit/TaskHeader.tsx +++ b/packages/browseros-agent/apps/claw-app/components/audit/TaskHeader.tsx @@ -70,6 +70,11 @@ export function TaskHeader({ detail }: TaskHeaderProps) {

{task.name}

+ {task.taskSummary && ( +

+ {task.taskSummary} +

+ )} diff --git a/packages/browseros-agent/apps/claw-app/screens/audit/Audit.test.tsx b/packages/browseros-agent/apps/claw-app/screens/audit/Audit.test.tsx index ab33735b1d..6d91a48060 100644 --- a/packages/browseros-agent/apps/claw-app/screens/audit/Audit.test.tsx +++ b/packages/browseros-agent/apps/claw-app/screens/audit/Audit.test.tsx @@ -123,6 +123,23 @@ describe('Audit screen', () => { // (LIVE / FAILED / STOPPED) render a chip in the agent cell. }) + it('renders the task summary snippet under the target when present', () => { + dataOverride = { + ...baseData, + tasks: [ + { + ...sampleTask, + taskSummary: + 'Compared two invoicing tools and noted their pricing tiers.', + }, + ], + } + const html = renderApp() + expect(html).toContain( + 'Compared two invoicing tools and noted their pricing tiers.', + ) + }) + it('hides token usage from the task list', () => { dataOverride = { ...baseData, diff --git a/packages/browseros-agent/apps/claw-app/screens/audit/audit.columns.tsx b/packages/browseros-agent/apps/claw-app/screens/audit/audit.columns.tsx index 95ba4e9f7f..eb4cab9d15 100644 --- a/packages/browseros-agent/apps/claw-app/screens/audit/audit.columns.tsx +++ b/packages/browseros-agent/apps/claw-app/screens/audit/audit.columns.tsx @@ -47,9 +47,16 @@ export const TASK_COLUMNS: ColumnDef[] = [ // the one cell a reader scans rather than skims, and the design calls // for the platform UI face at reading size here. cell: ({ row }) => ( - - {row.original.name} - +
+ + {row.original.name} + + {row.original.taskSummary && ( + + {row.original.taskSummary} + + )} +
), enableSorting: false, }, diff --git a/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.test.tsx b/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.test.tsx index 1367b9e19d..fa835a8ea0 100644 --- a/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.test.tsx +++ b/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.test.tsx @@ -124,6 +124,23 @@ describe('TaskDetailPage', () => { expect(html).not.toContain('/audit/screenshot/2') }) + it('renders the task summary in the header when present', () => { + dataOverride = { + ...baseData, + detail: { + ...sampleTask, + session: { + ...sampleTask.session, + taskSummary: 'Checked warranty terms across three retailer pages.', + }, + }, + } + const html = render() + expect(html).toContain( + 'Checked warranty terms across three retailer pages.', + ) + }) + it('renders the total token consumption on the summary card', () => { dataOverride = { ...baseData, detail: sampleTask } const html = render() diff --git a/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.tsx b/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.tsx index 3105f1af1c..f6506ef5c6 100644 --- a/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.tsx +++ b/packages/browseros-agent/apps/claw-app/screens/task-detail/TaskDetailPage.tsx @@ -121,6 +121,11 @@ export function TaskDetailPage() { items={items} defaultId={pickDefaultTabId(groups)} listVariant="line" + // Many-tab sessions (one tab per browser page) overflow the fixed + // page width; scroll the strip horizontally instead of spilling + // off-screen, keep each trigger at its natural width, and hide the + // scrollbar so the strip scrolls cleanly with no visible track. + listClassName="w-full max-w-full justify-start overflow-x-auto [&_button]:shrink-0 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" /> Self { - Self { name, kind } + Self { + name, + kind, + optional: false, + } + } + + const fn optional(name: &'static str, kind: PropertyKind) -> Self { + Self { + name, + kind, + optional: true, + } } fn normalize(self, value: &Value) -> Option { @@ -125,6 +149,15 @@ impl PropertyDefinition { }; Some(Value::String(category.to_string())) } + PropertyKind::TaskSummary => { + // The one free-text property: the agent-authored summary is already + // PII-scrubbed and length-capped upstream; here it is only bounded + // defensively and passed through as-is. + let raw = value.as_str()?; + Some(Value::String( + raw.chars().take(TASK_SUMMARY_MAX_CHARS).collect(), + )) + } PropertyKind::Harness => normalize_token(value, &HARNESS_VALUES), PropertyKind::EndKind => normalize_token(value, &END_KIND_VALUES), PropertyKind::ToolName => { @@ -193,8 +226,19 @@ impl EventDefinition { let input = properties.as_object()?; let mut output = Map::new(); for property in self.properties { - let value = input.get(property.name)?; - output.insert(property.name.to_string(), property.normalize(value)?); + let Some(value) = input.get(property.name) else { + if property.optional { + continue; + } + return None; + }; + let Some(normalized) = property.normalize(value) else { + if property.optional { + continue; + } + return None; + }; + output.insert(property.name.to_string(), normalized); } Some(Value::Object(output)) } @@ -204,10 +248,11 @@ impl EventDefinition { properties: &HashMap, ) -> bool { self.properties.iter().all(|property| { - let Some(current) = properties.get(property.name) else { - return false; - }; - property.normalize(current).as_ref() == Some(current) + match properties.get(property.name) { + Some(current) => property.normalize(current).as_ref() == Some(current), + // Absent is acceptable only for optional properties. + None => property.optional, + } }) } @@ -229,6 +274,7 @@ pub const AGENT_SESSION_TASK_DECLARED: EventDefinition = EventDefinition::new( &[ PropertyDefinition::new(TASK_CATEGORY, PropertyKind::TaskCategory), PropertyDefinition::new(CLIENT_NAME, PropertyKind::ClientName), + PropertyDefinition::optional(TASK_SUMMARY, PropertyKind::TaskSummary), ], ); pub const AGENT_SESSION_ENDED: EventDefinition = EventDefinition::new( @@ -375,7 +421,7 @@ mod tests { ); assert_eq!( AGENT_SESSION_TASK_DECLARED.property_names(), - vec!["task_category", "client_name"] + vec!["task_category", "client_name", "task_summary"] ); assert_eq!( AGENT_SESSION_ENDED.property_names(), @@ -521,6 +567,47 @@ mod tests { ); } + #[test] + fn task_declared_passes_through_the_free_text_summary() { + assert_eq!( + AGENT_SESSION_TASK_DECLARED.sanitize(&json!({ + "task_category": "shopping", + "client_name": "cursor", + "task_summary": "Compared warranty terms across three retailers.", + })), + Some(json!({ + "task_category": "shopping", + "client_name": "cursor", + "task_summary": "Compared warranty terms across three retailers.", + })) + ); + } + + #[test] + fn task_declared_still_sends_when_the_optional_summary_is_absent() { + assert_eq!( + AGENT_SESSION_TASK_DECLARED + .sanitize(&json!({ "task_category": "research", "client_name": "codex" })), + Some(json!({ "task_category": "research", "client_name": "codex" })) + ); + } + + #[test] + fn task_summary_is_capped_at_the_boundary() { + let long = "x".repeat(TASK_SUMMARY_MAX_CHARS + 50); + let sanitized = AGENT_SESSION_TASK_DECLARED.sanitize(&json!({ + "task_category": "other", + "client_name": "codex", + "task_summary": long, + })); + let summary = sanitized + .as_ref() + .and_then(|value| value.get("task_summary")) + .and_then(Value::as_str) + .unwrap_or_default(); + assert_eq!(summary.chars().count(), TASK_SUMMARY_MAX_CHARS); + } + #[test] fn unlisted_client_names_surface_their_slug() { for (raw, expected) in [ diff --git a/packages/browseros-agent/apps/claw-server-rust/src/api/http/sessions.rs b/packages/browseros-agent/apps/claw-server-rust/src/api/http/sessions.rs index 84fccbbb19..d9c318b409 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/api/http/sessions.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/api/http/sessions.rs @@ -216,6 +216,7 @@ async fn contract_summary(task: TaskSummary, live: Option<&Arc>) -> Ses .and_then(|session| session.agent().profile_id()) .map(|profile_id| profile_id.as_str().to_string()); summary.site = task.site; + summary.task_summary = task.task_summary; summary.ended_at = task.ended_at; summary.latest_screenshot_id = task.last_screenshot_dispatch_id; summary.token_usage = token_usage; @@ -249,6 +250,7 @@ fn contract_live_projection(projection: LiveSessionProjection) -> SessionSummary summary.harness = harness; summary.color = Some(color); summary.site = task.site; + summary.task_summary = task.task_summary; summary.ended_at = task.ended_at; summary.latest_screenshot_id = task.last_screenshot_dispatch_id; summary.token_usage = token_usage; diff --git a/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/prompt.rs b/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/prompt.rs index 3532120088..2fbcc5ea5b 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/prompt.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/prompt.rs @@ -22,8 +22,9 @@ Shared with other agents: tabs action="new" and work on that copy; leave the original untouched. - Preserve useful pages: leave anything the user may want to inspect open instead of closing it when the task ends. -- Name your session early with name_session: a 2-3 word task label plus the - category that best fits the task; tabs group as /. +- Name your session early with name_session: a 2-3 word task label, the category + that best fits the task, and a short PII-free summary you can search for later; + tabs group as /. - The user oversees this browser from the BrowserOS neo cockpit (live view, audit, replay). diff --git a/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs b/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs index 6c94d7f463..bf1c5f4e14 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs @@ -46,8 +46,10 @@ use uuid::Uuid; const SERVER_NAME: &str = "browseros-neo"; const SERVER_TITLE: &str = "BrowserOS neo"; const NAME_SESSION_TOOL_NAME: &str = "name_session"; -const NAME_SESSION_DESCRIPTION: &str = "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", plus a `category` for the kind of task. Tabs are grouped as /; the label stays on this machine and only the category is used for anonymous aggregate analytics. Call again to update."; +const NAME_SESSION_DESCRIPTION: &str = "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as /; the label stays on this machine, the summary powers audit search and is also recorded for analytics, and the category is used for anonymous aggregate analytics. Call again to update."; const NAME_SESSION_CATEGORY_DESCRIPTION: &str = "The kind of task, for anonymous aggregate analytics only; the free-form name is never sent. Pick the closest fit from the list."; +const NAME_SESSION_SUMMARY_DESCRIPTION: &str = "One or two short lines saying what this task is, phrased so you can find it again by searching later. No names, emails, URLs, file paths, or account numbers."; +const SUMMARY_MAX_LEN: usize = 200; const NAME_SESSION_INPUT_MAX_LEN: usize = 64; const SESSION_ARG_DESCRIPTION: &str = "Opaque session handle returned by the server. Pass it back on every call to keep working in the same browser session; omit it to start a new session."; const SAVE_SKILL_TOOL_NAME: &str = "save_skill"; @@ -144,6 +146,14 @@ impl ClawMcpService { .into_call_tool_result(); } }; + // Scrub structural PII from any provided summary before it is stored, indexed for + // search, or recorded on the task-declared analytics event. Last write wins locally. + let scrubbed_summary = raw_args + .get("summary") + .and_then(Value::as_str) + .map(str::trim) + .filter(|summary| !summary.is_empty()) + .map(scrub_summary); if let Some(category) = raw_args .get("category") .and_then(Value::as_str) @@ -153,15 +163,34 @@ impl ClawMcpService { // At most once per session: a later name_session rename must not // re-declare and overcount the category mix or the declaration rate. if started.session.try_mark_task_declared() { + let mut properties = json!({ + "task_category": category, + "client_name": started.session.client_name(), + }); + // The scrubbed summary rides along with the category declaration; the + // analytics layer bounds it defensively before it leaves the machine. + if let Some(summary) = scrubbed_summary + .as_deref() + .filter(|summary| !summary.is_empty()) + { + properties["task_summary"] = Value::String(summary.to_string()); + } self.state.analytics.capture( crate::analytics::events::AGENT_SESSION_TASK_DECLARED, - json!({ - "task_category": category, - "client_name": started.session.client_name(), - }), + properties, ); } } + if let Some(clean) = scrubbed_summary.as_deref() + && !clean.is_empty() + && let Err(error) = self + .state + .audit_log + .set_task_summary(started.session.id().as_str(), clean) + .await + { + warn!(error = %error, "failed to store task summary"); + } let browser = self.state.browser.session().await; apply_agent_tab_group_title( browser.as_ref(), @@ -172,13 +201,19 @@ impl ClawMcpService { ) .await; let result = ToolResult::text(rename.response, None); + // The audit dispatch persists the raw tool arguments; substitute the scrubbed + // summary so the unsanitized text never reaches the audit detail timeline. + let dispatch_args = match scrubbed_summary.as_deref() { + Some(clean) => with_scrubbed_summary(raw_args, clean), + None => raw_args.clone(), + }; if let Err(error) = record_local_tool_dispatch( &self.state, LocalToolDispatch { session: &started.session, agent_label: &started.agent_label, tool_name: NAME_SESSION_TOOL_NAME, - raw_args, + raw_args: &dispatch_args, result: &result, duration_ms: i64::try_from(started_at.elapsed().as_millis()).unwrap_or(i64::MAX), dispatch_id: dispatch_id.clone(), @@ -659,6 +694,62 @@ async fn rename_session( }) } +/// Best-effort structural PII scrub for an agent-provided task summary before it is +/// stored and indexed for search: drops any whitespace token that looks like an email, +/// URL, file path, bare domain/filename, or a long digit run (phone / card / account +/// number). Collapses whitespace and caps the length. Free prose and names are kept; +/// the agent is instructed to omit those, and the summary never leaves this machine. +fn scrub_summary(raw: &str) -> String { + let scrubbed = raw + .split_whitespace() + .filter(|token| !is_pii_token(token)) + .collect::>() + .join(" "); + if scrubbed.chars().count() > SUMMARY_MAX_LEN { + scrubbed + .chars() + .take(SUMMARY_MAX_LEN) + .collect::() + .trim_end() + .to_string() + } else { + scrubbed + } +} + +/// Clones the tool arguments with the `summary` field replaced by its already-scrubbed +/// form, so the audit dispatch timeline persists the sanitized summary rather than the raw +/// one the scrubber removed from `tasks.task_summary` and the search index. +fn with_scrubbed_summary(raw_args: &Value, clean: &str) -> Value { + let mut owned = raw_args.clone(); + if let Some(object) = owned.as_object_mut() { + object.insert("summary".to_string(), Value::String(clean.to_string())); + } + owned +} + +fn is_pii_token(token: &str) -> bool { + let lower = token.to_ascii_lowercase(); + if token.contains('@') + || lower.contains("://") + || lower.starts_with("www.") + || token.contains('/') + || token.contains('\\') + { + return true; + } + if token.chars().filter(|c| c.is_ascii_digit()).count() >= 7 { + return true; + } + // bare domains / filenames: example.com, crm.internal.acme.com, report.pdf + if let Some((prefix, suffix)) = lower.rsplit_once('.') { + return !prefix.is_empty() + && (2..=24).contains(&suffix.len()) + && suffix.chars().all(|c| c.is_ascii_alphabetic()); + } + false +} + fn name_session_tool() -> Tool { let Value::Object(input_schema) = json!({ "type": "object", @@ -668,6 +759,11 @@ fn name_session_tool() -> Tool { "type": "string", "enum": crate::analytics::events::TASK_CATEGORY_VALUES, "description": NAME_SESSION_CATEGORY_DESCRIPTION + }, + "summary": { + "type": "string", + "maxLength": SUMMARY_MAX_LEN, + "description": NAME_SESSION_SUMMARY_DESCRIPTION } }, "required": ["name"] @@ -1119,7 +1215,7 @@ mod tests { assert!(instructions.contains("BrowserOS neo — the browser for agents")); assert!(instructions.contains("Reach for run first")); assert!(instructions.contains( - "- Name your session early with name_session: a 2-3 word task label plus the\n category that best fits the task; tabs group as /." + "- Name your session early with name_session: a 2-3 word task label, the category\n that best fits the task, and a short PII-free summary you can search for later;\n tabs group as /." )); assert!(instructions.contains( "- If the user points you at a tab you don't own, open its URL with\n tabs action=\"new\" and work on that copy; leave the original untouched." @@ -1185,6 +1281,11 @@ mod tests { "enum": crate::analytics::events::TASK_CATEGORY_VALUES, "description": NAME_SESSION_CATEGORY_DESCRIPTION }, + "summary": { + "type": "string", + "maxLength": SUMMARY_MAX_LEN, + "description": NAME_SESSION_SUMMARY_DESCRIPTION + }, "session": { "type": "string", "description": SESSION_ARG_DESCRIPTION } }, "required": ["name"] @@ -1202,6 +1303,40 @@ mod tests { Ok(()) } + #[test] + fn scrub_summary_drops_structural_pii_and_keeps_prose() { + let raw = "Downloaded invoices for john@acme.com from \ + https://billing.acme.com/portal ref 4155551234 saved to /home/user/out.pdf"; + let clean = scrub_summary(raw); + assert!(!clean.contains('@')); + assert!(!clean.contains("://")); + assert!(!clean.contains('/')); + assert!(!clean.contains("4155551234")); + assert!(!clean.to_ascii_lowercase().contains("acme.com")); + assert!(clean.contains("Downloaded")); + assert!(clean.contains("invoices")); + } + + #[test] + fn scrub_summary_caps_length() { + let raw = "word ".repeat(200); + assert!(scrub_summary(&raw).chars().count() <= SUMMARY_MAX_LEN); + } + + #[test] + fn with_scrubbed_summary_replaces_summary_and_keeps_other_args() { + let raw = "Emailed john@acme.com the invoices"; + let clean = scrub_summary(raw); + let sanitized = + with_scrubbed_summary(&json!({ "name": "invoice sync", "summary": raw }), &clean); + // The recorded dispatch args carry the scrubbed copy, never the raw one. + assert_eq!(sanitized["summary"].as_str(), Some(clean.as_str())); + assert!(!clean.contains('@')); + assert!(!clean.to_ascii_lowercase().contains("acme.com")); + // Unrelated arguments are preserved verbatim. + assert_eq!(sanitized["name"].as_str(), Some("invoice sync")); + } + #[tokio::test] async fn save_skill_is_registered_locally_with_annotations() -> anyhow::Result<()> { let call = crate::api::mcp::test_support::tool_call("tabs", json!({})).await?; diff --git a/packages/browseros-agent/apps/claw-server-rust/src/db/audit_log.rs b/packages/browseros-agent/apps/claw-server-rust/src/db/audit_log.rs index b9eb78e119..2d8af06b9b 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/db/audit_log.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/db/audit_log.rs @@ -149,6 +149,8 @@ pub struct TaskSummary { pub tool_input_token_estimate: i64, pub tool_output_token_estimate: i64, pub tokens_measured: bool, + /// Agent-declared, PII-scrubbed summary of the task; None when never declared. + pub task_summary: Option, } impl From for TaskSummary { @@ -175,6 +177,7 @@ impl From for TaskSummary { tool_input_token_estimate: model.tool_input_token_estimate, tool_output_token_estimate: model.tool_output_token_estimate, tokens_measured: model.tokens_measured, + task_summary: model.task_summary, } } } @@ -389,6 +392,32 @@ impl AuditLog { Ok(()) } + /// Stores the agent-declared, already-scrubbed task summary and refreshes the + /// FTS5 search index for the session. Last write wins. + pub async fn set_task_summary(&self, session_id: &str, summary: &str) -> AppResult<()> { + let txn = self.db.connection().begin().await?; + txn.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "UPDATE tasks SET task_summary = ? WHERE session_id = ?", + [summary.to_owned().into(), session_id.to_owned().into()], + )) + .await?; + txn.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "DELETE FROM task_search WHERE session_id = ?", + [session_id.to_owned().into()], + )) + .await?; + txn.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + "INSERT INTO task_search(session_id, summary) VALUES (?, ?)", + [session_id.to_owned().into(), summary.to_owned().into()], + )) + .await?; + txn.commit().await?; + Ok(()) + } + /// Records a session end and refreshes its task summary atomically. pub async fn record_session_end( &self, @@ -453,9 +482,15 @@ impl AuditLog { pub async fn list_tasks(&self, query: ListTasksQuery) -> AppResult { let limit = query.limit.unwrap_or(25).clamp(1, 100); let page_size = usize::try_from(limit).unwrap_or(100); + // Full-text hits over the task summary, resolved to the session_ids the FTS5 + // index matched, so the summary participates in the same OR filter as the fields. + let summary_matches = match query.search.as_deref() { + Some(search) => fts_search_session_ids(self.db.connection(), search).await?, + None => Vec::new(), + }; let search_condition = query.search.map(|search| { let pattern = format!("%{}%", search.to_ascii_lowercase()); - Condition::any() + let mut any = Condition::any() .add(Func::lower(Expr::col(tasks::Column::Title)).like(pattern.clone())) .add(Func::lower(Expr::col(tasks::Column::AgentLabel)).like(pattern.clone())) .add( @@ -464,7 +499,11 @@ impl AuditLog { Expr::value(""), ])) .like(pattern), - ) + ); + if !summary_matches.is_empty() { + any = any.add(tasks::Column::SessionId.is_in(summary_matches)); + } + any }); let condition = Condition::all() .add(tasks::Column::DispatchCount.gt(0)) @@ -588,6 +627,42 @@ async fn mark_screenshot(conn: &C, dispatch_id: i64) -> AppR Ok(()) } +/// Turns raw user search text into a safe FTS5 query: each whitespace token that +/// carries a letter or digit becomes a quoted prefix term (`"token"*`), joined by +/// spaces (implicit AND). Quoting neutralizes FTS5 operators in user input; the +/// trailing `*` gives search-as-you-type prefix matching. Empty when nothing usable. +fn build_fts_query(search: &str) -> String { + search + .split_whitespace() + .filter(|token| token.chars().any(char::is_alphanumeric)) + .map(|token| format!("\"{}\"*", token.replace('"', "\"\""))) + .collect::>() + .join(" ") +} + +/// Resolves a user search string against the `task_search` FTS5 index to the set of +/// matching `session_id`s. Empty (no query) yields no matches rather than erroring. +async fn fts_search_session_ids( + conn: &C, + search: &str, +) -> AppResult> { + let fts_query = build_fts_query(search); + if fts_query.is_empty() { + return Ok(Vec::new()); + } + let rows = conn + .query_all(Statement::from_sql_and_values( + DbBackend::Sqlite, + "SELECT session_id FROM task_search WHERE task_search MATCH ?", + [fts_query.into()], + )) + .await?; + Ok(rows + .into_iter() + .filter_map(|row| row.try_get::("", "session_id").ok()) + .collect()) +} + async fn recompute_task(conn: &C, session_id: &str) -> AppResult<()> { let dispatches = query_dispatches_for_session(conn, session_id).await?; let start = query_start(conn, session_id).await?; @@ -672,6 +747,10 @@ async fn recompute_task(conn: &C, session_id: &str) -> AppRe tool_output_token_estimate: Set(tool_output_token_estimate), tokens_measured: Set(tokens_measured), updated_at: Set(now_epoch_ms()), + // Owned by name_session via set_task_summary, not by the recompute projection: + // NULL on first insert, and deliberately absent from update_columns below so a + // later recompute never clobbers a declared summary. + task_summary: Set(None), }) .on_conflict( OnConflict::column(tasks::Column::SessionId) @@ -1139,6 +1218,68 @@ mod tests { Ok(()) } + #[tokio::test] + async fn task_summary_is_searchable_survives_recompute_and_is_purged() -> anyhow::Result<()> { + let dir = tempdir()?; + let audit = AuditLog::new(Database::open(dir.path().join(DATABASE_FILENAME)).await?); + // A dispatch materializes the listable task row; the summary is set out-of-band. + audit + .record_tool_dispatch(dispatch("s1", "https://shop.example.com", false)) + .await?; + audit + .set_task_summary("s1", "Reconciled the quarterly wholesale invoices") + .await?; + + // FTS5 search over the summary finds the session (title/site do not contain the term), + // and the summary round-trips on the returned task. + let hit = audit + .list_tasks(ListTasksQuery { + search: Some("wholesale".to_string()), + ..Default::default() + }) + .await?; + assert_eq!(hit.tasks.len(), 1); + assert_eq!(hit.tasks[0].session_id, "s1"); + assert_eq!( + hit.tasks[0].task_summary.as_deref(), + Some("Reconciled the quarterly wholesale invoices") + ); + + // A later recompute (session end) must not clobber the declared summary. + audit.record_session_end("s1", "closed", None).await?; + let after = audit + .list_tasks(ListTasksQuery { + search: Some("reconciled".to_string()), + ..Default::default() + }) + .await?; + assert_eq!(after.tasks.len(), 1); + assert_eq!( + after.tasks[0].task_summary.as_deref(), + Some("Reconciled the quarterly wholesale invoices") + ); + + // A non-matching query returns nothing. + let miss = audit + .list_tasks(ListTasksQuery { + search: Some("zzznotthere".to_string()), + ..Default::default() + }) + .await?; + assert!(miss.tasks.is_empty()); + + // Retention purges the task and its search-index rows. + audit.delete_sessions(&["s1".to_string()]).await?; + let purged = audit + .list_tasks(ListTasksQuery { + search: Some("wholesale".to_string()), + ..Default::default() + }) + .await?; + assert!(purged.tasks.is_empty()); + Ok(()) + } + #[tokio::test] async fn completed_session_recovers_after_tool_error() -> anyhow::Result<()> { let dir = tempdir()?; @@ -1455,6 +1596,20 @@ impl AuditLog { .exec(&txn) .await? .rows_affected; + // task_search is an FTS5 virtual table (not a SeaORM entity); purge its rows for + // the same sessions so the search index does not retain deleted summaries. + let placeholders = session_ids + .iter() + .map(|_| "?") + .collect::>() + .join(", "); + let delete_index = format!("DELETE FROM task_search WHERE session_id IN ({placeholders})"); + txn.execute(Statement::from_sql_and_values( + DbBackend::Sqlite, + &delete_index, + session_ids.iter().map(|id| id.clone().into()), + )) + .await?; txn.commit().await?; Ok(AuditDeleteCounts { dispatches, diff --git a/packages/browseros-agent/apps/claw-server-rust/src/db/entities/tasks.rs b/packages/browseros-agent/apps/claw-server-rust/src/db/entities/tasks.rs index 02a4d57bcc..7927e14ede 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/db/entities/tasks.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/db/entities/tasks.rs @@ -27,6 +27,9 @@ pub struct Model { /// True iff the session has dispatches and every one carries token-estimator v1. pub tokens_measured: bool, pub updated_at: i64, + /// Agent-declared, PII-scrubbed one-or-two-line summary of the task, for audit search. + /// Written out-of-band by `name_session`; excluded from the task recompute upsert. + pub task_summary: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/browseros-agent/apps/claw-server-rust/src/db/migration.rs b/packages/browseros-agent/apps/claw-server-rust/src/db/migration.rs index baaba3c5b3..9744708a97 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/db/migration.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/db/migration.rs @@ -22,10 +22,70 @@ impl MigratorTrait for Migrator { Box::new(m0013_add_parent_dispatch_id::Migration), Box::new(m0014_add_skills_and_runs::Migration), Box::new(m0015_add_skill_run_marks::Migration), + Box::new(m0016_add_task_summary::Migration), ] } } +mod m0016_add_task_summary { + use super::*; + + pub struct Migration; + + impl MigrationName for Migration { + fn name(&self) -> &str { + "m0016_add_task_summary" + } + } + + #[async_trait::async_trait] + impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Canonical, PII-scrubbed summary shown in the audit UI. Nullable: existing + // rows and sessions that never declared one stay NULL. + if !manager.has_column("tasks", "task_summary").await? { + manager + .alter_table( + Table::alter() + .table(Alias::new("tasks")) + .add_column(ColumnDef::new(Alias::new("task_summary")).text()) + .to_owned(), + ) + .await?; + } + // Full-text search index over the summary, kept in sync by AuditLog::set_task_summary. + // Standalone (not external-content) because tasks is keyed by a String session_id; + // session_id is stored UNINDEXED only to map a MATCH hit back to its task. + manager + .get_connection() + .execute_unprepared( + "CREATE VIRTUAL TABLE IF NOT EXISTS task_search USING fts5(\ + session_id UNINDEXED, summary, tokenize = 'porter unicode61')", + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP TABLE IF EXISTS task_search") + .await?; + if manager.has_column("tasks", "task_summary").await? { + manager + .alter_table( + Table::alter() + .table(Alias::new("tasks")) + .drop_column(Alias::new("task_summary")) + .to_owned(), + ) + .await?; + } + Ok(()) + } + } +} + mod m0015_add_skill_run_marks { use super::*; diff --git a/packages/browseros-agent/apps/claw-server-rust/src/db/mod.rs b/packages/browseros-agent/apps/claw-server-rust/src/db/mod.rs index c173e40fb0..08dd0828d8 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/db/mod.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/db/mod.rs @@ -226,6 +226,7 @@ mod tests { "skills", "skill_runs", "skill_run_marks", + "task_search", "seaql_migrations", ] { assert!(names.contains(table), "missing table {table}"); @@ -313,6 +314,7 @@ mod tests { "tool_input_token_estimate", "tool_output_token_estimate", "tokens_measured", + "task_summary", ] { assert!( task_columns.contains(column), @@ -327,7 +329,7 @@ mod tests { "SELECT version FROM seaql_migrations".to_string(), )) .await?; - assert_eq!(migrations.len(), 15); + assert_eq!(migrations.len(), 16); assert_eq!( migrations[0].try_get::("", "version")?, "m0001_baseline" @@ -388,6 +390,10 @@ mod tests { migrations[14].try_get::("", "version")?, "m0015_add_skill_run_marks" ); + assert_eq!( + migrations[15].try_get::("", "version")?, + "m0016_add_task_summary" + ); Ok(()) } @@ -427,7 +433,7 @@ mod tests { let migration_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seaql_migrations") .fetch_one(&mut conn) .await?; - assert_eq!(migration_count, 15); + assert_eq!(migration_count, 16); conn.close().await?; Ok(()) } @@ -491,7 +497,7 @@ mod tests { "SELECT version FROM seaql_migrations ORDER BY version".to_string(), )) .await?; - assert_eq!(migrations.len(), 15); + assert_eq!(migrations.len(), 16); assert_eq!( migrations .iter() @@ -514,7 +520,7 @@ mod tests { .await? .ok_or_else(|| anyhow::anyhow!("migration count missing"))? .try_get::("", "count")?; - assert_eq!(migration_count, 15); + assert_eq!(migration_count, 16); Ok(()) } @@ -650,7 +656,7 @@ mod tests { "SELECT version FROM seaql_migrations".to_string(), )) .await?; - assert_eq!(migrations.len(), 15); + assert_eq!(migrations.len(), 16); Ok(()) } diff --git a/packages/browseros-agent/apps/claw-server-rust/tests/routes.rs b/packages/browseros-agent/apps/claw-server-rust/tests/routes.rs index 14d53c3ff6..81a7c59a9a 100644 --- a/packages/browseros-agent/apps/claw-server-rust/tests/routes.rs +++ b/packages/browseros-agent/apps/claw-server-rust/tests/routes.rs @@ -487,7 +487,7 @@ async fn mcp_name_session_lists_and_renames_while_disconnected() -> anyhow::Resu .ok_or_else(|| anyhow::anyhow!("name_session missing"))?; assert_eq!( tool["description"], - "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", plus a `category` for the kind of task. Tabs are grouped as /; the label stays on this machine and only the category is used for anonymous aggregate analytics. Call again to update." + "Name this browser session at the start of a task: a small lowercase 2-3 word label for what it is doing, e.g. \"invoice processing\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as /; the label stays on this machine, the summary powers audit search and is also recorded for analytics, and the category is used for anonymous aggregate analytics. Call again to update." ); assert_eq!( tool["inputSchema"], @@ -512,6 +512,11 @@ async fn mcp_name_session_lists_and_renames_while_disconnected() -> anyhow::Resu ], "description": "The kind of task, for anonymous aggregate analytics only; the free-form name is never sent. Pick the closest fit from the list." }, + "summary": { + "type": "string", + "maxLength": 200, + "description": "One or two short lines saying what this task is, phrased so you can find it again by searching later. No names, emails, URLs, file paths, or account numbers." + }, "session": { "type": "string", "description": "Opaque session handle returned by the server. Pass it back on every call to keep working in the same browser session; omit it to start a new session." diff --git a/packages/browseros-agent/contracts/claw-api/fixtures/session-list.json b/packages/browseros-agent/contracts/claw-api/fixtures/session-list.json index c08a266f66..1414b0cf2b 100644 --- a/packages/browseros-agent/contracts/claw-api/fixtures/session-list.json +++ b/packages/browseros-agent/contracts/claw-api/fixtures/session-list.json @@ -6,6 +6,7 @@ "slug": "codex", "label": "Codex", "name": "Research BrowserClaw", + "taskSummary": "Researching BrowserClaw capabilities and gathering release notes for a comparison writeup.", "harness": "Codex", "color": "#0254ec", "startedAt": 1784300400000, diff --git a/packages/browseros-agent/contracts/claw-api/schemas/sessions.yaml b/packages/browseros-agent/contracts/claw-api/schemas/sessions.yaml index 0fd30f76b8..fa82a30bab 100644 --- a/packages/browseros-agent/contracts/claw-api/schemas/sessions.yaml +++ b/packages/browseros-agent/contracts/claw-api/schemas/sessions.yaml @@ -111,6 +111,11 @@ SessionSummary: type: string site: type: string + taskSummary: + type: string + description: >- + Agent-declared, PII-scrubbed one-or-two-line summary of the task, used + for audit search. Absent when the session never declared one. startedAt: type: integer format: int64 diff --git a/packages/browseros-agent/contracts/claw-api/tests/schema.test.ts b/packages/browseros-agent/contracts/claw-api/tests/schema.test.ts index acffc62028..154e1e6d7f 100644 --- a/packages/browseros-agent/contracts/claw-api/tests/schema.test.ts +++ b/packages/browseros-agent/contracts/claw-api/tests/schema.test.ts @@ -106,6 +106,10 @@ describe('session visual API schema', () => { } expect(schemas.SessionSummary?.properties).toHaveProperty('tokenUsage') expect(schemas.SessionSummary?.required ?? []).not.toContain('tokenUsage') + // Optional agent-declared, PII-scrubbed search summary: present on the shape, never required + // (absent for sessions that never declared one). + expect(schemas.SessionSummary?.properties).toHaveProperty('taskSummary') + expect(schemas.SessionSummary?.required ?? []).not.toContain('taskSummary') expect(schemas.SessionTokenUsage?.required).toEqual([ 'inputTokenEstimate', 'outputTokenEstimate', diff --git a/packages/browseros-agent/crates/claw-api/src/generated/sessions.rs b/packages/browseros-agent/crates/claw-api/src/generated/sessions.rs index 0fb85b9884..8407a9fbca 100644 --- a/packages/browseros-agent/crates/claw-api/src/generated/sessions.rs +++ b/packages/browseros-agent/crates/claw-api/src/generated/sessions.rs @@ -231,6 +231,9 @@ pub struct SessionSummary { pub name: String, #[serde(rename = "site", skip_serializing_if = "Option::is_none")] pub site: Option, + /// Agent-declared, PII-scrubbed one-or-two-line summary of the task, used for audit search. Absent when the session never declared one. + #[serde(rename = "taskSummary", skip_serializing_if = "Option::is_none")] + pub task_summary: Option, #[serde(rename = "startedAt")] pub started_at: i64, #[serde(rename = "endedAt", skip_serializing_if = "Option::is_none")] @@ -277,6 +280,7 @@ impl SessionSummary { label, name, site: None, + task_summary: None, started_at, ended_at: None, duration_ms, diff --git a/packages/browseros-agent/packages/claw-api-client/src/generated/openapi.ts b/packages/browseros-agent/packages/claw-api-client/src/generated/openapi.ts index 72f9af17cd..95e281cc64 100644 --- a/packages/browseros-agent/packages/claw-api-client/src/generated/openapi.ts +++ b/packages/browseros-agent/packages/claw-api-client/src/generated/openapi.ts @@ -530,6 +530,8 @@ export interface components { label: string name: string site?: string + /** @description Agent-declared, PII-scrubbed one-or-two-line summary of the task, used for audit search. Absent when the session never declared one. */ + taskSummary?: string /** Format: int64 */ startedAt: number /** Format: int64 */ diff --git a/packages/browseros-agent/packages/claw-api/src/generated/models/sessions.ts b/packages/browseros-agent/packages/claw-api/src/generated/models/sessions.ts index 5f3925cefb..5ac48bae52 100644 --- a/packages/browseros-agent/packages/claw-api/src/generated/models/sessions.ts +++ b/packages/browseros-agent/packages/claw-api/src/generated/models/sessions.ts @@ -266,6 +266,12 @@ export interface SessionSummary { * @memberof SessionSummary */ site?: string; + /** + * Agent-declared, PII-scrubbed one-or-two-line summary of the task, used for audit search. Absent when the session never declared one. + * @type {string} + * @memberof SessionSummary + */ + taskSummary?: string; /** * * @type {number} diff --git a/packages/browseros-agent/resources/skills/browserclaw/SKILL.md b/packages/browseros-agent/resources/skills/browserclaw/SKILL.md index 3824402fd1..d4607a6465 100644 --- a/packages/browseros-agent/resources/skills/browserclaw/SKILL.md +++ b/packages/browseros-agent/resources/skills/browserclaw/SKILL.md @@ -9,7 +9,7 @@ When a task needs a browser or a website (open it, read it, act on it, fill a fo ## Shared browser etiquette -- Call `name_session` early with a 2-3 word task label and the best-fit `category`; tabs group as `/` in the cockpit. +- Call `name_session` early with a 2-3 word task label, the best-fit `category`, and a short PII-free `summary` you can search for later; tabs group as `/` in the cockpit. - Open your own tab with `tabs` action `"new"`. Work only in task-owned tabs. - If the user points you at a tab you do not own, open its URL in your own tab and leave the original untouched. - Preserve useful pages that the user may want to inspect instead of closing them when the task ends.