Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
eeeab27
Add local history retention and AI history lease policy
KAVentures Sep 27, 2026
b1965f4
Apply history retention guard before persistence
KAVentures Sep 27, 2026
d863ee3
Allow retention cleanup to prune queued sessions
KAVentures Sep 27, 2026
010c0fc
Add session history catalog and retention cleanup
KAVentures Sep 27, 2026
51737a5
Expose local history retention controls and session catalog
KAVentures Sep 27, 2026
716eae0
Run retention recovery at local app lifecycle boundaries
KAVentures Sep 27, 2026
dacc20e
Purge ephemeral agent runs when their observed session closes
KAVentures Sep 27, 2026
54568e8
Close ephemeral human history at recording boundaries
KAVentures Sep 27, 2026
65e8065
Register history retention routes and capture integration
KAVentures Sep 27, 2026
b9576d6
Invalidate saved-history cursors after manual deletion
KAVentures Sep 27, 2026
0e2c421
Invalidate Pulse cursors on history changes and gate saved findings
KAVentures Sep 27, 2026
906467c
Enforce saved-history leases across MCP reads
KAVentures Sep 27, 2026
281ed94
Install saved-history guard for compact stdio MCP
KAVentures Sep 27, 2026
ecd45df
Install saved-history guard for compact HTTP MCP
KAVentures Sep 27, 2026
60fe465
Install saved-history guard for legacy stdio MCP
KAVentures Sep 27, 2026
bb6a3c9
Install saved-history guard for legacy HTTP MCP
KAVentures Sep 27, 2026
d79a8b1
Support bounded date ranges for agent execution history
KAVentures Sep 27, 2026
a2e7a8c
Add lightweight MCP history navigation tool
KAVentures Sep 27, 2026
827d711
Register history navigation on compact stdio MCP
KAVentures Sep 27, 2026
469babd
Register history navigation on compact HTTP MCP
KAVentures Sep 27, 2026
42d1e3b
Register history navigation on legacy stdio MCP
KAVentures Sep 27, 2026
6ca287c
Register history navigation on legacy HTTP MCP
KAVentures Sep 27, 2026
f342ed6
Add explicit retained-history JSON export
KAVentures Sep 27, 2026
c755c66
Add History retention, AI lease, export and deletion UI
KAVentures Sep 27, 2026
a34e557
Project structural browser AI lifecycle into agent evidence
KAVentures Sep 27, 2026
a4c05ab
Observe structural lifecycle for common browser agents
KAVentures Sep 27, 2026
535ea6e
Load structural web-agent lifecycle adapter
KAVentures Sep 27, 2026
5eb4f71
Keep browser manifest version field numeric
KAVentures Sep 27, 2026
e728ae3
Register browser agent lifecycle projection
KAVentures Sep 27, 2026
e056031
Revoke saved-history lease when AI access is turned off
KAVentures Sep 27, 2026
7c20f3d
Revoke history lease with normal AI access
KAVentures Sep 27, 2026
4c1c540
Test structural browser agent adapter privacy contract
KAVentures Sep 27, 2026
448af8f
Add history retention, lease and browser-agent regression tests
KAVentures Sep 27, 2026
9feb09c
Advertise explicit saved-history navigation in MCP bundle
KAVentures Sep 27, 2026
5e1942d
Send browser-agent lifecycle on existing event wire format
KAVentures Sep 27, 2026
8cf7ce6
Test agent lifecycle wire format against browser background contract
KAVentures Sep 27, 2026
df693b7
Invalidate Pulse state when saved-history access changes
KAVentures Sep 27, 2026
3ac0aa3
Release prep: bump version to 0.94.0
KAVentures Sep 27, 2026
4b1ba20
Release prep: align package version at 0.94.0
KAVentures Sep 27, 2026
cf148e5
Release prep: align MCP bundle at v0.94.0
KAVentures Sep 27, 2026
90ab423
Document v0.94 history and universal agent observation
KAVentures Sep 27, 2026
c466b06
Preserve legacy retention until History initializes
KAVentures Sep 27, 2026
5055a2b
Keep legacy MCP at 24 tools while enforcing history guard
KAVentures Sep 27, 2026
9e70715
Keep legacy HTTP MCP tool surface stable
KAVentures Sep 27, 2026
a7be77e
Expect list_history in compact MCP bundle
KAVentures Sep 27, 2026
b50d8ec
Update compact MCP contract for History
KAVentures Sep 27, 2026
1cfe37a
Align release contract with v0.94
KAVentures Sep 27, 2026
71ca56f
Expect v0.94 browser sensor version
KAVentures Sep 27, 2026
61128f4
Update active-tab test for v0.94 browser sensor
KAVentures Sep 27, 2026
16b758d
Fix History test and cover legacy uninitialized retention
KAVentures Sep 27, 2026
0eff0b8
Grant explicit history lease in Context MCP privacy test
KAVentures Sep 27, 2026
214f098
Document legacy MCP compatibility in v0.94 bundle
KAVentures Sep 27, 2026
c72786b
Exercise legacy MCP against production History runtime
KAVentures Sep 27, 2026
6542ee3
Make redaction test retention choice explicit
KAVentures Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.93.0
0.94.0
98 changes: 98 additions & 0 deletions browser_extension/agent_surface_adapters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
(() => {
'use strict';

const PROVIDERS = [
{key:'chatgpt', name:'ChatGPT', hosts:['chatgpt.com','chat.openai.com']},
{key:'claude', name:'Claude', hosts:['claude.ai']},
{key:'microsoft_copilot', name:'Microsoft Copilot', hosts:['copilot.microsoft.com','copilot.microsoft365.com','m365.cloud.microsoft']},
{key:'lovable', name:'Lovable', hosts:['lovable.dev']},
{key:'gemini', name:'Gemini', hosts:['gemini.google.com']},
];
const STOP_RE=/^(stop|cancel)( generating| response| task| run)?$/i;
const SEND_RE=/^(send|submit|ask|run|build|generate)( prompt| message| request)?$/i;
const APPROVE_RE=/^(allow|approve|confirm|continue|accept)$/i;

function providerForHost(host){
const h=String(host||'').toLowerCase().replace(/^www\./,'');
return PROVIDERS.find(p=>p.hosts.some(x=>h===x||h.endsWith('.'+x)))||null;
}
function accessibleName(el){
if(!el)return '';
return String(el.getAttribute?.('aria-label')||el.getAttribute?.('title')||el.textContent||'').replace(/\s+/g,' ').trim().slice(0,80);
}
function buttonLike(el){return !!el?.closest?.('button,[role="button"],input[type="submit"]');}
function hasBusyState(doc){
if(!doc)return false;
if(doc.querySelector('[aria-busy="true"]'))return true;
return [...doc.querySelectorAll('button,[role="button"]')].some(el=>STOP_RE.test(accessibleName(el)));
}
function hasVisibleErrorState(doc){return !!doc?.querySelector?.('[role="alert"][aria-live], [role="alert"]');}
function hasApprovalState(doc){
if(!doc)return false;
for(const root of doc.querySelectorAll('dialog,[role="dialog"]')){
if([...root.querySelectorAll('button,[role="button"]')].some(el=>APPROVE_RE.test(accessibleName(el))))return true;
}
return false;
}
function isSendControl(el){const control=buttonLike(el);return !!control&&SEND_RE.test(accessibleName(control));}
function isStopControl(el){const control=buttonLike(el);return !!control&&STOP_RE.test(accessibleName(control));}
function isApprovalControl(el){const control=buttonLike(el);return !!control&&APPROVE_RE.test(accessibleName(control))&&!!control.closest('dialog,[role="dialog"]');}
function safeRunId(){
try{return 'web-'+crypto.randomUUID().replaceAll('-','');}catch(_){return 'web-'+Date.now().toString(36)+Math.random().toString(36).slice(2,12);}
}
function structuralPayload(provider, action, runId, source){
return {
type:'workflow_observer_event',
observed_at:new Date().toISOString(),
action,
// background.js derives the real safe page URL from sender.tab. Deliberately
// do not send conversation paths, titles, prompts, responses or alert text.
page:{url:location.origin+'/',title:provider.name},
target:{role:'agent-lifecycle',label:provider.name},
metadata:{agent_provider:provider.key,agent_run_id:runId,state_source:String(source||'semantic_state').slice(0,40),top_frame:true}
};
}
function sendLifecycle(provider,action,runId,source){
// No DOM text or user/model content is included in this message.
const message=structuralPayload(provider,action,runId,source);
try{
const runtime=(typeof browser!=='undefined'&&browser.runtime)?browser.runtime:(typeof chrome!=='undefined'&&chrome.runtime?chrome.runtime:null);
runtime?.sendMessage(message).catch?.(()=>{});
}catch(_){}
}

function start(doc=typeof document!=='undefined'?document:null){
if(!doc||typeof location==='undefined')return;
const provider=providerForHost(location.hostname);if(!provider)return;
let runId='',wasBusy=false,errorSent=false,approvalSent=false,finishTimer=null;
const begin=source=>{if(runId)return;runId=safeRunId();wasBusy=false;errorSent=false;approvalSent=false;sendLifecycle(provider,'agent_run_started',runId,source);};
const reset=()=>{runId='';wasBusy=false;errorSent=false;approvalSent=false;if(finishTimer){clearTimeout(finishTimer);finishTimer=null;}};
const finish=(action='agent_run_finished',source='busy_cleared')=>{if(!runId)return;sendLifecycle(provider,action,runId,source);reset();};
const sample=()=>{
const busy=hasBusyState(doc);
if(busy&&!runId)begin('busy_state');
if(runId){
if(busy)wasBusy=true;
if(hasVisibleErrorState(doc)&&!errorSent){sendLifecycle(provider,'agent_error',runId,'aria_alert_present');errorSent=true;}
if(hasApprovalState(doc)&&!approvalSent){sendLifecycle(provider,'agent_approval_requested',runId,'semantic_dialog');approvalSent=true;}
if(wasBusy&&!busy&&!finishTimer){finishTimer=setTimeout(()=>{finishTimer=null;if(runId&&!hasBusyState(doc))finish();},900);}
if(busy&&finishTimer){clearTimeout(finishTimer);finishTimer=null;}
}
};
doc.addEventListener('click',ev=>{
if(isSendControl(ev.target)){begin('send_control');setTimeout(sample,0);return;}
if(runId&&isStopControl(ev.target)){finish('agent_run_cancelled','stop_control');return;}
if(runId&&isApprovalControl(ev.target)){sendLifecycle(provider,'agent_approval_received',runId,'approval_control');approvalSent=true;}
},true);
doc.addEventListener('submit',()=>{begin('form_submit');setTimeout(sample,0);},true);
const observer=new MutationObserver(()=>sample());
const root=doc.documentElement||doc;observer.observe(root,{subtree:true,childList:true,attributes:true,attributeFilter:['aria-busy','aria-live','role','open','disabled']});
setInterval(()=>{if(!doc.hidden)sample();},1500);
sample();
}

globalThis.__OWG_AGENT_SURFACE_ADAPTERS_FOR_TESTS__={PROVIDERS,providerForHost,accessibleName,hasBusyState,hasVisibleErrorState,hasApprovalState,isSendControl,isStopControl,isApprovalControl,structuralPayload};
if(typeof document!=='undefined'&&typeof MutationObserver!=='undefined'){
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',()=>start(),{once:true});else start();
}
})();
54 changes: 14 additions & 40 deletions browser_extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,48 +1,22 @@
{
"manifest_version": 3,
"name": "Workflow Observer Browser Sensor",
"version": "1.11.0",
"version_name": "1.11.0-v58-metadata-signals",
"description": "Local-only semantic browser telemetry for Workflow Observer. Query values/fragments, typed field values, clipboard contents, filenames, file contents, and ordinary key identities are not collected.",
"version": "1.12.0",
"version_name": "1.12.0-v94-agent-lifecycle",
"description": "Local-only structural browser telemetry for OpenWorkGraph. Query values/fragments, typed field values, clipboard contents, filenames, file contents, prompts and model responses are not collected.",
"incognito": "not_allowed",
"permissions": [
"tabs",
"webNavigation",
"storage",
"alarms"
],
"host_permissions": [
"http://127.0.0.1:8787/*",
"http://*/*",
"https://*/*"
],
"permissions": ["tabs", "webNavigation", "storage", "alarms"],
"host_permissions": ["http://127.0.0.1:8787/*", "http://*/*", "https://*/*"],
"background": {
"scripts": [
"browser_auth.js",
"background.js",
"workflow_enrichment.js",
"browser_signal_enrichment.js"
],
"scripts": ["browser_auth.js", "background.js", "workflow_enrichment.js", "browser_signal_enrichment.js"],
"service_worker": "secure_background.js"
},
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*"
],
"js": [
"content.js",
"content_enrichment.js",
"browser_signals.js"
],
"run_at": "document_start",
"all_frames": true,
"match_about_blank": true
}
],
"action": {
"default_title": "Workflow Observer Browser Sensor",
"default_popup": "popup.html"
}
"content_scripts": [{
"matches": ["http://*/*", "https://*/*"],
"js": ["content.js", "content_enrichment.js", "browser_signals.js", "agent_surface_adapters.js"],
"run_at": "document_start",
"all_frames": true,
"match_about_blank": true
}],
"action": {"default_title": "Workflow Observer Browser Sensor", "default_popup": "popup.html"}
}
46 changes: 34 additions & 12 deletions collector/outbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from sensitive_identifiers import sanitize_event_identifiers
from shared.evidence_deletion import event_overlaps_range
from shared.history_policy import event_kind


class EventOutbox:
Expand Down Expand Up @@ -56,8 +57,6 @@ def _sanitize_existing_pending(self) -> None:
safe = sanitize_event_identifiers(event)
payload = json.dumps(safe, ensure_ascii=False, separators=(",", ":"))
except Exception:
# Do not destroy a delivery queue merely because one legacy
# payload is malformed; new events are always sanitized.
continue
if payload != row["payload_json"]:
conn.execute(
Expand Down Expand Up @@ -93,6 +92,18 @@ def acknowledge(self, event_ids: list[str]) -> None:
with self._lock, self._connect() as conn:
conn.execute(f"DELETE FROM pending_events WHERE event_id IN ({placeholders})", tuple(ids))

def _delete_ids(self, conn: sqlite3.Connection, delete_ids: list[str]) -> int:
if not delete_ids:
return 0
for offset in range(0, len(delete_ids), 500):
chunk = delete_ids[offset : offset + 500]
placeholders = ",".join("?" for _ in chunk)
conn.execute(
f"DELETE FROM pending_events WHERE event_id IN ({placeholders})",
tuple(chunk),
)
return len(delete_ids)

def prune_range(self, since: str, until: str) -> int:
"""Remove queued evidence that overlaps a durable local deletion range."""
with self._lock, self._connect() as conn:
Expand All @@ -105,16 +116,27 @@ def prune_range(self, since: str, until: str) -> int:
continue
if isinstance(event, dict) and event_overlaps_range(event, since, until):
delete_ids.append(str(row["event_id"]))
if not delete_ids:
return 0
for offset in range(0, len(delete_ids), 500):
chunk = delete_ids[offset : offset + 500]
placeholders = ",".join("?" for _ in chunk)
conn.execute(
f"DELETE FROM pending_events WHERE event_id IN ({placeholders})",
tuple(chunk),
)
return len(delete_ids)
return self._delete_ids(conn, delete_ids)

def prune_sessions(self, kind: str, session_ids: list[str]) -> int:
"""Remove queued rows belonging to expired human or agent sessions."""
selected_kind = "agent" if str(kind).lower() == "agent" else "human"
wanted = {str(value) for value in session_ids if str(value)}
if not wanted:
return 0
with self._lock, self._connect() as conn:
rows = conn.execute("SELECT event_id, payload_json FROM pending_events").fetchall()
delete_ids: list[str] = []
for row in rows:
try:
event = json.loads(row["payload_json"])
except Exception:
continue
if not isinstance(event, dict):
continue
if str(event.get("session_id") or "") in wanted and event_kind(event) == selected_kind:
delete_ids.append(str(row["event_id"]))
return self._delete_ids(conn, delete_ids)

def mark_failed(self, event_ids: list[str], error: str) -> None:
ids = [str(x) for x in event_ids if x]
Expand Down
Loading
Loading