),
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.