From a14559e553cd7788098f5161ed00bdd49dbc93b9 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Thu, 27 Aug 2026 17:41:04 +0530 Subject: [PATCH 1/5] feat(claw-server): searchable audit via a PII-free task summary Record an agent-declared, PII-scrubbed one-or-two-line task summary on name_session, persist it on the tasks row, and index it in a SQLite FTS5 table so the audit search bar finds sessions by what the task was about. - name_session gains an optional `summary` argument (last write wins), scrubbed server-side for structural PII (emails, URLs, file paths, bare domains, long digit runs) before it is stored or indexed. The summary is kept in local SQLite only and never sent to analytics. - Add tasks.task_summary plus an FTS5 task_search index, written in one transaction and excluded from the task recompute upsert so an out-of-band summary survives event replay; purge both on retention. - Extend the existing sessions search to also match summary content via the FTS5 index alongside the current title/agent/site match. - Surface the summary on the SessionSummary contract and render a two-line snippet under the target in the audit table. --- .../claw-app/components/audit/FilterBar.tsx | 2 +- .../claw-app/screens/audit/Audit.test.tsx | 17 ++ .../claw-app/screens/audit/audit.columns.tsx | 13 +- .../claw-server-rust/src/api/http/sessions.rs | 2 + .../claw-server-rust/src/api/mcp/prompt.rs | 5 +- .../claw-server-rust/src/api/mcp/service.rs | 100 ++++++++++- .../apps/claw-server-rust/src/db/audit_log.rs | 159 +++++++++++++++++- .../claw-server-rust/src/db/entities/tasks.rs | 3 + .../apps/claw-server-rust/src/db/migration.rs | 60 +++++++ .../apps/claw-server-rust/src/db/mod.rs | 16 +- .../apps/claw-server-rust/tests/routes.rs | 7 +- .../claw-api/fixtures/session-list.json | 1 + .../contracts/claw-api/schemas/sessions.yaml | 5 + .../contracts/claw-api/tests/schema.test.ts | 4 + .../crates/claw-api/src/generated/sessions.rs | 4 + .../claw-api-client/src/generated/openapi.ts | 2 + .../claw-api/src/generated/models/sessions.ts | 6 + .../resources/skills/browserclaw/SKILL.md | 2 +- 18 files changed, 391 insertions(+), 17 deletions(-) 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/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-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..9ea3b9928b 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 and summary stay on this machine (the summary makes the session findable in audit search), and only 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"; @@ -162,6 +164,25 @@ impl ClawMcpService { ); } } + if let Some(summary) = raw_args + .get("summary") + .and_then(Value::as_str) + .map(str::trim) + .filter(|summary| !summary.is_empty()) + { + // Scrub structural PII before it is persisted and indexed for audit search; + // stored locally only, never sent to analytics. Last write wins. + let clean = scrub_summary(summary); + if !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(), @@ -659,6 +680,51 @@ 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 + } +} + +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 +734,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 +1190,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 +1256,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 +1278,26 @@ 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); + } + #[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..8f7cc982b0 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 and summary stay on this machine (the summary makes the session findable in audit search), and only 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. From ba594ce6f0e408472b10490f3f49fca4c6bbeae0 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Thu, 27 Aug 2026 17:58:11 +0530 Subject: [PATCH 2/5] fix(claw-server): scrub the summary in the recorded name_session dispatch args The name_session dispatch persisted its raw arguments, so a summary the agent should have kept PII-free was scrubbed in tasks.task_summary and the search index but still surfaced verbatim in the audit detail timeline via the dispatch argsJson. Substitute the already-scrubbed summary into the recorded arguments so every audit surface shows the sanitized copy. --- .../claw-server-rust/src/api/mcp/service.rs | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) 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 9ea3b9928b..0fccdcbc1a 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 @@ -164,24 +164,23 @@ impl ClawMcpService { ); } } - if let Some(summary) = raw_args + // Scrub structural PII from any provided summary before it is persisted or + // indexed for search; stored locally only, never sent to analytics. Last write wins. + 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(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 { - // Scrub structural PII before it is persisted and indexed for audit search; - // stored locally only, never sent to analytics. Last write wins. - let clean = scrub_summary(summary); - if !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"); - } + warn!(error = %error, "failed to store task summary"); } let browser = self.state.browser.session().await; apply_agent_tab_group_title( @@ -193,13 +192,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(), @@ -703,6 +708,17 @@ fn scrub_summary(raw: &str) -> String { } } +/// 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('@') @@ -1298,6 +1314,20 @@ mod tests { 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?; From a9bf6f121722eb1842d9cc1f32c26ba92c8eed43 Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Thu, 27 Aug 2026 21:15:58 +0530 Subject: [PATCH 3/5] fix(claw-app): show the task summary on the audit detail page and scroll the tab strip The detail header omitted the agent-declared summary that the audit list already shows; render it under the title. Separately, sessions that touch many browser tabs produced a tab strip wider than the page, so it spilled off-screen; make the strip scroll horizontally with each trigger at its natural width. --- .../claw-app/components/audit/TaskHeader.tsx | 5 +++++ .../screens/task-detail/TaskDetailPage.test.tsx | 17 +++++++++++++++++ .../screens/task-detail/TaskDetailPage.tsx | 4 ++++ 3 files changed, 26 insertions(+) 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/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..f2d431de8d 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,10 @@ 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, and keep each trigger at its natural width. + listClassName="w-full max-w-full justify-start overflow-x-auto [&_button]:shrink-0" /> Date: Thu, 27 Aug 2026 21:18:36 +0530 Subject: [PATCH 4/5] fix(claw-app): hide the audit detail tab-strip scrollbar while keeping horizontal scroll --- .../apps/claw-app/screens/task-detail/TaskDetailPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 f2d431de8d..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 @@ -123,8 +123,9 @@ export function TaskDetailPage() { listVariant="line" // Many-tab sessions (one tab per browser page) overflow the fixed // page width; scroll the strip horizontally instead of spilling - // off-screen, and keep each trigger at its natural width. - listClassName="w-full max-w-full justify-start overflow-x-auto [&_button]:shrink-0" + // 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" /> Date: Thu, 27 Aug 2026 22:07:11 +0530 Subject: [PATCH 5/5] feat(analytics): record the task summary alongside the declared category name_session now attaches the PII-scrubbed task summary to the agent_session_task_declared event so the summary is available for product analytics, not only local audit search. The analytics catalog gains its first free-text property: task_summary is optional (the declaration still sends without it) and length-bounded defensively at the boundary, with the scrub and cap already applied upstream. Update the tool description to note the summary powers search and is recorded for analytics. --- .../claw-server-rust/src/analytics/events.rs | 105 ++++++++++++++++-- .../claw-server-rust/src/api/mcp/service.rs | 35 +++--- .../apps/claw-server-rust/tests/routes.rs | 2 +- 3 files changed, 119 insertions(+), 23 deletions(-) diff --git a/packages/browseros-agent/apps/claw-server-rust/src/analytics/events.rs b/packages/browseros-agent/apps/claw-server-rust/src/analytics/events.rs index e74b8e0b97..7f2cbde69a 100644 --- a/packages/browseros-agent/apps/claw-server-rust/src/analytics/events.rs +++ b/packages/browseros-agent/apps/claw-server-rust/src/analytics/events.rs @@ -2,7 +2,9 @@ //! //! Producers can select one of these opaque definitions, but cannot construct a //! new wire event or widen its property schema. Free-form input is reduced to -//! fixed tokens here before the delivery service ever sees it. +//! fixed tokens here before the delivery service ever sees it. The sole +//! exception is the agent-declared `task_summary`, a free-text property that is +//! PII-scrubbed and length-capped upstream and only bounded defensively here. use serde_json::{Map, Value}; use std::{ @@ -12,6 +14,7 @@ use std::{ const CLIENT_NAME: &str = "client_name"; const TASK_CATEGORY: &str = "task_category"; +const TASK_SUMMARY: &str = "task_summary"; const HARNESS: &str = "harness"; const KIND: &str = "kind"; const TOOL_NAME: &str = "tool_name"; @@ -34,6 +37,11 @@ const SCREENSHOT_TOKENS_PER_DISPATCH: &str = "screenshot_tokens_per_dispatch"; pub(crate) const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +/// Hard cap on the free-text task summary before it leaves the machine. The +/// summary is already PII-scrubbed and length-capped upstream; this is the +/// final defensive bound at the analytics boundary. +pub(crate) const TASK_SUMMARY_MAX_CHARS: usize = 200; + const KNOWN_CLIENTS: [&str; 15] = [ "claude-desktop", "claude-code", @@ -95,6 +103,7 @@ pub(crate) const TASK_CATEGORY_VALUES: [&str; 11] = [ enum PropertyKind { ClientName, TaskCategory, + TaskSummary, Harness, EndKind, ToolName, @@ -106,11 +115,26 @@ enum PropertyKind { struct PropertyDefinition { name: &'static str, kind: PropertyKind, + /// When true, the event still validates and sends if this property is + /// absent; when present it must still normalize. + optional: bool, } impl PropertyDefinition { const fn new(name: &'static str, kind: PropertyKind) -> 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/mcp/service.rs b/packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs index 0fccdcbc1a..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,7 +46,7 @@ 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\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as /; the label and summary stay on this machine (the summary makes the session findable in audit search), 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; @@ -146,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) @@ -155,23 +163,24 @@ 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, ); } } - // Scrub structural PII from any provided summary before it is persisted or - // indexed for search; stored locally only, never sent to analytics. Last write wins. - 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(clean) = scrubbed_summary.as_deref() && !clean.is_empty() && let Err(error) = self 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 8f7cc982b0..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\", a `category` for the kind of task, and a short `summary`. Tabs are grouped as /; the label and summary stay on this machine (the summary makes the session findable in audit search), 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"],