Skip to content

Commit a14559e

Browse files
committed
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.
1 parent 67b8355 commit a14559e

18 files changed

Lines changed: 391 additions & 17 deletions

File tree

packages/browseros-agent/apps/claw-app/components/audit/FilterBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export function FilterBar({
192192
<Input
193193
value={localSearch}
194194
onChange={(e) => setLocalSearch(e.target.value)}
195-
placeholder="search sessions..."
195+
placeholder="search sessions, summaries..."
196196
// pr-7 reserves space for the inline clear button so the
197197
// text never sits under the icon.
198198
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"

packages/browseros-agent/apps/claw-app/screens/audit/Audit.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,23 @@ describe('Audit screen', () => {
123123
// (LIVE / FAILED / STOPPED) render a chip in the agent cell.
124124
})
125125

126+
it('renders the task summary snippet under the target when present', () => {
127+
dataOverride = {
128+
...baseData,
129+
tasks: [
130+
{
131+
...sampleTask,
132+
taskSummary:
133+
'Compared two invoicing tools and noted their pricing tiers.',
134+
},
135+
],
136+
}
137+
const html = renderApp()
138+
expect(html).toContain(
139+
'Compared two invoicing tools and noted their pricing tiers.',
140+
)
141+
})
142+
126143
it('hides token usage from the task list', () => {
127144
dataOverride = {
128145
...baseData,

packages/browseros-agent/apps/claw-app/screens/audit/audit.columns.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,16 @@ export const TASK_COLUMNS: ColumnDef<TaskSummary>[] = [
4747
// the one cell a reader scans rather than skims, and the design calls
4848
// for the platform UI face at reading size here.
4949
cell: ({ row }) => (
50-
<span className="block truncate font-[system-ui,sans-serif] text-[13px] text-ledger-ink">
51-
{row.original.name}
52-
</span>
50+
<div className="min-w-0">
51+
<span className="block truncate font-[system-ui,sans-serif] text-[13px] text-ledger-ink">
52+
{row.original.name}
53+
</span>
54+
{row.original.taskSummary && (
55+
<span className="mt-0.5 line-clamp-2 block text-[11px] text-ledger-ink-2 leading-snug">
56+
{row.original.taskSummary}
57+
</span>
58+
)}
59+
</div>
5360
),
5461
enableSorting: false,
5562
},

packages/browseros-agent/apps/claw-server-rust/src/api/http/sessions.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ async fn contract_summary(task: TaskSummary, live: Option<&Arc<Session>>) -> Ses
216216
.and_then(|session| session.agent().profile_id())
217217
.map(|profile_id| profile_id.as_str().to_string());
218218
summary.site = task.site;
219+
summary.task_summary = task.task_summary;
219220
summary.ended_at = task.ended_at;
220221
summary.latest_screenshot_id = task.last_screenshot_dispatch_id;
221222
summary.token_usage = token_usage;
@@ -249,6 +250,7 @@ fn contract_live_projection(projection: LiveSessionProjection) -> SessionSummary
249250
summary.harness = harness;
250251
summary.color = Some(color);
251252
summary.site = task.site;
253+
summary.task_summary = task.task_summary;
252254
summary.ended_at = task.ended_at;
253255
summary.latest_screenshot_id = task.last_screenshot_dispatch_id;
254256
summary.token_usage = token_usage;

packages/browseros-agent/apps/claw-server-rust/src/api/mcp/prompt.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ Shared with other agents:
2222
tabs action="new" and work on that copy; leave the original untouched.
2323
- Preserve useful pages: leave anything the user may want to inspect open
2424
instead of closing it when the task ends.
25-
- Name your session early with name_session: a 2-3 word task label plus the
26-
category that best fits the task; tabs group as <client>/<name>.
25+
- Name your session early with name_session: a 2-3 word task label, the category
26+
that best fits the task, and a short PII-free summary you can search for later;
27+
tabs group as <client>/<name>.
2728
- The user oversees this browser from the BrowserOS neo cockpit (live view,
2829
audit, replay).
2930

packages/browseros-agent/apps/claw-server-rust/src/api/mcp/service.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ use uuid::Uuid;
4646
const SERVER_NAME: &str = "browseros-neo";
4747
const SERVER_TITLE: &str = "BrowserOS neo";
4848
const NAME_SESSION_TOOL_NAME: &str = "name_session";
49-
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 <client>/<name>; the label stays on this machine and only the category is used for anonymous aggregate analytics. Call again to update.";
49+
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 <client>/<name>; 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.";
5050
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.";
51+
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.";
52+
const SUMMARY_MAX_LEN: usize = 200;
5153
const NAME_SESSION_INPUT_MAX_LEN: usize = 64;
5254
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.";
5355
const SAVE_SKILL_TOOL_NAME: &str = "save_skill";
@@ -162,6 +164,25 @@ impl ClawMcpService {
162164
);
163165
}
164166
}
167+
if let Some(summary) = raw_args
168+
.get("summary")
169+
.and_then(Value::as_str)
170+
.map(str::trim)
171+
.filter(|summary| !summary.is_empty())
172+
{
173+
// Scrub structural PII before it is persisted and indexed for audit search;
174+
// stored locally only, never sent to analytics. Last write wins.
175+
let clean = scrub_summary(summary);
176+
if !clean.is_empty()
177+
&& let Err(error) = self
178+
.state
179+
.audit_log
180+
.set_task_summary(started.session.id().as_str(), &clean)
181+
.await
182+
{
183+
warn!(error = %error, "failed to store task summary");
184+
}
185+
}
165186
let browser = self.state.browser.session().await;
166187
apply_agent_tab_group_title(
167188
browser.as_ref(),
@@ -659,6 +680,51 @@ async fn rename_session(
659680
})
660681
}
661682

683+
/// Best-effort structural PII scrub for an agent-provided task summary before it is
684+
/// stored and indexed for search: drops any whitespace token that looks like an email,
685+
/// URL, file path, bare domain/filename, or a long digit run (phone / card / account
686+
/// number). Collapses whitespace and caps the length. Free prose and names are kept;
687+
/// the agent is instructed to omit those, and the summary never leaves this machine.
688+
fn scrub_summary(raw: &str) -> String {
689+
let scrubbed = raw
690+
.split_whitespace()
691+
.filter(|token| !is_pii_token(token))
692+
.collect::<Vec<_>>()
693+
.join(" ");
694+
if scrubbed.chars().count() > SUMMARY_MAX_LEN {
695+
scrubbed
696+
.chars()
697+
.take(SUMMARY_MAX_LEN)
698+
.collect::<String>()
699+
.trim_end()
700+
.to_string()
701+
} else {
702+
scrubbed
703+
}
704+
}
705+
706+
fn is_pii_token(token: &str) -> bool {
707+
let lower = token.to_ascii_lowercase();
708+
if token.contains('@')
709+
|| lower.contains("://")
710+
|| lower.starts_with("www.")
711+
|| token.contains('/')
712+
|| token.contains('\\')
713+
{
714+
return true;
715+
}
716+
if token.chars().filter(|c| c.is_ascii_digit()).count() >= 7 {
717+
return true;
718+
}
719+
// bare domains / filenames: example.com, crm.internal.acme.com, report.pdf
720+
if let Some((prefix, suffix)) = lower.rsplit_once('.') {
721+
return !prefix.is_empty()
722+
&& (2..=24).contains(&suffix.len())
723+
&& suffix.chars().all(|c| c.is_ascii_alphabetic());
724+
}
725+
false
726+
}
727+
662728
fn name_session_tool() -> Tool {
663729
let Value::Object(input_schema) = json!({
664730
"type": "object",
@@ -668,6 +734,11 @@ fn name_session_tool() -> Tool {
668734
"type": "string",
669735
"enum": crate::analytics::events::TASK_CATEGORY_VALUES,
670736
"description": NAME_SESSION_CATEGORY_DESCRIPTION
737+
},
738+
"summary": {
739+
"type": "string",
740+
"maxLength": SUMMARY_MAX_LEN,
741+
"description": NAME_SESSION_SUMMARY_DESCRIPTION
671742
}
672743
},
673744
"required": ["name"]
@@ -1119,7 +1190,7 @@ mod tests {
11191190
assert!(instructions.contains("BrowserOS neo — the browser for agents"));
11201191
assert!(instructions.contains("Reach for run first"));
11211192
assert!(instructions.contains(
1122-
"- 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 <client>/<name>."
1193+
"- 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 <client>/<name>."
11231194
));
11241195
assert!(instructions.contains(
11251196
"- 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 {
11851256
"enum": crate::analytics::events::TASK_CATEGORY_VALUES,
11861257
"description": NAME_SESSION_CATEGORY_DESCRIPTION
11871258
},
1259+
"summary": {
1260+
"type": "string",
1261+
"maxLength": SUMMARY_MAX_LEN,
1262+
"description": NAME_SESSION_SUMMARY_DESCRIPTION
1263+
},
11881264
"session": { "type": "string", "description": SESSION_ARG_DESCRIPTION }
11891265
},
11901266
"required": ["name"]
@@ -1202,6 +1278,26 @@ mod tests {
12021278
Ok(())
12031279
}
12041280

1281+
#[test]
1282+
fn scrub_summary_drops_structural_pii_and_keeps_prose() {
1283+
let raw = "Downloaded invoices for john@acme.com from \
1284+
https://billing.acme.com/portal ref 4155551234 saved to /home/user/out.pdf";
1285+
let clean = scrub_summary(raw);
1286+
assert!(!clean.contains('@'));
1287+
assert!(!clean.contains("://"));
1288+
assert!(!clean.contains('/'));
1289+
assert!(!clean.contains("4155551234"));
1290+
assert!(!clean.to_ascii_lowercase().contains("acme.com"));
1291+
assert!(clean.contains("Downloaded"));
1292+
assert!(clean.contains("invoices"));
1293+
}
1294+
1295+
#[test]
1296+
fn scrub_summary_caps_length() {
1297+
let raw = "word ".repeat(200);
1298+
assert!(scrub_summary(&raw).chars().count() <= SUMMARY_MAX_LEN);
1299+
}
1300+
12051301
#[tokio::test]
12061302
async fn save_skill_is_registered_locally_with_annotations() -> anyhow::Result<()> {
12071303
let call = crate::api::mcp::test_support::tool_call("tabs", json!({})).await?;

0 commit comments

Comments
 (0)