diff --git a/dashboard/analysis_handoff.js b/dashboard/analysis_handoff.js
new file mode 100644
index 00000000..b18dd03b
--- /dev/null
+++ b/dashboard/analysis_handoff.js
@@ -0,0 +1,105 @@
+(() => {
+ 'use strict';
+ const $ = s => document.querySelector(s);
+ const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ let period = 'week', start = '', end = '', preview = null, generation = 0;
+ const dateInput = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
+ function selection() {
+ if (period === 'current' || period === 'all') return {scope: period};
+ const now = new Date(), a = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const b = new Date(a); b.setDate(b.getDate()+1);
+ if (period === 'week') a.setDate(a.getDate()-6);
+ if (period === 'custom') {
+ if (!start || !end) throw new Error('Choose both dates.');
+ const aa = new Date(start+'T00:00:00'), bb = new Date(end+'T00:00:00');
+ bb.setDate(bb.getDate()+1);
+ if (!Number.isFinite(+aa) || !Number.isFinite(+bb) || aa >= bb) throw new Error('End date must be on or after start.');
+ return {scope:'range',since:aa.toISOString(),until:bb.toISOString()};
+ }
+ return {scope:'range',since:a.toISOString(),until:b.toISOString()};
+ }
+ window.owgEvidenceSelection = selection;
+ async function api(path, options) {
+ await window.__owgAuthReady;
+ const r = await fetch(path,{cache:'no-store',...options});
+ const d = await r.json().catch(()=>({}));
+ if (!r.ok) throw new Error(d.detail || 'Could not prepare evidence.');
+ return d;
+ }
+ async function download(fmt, detailed, redacted=true, chosen=selection()) {
+ const d = await api('/v1/export-ticket',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({format:fmt,...chosen,include_raw:!!detailed,redact_names:!!redacted})});
+ const a = document.createElement('a');a.href=d.url;a.download='';a.hidden=true;
+ document.body.appendChild(a);a.click();a.remove();
+ }
+ function syncControls() {
+ document.querySelectorAll('.owg-period').forEach(node => {
+ node.querySelector('select').value=period;
+ node.querySelector('[data-custom]').hidden=period!=='custom';
+ node.querySelector('[data-start]').value=start;node.querySelector('[data-end]').value=end;
+ });
+ }
+ async function refresh() {
+ const seq=++generation; preview=null;
+ document.querySelectorAll('[data-period-summary]').forEach(x=>x.textContent='Checking selected evidence…');
+ try {
+ const d=await api('/v1/analysis-preview?'+new URLSearchParams(selection()));
+ if(seq!==generation)return;preview=d;
+ const text=d.canonical_rows
+ ? `${d.canonical_rows} recorded events · observed ${new Date(d.first_observed_at).toLocaleString()} – ${new Date(d.last_observed_at).toLocaleString()}`
+ : d.retained_rows ? 'No activity in this selection. Saved work is available; choose another period.' : 'No saved evidence yet. Work normally after enabling recording.';
+ document.querySelectorAll('[data-period-summary]').forEach(x=>x.textContent=text);
+ } catch(e) {
+ if(seq!==generation)return;
+ document.querySelectorAll('[data-period-summary]').forEach(x=>x.textContent=e.message);
+ }
+ }
+ async function prepare() {
+ await refresh();
+ if(!preview){window.toast?.('Could not prepare selected evidence. Check the period and dashboard connection.');return;}
+ const d=preview, request=d.request_text;
+ const chosen={scope:d.window.scope,...(d.window.scope==='range'?{since:d.window.since,until:d.window.until}:{})};
+ const access=d.local_ai_history_access || {};
+ const localRead=!d.local_ai_enabled ? 'Local AI access is off.' : access.mode==='off' ? 'Local AI can read the current run only; saved-history access is off.' : `Local saved-history permission: ${access.mode}${access.expires_at?' until '+new Date(access.expires_at).toLocaleString():''}.`;
+ window.openModal?.('Prepare for AI','Discover opportunities from recorded work',`
+
No workflow description or repeated pattern is required. Your AI will interpret the evidence; this panel does not run a model.
+
What the AI will receive
+
${esc(d.canonical_rows)} recorded events · ${esc(d.event_types.focus_span||0)} focus spans · ${esc(d.event_types.capture_gap||0)} unobserved-gap records.
+
Selected: ${esc(d.window.since||'all available history')} to ${esc(d.window.until||'the latest retained evidence')} (end exclusive, UTC). Observed: ${esc(d.first_observed_at||'none')} to ${esc(d.last_observed_at||'none')}.
+
Sources: ${esc(Object.entries(d.sources).map(([k,v])=>`${k}: ${v}`).join(', ')||'none')}. Visible work text is not included in this activity package. Typed values and clipboard contents are not included.
+
${esc(localRead)} Hosted AI availability must be checked separately: it only sees previously shared evidence. Preparing a file does not change those permissions.
median ${fmtDuration(p.median_engaged_seconds)} · total ${fmtDuration(p.total_engaged_seconds)}
When
Steps
Engaged time
How it ended
${runs||'
Execution windows are unavailable for this pattern.
'}
`;
- }).join('') || (typeof window.emptyState==='function' ? window.emptyState('No repeated workflow yet','When you do the same sequence of steps across tools at least twice, it appears here with how long each run took.') : '
No repeated workflow yet.
');
+ }).join('') || (typeof window.emptyState==='function' ? window.emptyState('No repeated pattern found in this run','You can still prepare the recorded chronology for AI analysis.') : '
No repeated pattern found in this run. You can still prepare the recorded chronology for AI analysis.
');
host.querySelectorAll('[data-show-runs]').forEach(btn=>btn.onclick=()=>document.querySelector(`#rich-pattern-${btn.dataset.showRuns}`)?.classList.toggle('expanded'));
host.querySelectorAll('[data-automation]').forEach(btn=>btn.onclick=()=>{if(typeof window.activateTab==='function')window.activateTab('connect');if(typeof window.toast==='function')window.toast('Connect your AI, then ask it to inspect this repeated workflow through OpenWorkGraph MCP.');});
host.querySelectorAll('[data-pattern-export]').forEach(btn=>btn.onclick=()=>{
diff --git a/dashboard/index.html b/dashboard/index.html
index f1a467f1..5937585b 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -130,13 +130,13 @@
—
Current-run pattern runs
—
Key presses
-
Today's timeline
One lane per tool, built from the time each window or site was in front. Browser time is attributed to the site you were on (Gmail, Salesforce) rather than to the browser.
Loading your timeline…
+
Current run timeline
One lane per tool, built from the time each window or site was in front. Browser time is attributed to the site you were on (Gmail, Salesforce) rather than to the browser.
Loading your timeline…
Patterns in this run
This card counts only the current recording run; saved-history workflow examples appear separately below. Both are derived and may disagree until sufficient activity is observed.
-
Time by tool
Engaged time (actively working, not just open) per tool. Browser time is attributed to the site when it is known.
+
Time by tool
Estimated engaged time (recent input, not proof of continuous work) per tool. Browser time is attributed to the site when it is known.
-
Activity
Everything you did in apps and the browser, newest first. Titles keep their context; detected names, email addresses, phone numbers and personal numbers are shown as tokens such as PERSON_1A2B3C. Detection is best effort. Typed text and clipboard contents are never captured. Agent runs are on the Agents tab.
+
Activity
Recorded activity for the selected work period, newest first. Titles keep their context; detected names, email addresses, phone numbers and personal numbers are shown as tokens such as PERSON_1A2B3C. Detection is best effort. Typed text and clipboard contents are never captured. Agent runs are on the Agents tab.
Time
Tool
Page or window
Action
Observed navigation loop
Count
@@ -144,7 +144,7 @@
Browse saved days
-
Activity above shows this recording run. Choose a previous date to inspect your locally retained work without changing what connected AI apps can read. Deletion and retention settings still apply.
+
Activity above uses the selected work period. Choose a previous date to inspect your locally retained work without changing what connected AI apps can read. Deletion and retention settings still apply.
@@ -183,7 +183,7 @@
ChatGPT cloud sharing (optional)
-
Export this run
Exports keep full titles and labels (subjects, documents, projects, companies). Detected names, email addresses, phone numbers and personal identity numbers are replaced with stable tokens such as PERSON_1A2B3C before anything is stored. Name detection is best effort, so review a file before sharing it outside your organization.
XLSX
Best for reviewing evidence in Excel or uploading to an AI.
CSV ZIP
Portable tables for analysis tools.
JSON
Full structured export for code and reproducibility.
Business context such as customer, company and project names stays in the file.
+
Export selected work
Exports keep full titles and labels (subjects, documents, projects, companies). Detected names, email addresses, phone numbers and personal identity numbers are replaced with stable tokens such as PERSON_1A2B3C before anything is stored. Name detection is best effort, so review a file before sharing it outside your organization.
XLSX
Best for reviewing evidence in Excel or uploading to an AI.
CSV ZIP
Portable tables for analysis tools.
JSON
Full structured export for code and reproducibility.
Business context such as customer, company and project names stays in the file.
@@ -203,7 +203,7 @@
ChatGPT cloud sharing (optional)
function updateTabScrollHint(){const tabs=document.querySelector('.tabs');if(!tabs)return;const max=tabs.scrollWidth-tabs.clientWidth;tabs.classList.toggle('more-left',max>2&&tabs.scrollLeft>2);tabs.classList.toggle('more-right',max>2&&tabs.scrollLeftbutton.textContent=old,1400);}}catch(_){window.prompt('Copy this:',text);}}
function b64(s){return btoa(unescape(encodeURIComponent(s))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');}
function connectCursor(){const config=b64(JSON.stringify({url:MCP_ENDPOINT}));window.location.href=`cursor://anysphere.cursor-deeplink/mcp/install?name=OpenWorkGraph&config=${encodeURIComponent(config)}`;}
diff --git a/docs/EXPORTS_AND_AI.md b/docs/EXPORTS_AND_AI.md
index d9bb8d39..e52d4503 100644
--- a/docs/EXPORTS_AND_AI.md
+++ b/docs/EXPORTS_AND_AI.md
@@ -110,7 +110,7 @@ For more conservative analysis, also ask the model to state what evidence would
## AI access is explicit
-MCP reads are **OFF on every OpenWorkGraph launch**.
+New installations default AI access **ON at Redacted**. An explicit user opt-out or reset-on-restart preference is preserved; recording remains a separate choice.
The dashboard's **AI access** switch controls whether MCP tools may read workflow evidence. The server checks that switch on every tool call, so turning access off also blocks an AI client that was already configured or connected.
@@ -258,7 +258,7 @@ An export is a file the user can inspect before uploading.
With MCP, an AI client can decide which tool to call after access has been enabled. **Evidence returned by an MCP call is sent to the AI provider so that model can process it.** Keeping the OpenWorkGraph database local does not mean the returned tool data stays local to the computer.
-The AI-access switch (off on a new install; remembered after a restart unless you choose otherwise) and the local activity view exist to make that boundary explicit.
+The AI-access switch (on at Redacted on a new install; remembered after a restart unless you choose otherwise) and the local activity view exist to make that boundary explicit.
---
@@ -306,3 +306,53 @@ GET /v1/mcp-connection-config
```
All data/control endpoints require the appropriate authenticated local session/capability in normal operation.
+
+## Selected-period AI handoff
+
+Basic mode now has a **Work period** selector on Overview/Today, Activity, AI apps,
+and Export. Choose Today, Last 7 days, Custom dates, This run or All retained work.
+Activity paging and ordinary exports follow this selection. Current-run charts
+remain explicitly labeled as current-run views.
+
+**Prepare for AI** previews the selected and observed dates, event/source counts,
+redacted context examples, local saved-history permission and missing-evidence
+limits. It can copy an opportunity-discovery request or download a redacted CSV
+ZIP with the existing guide, prompt and manifest. It does not invoke a model,
+upload a file, enable recording or grant AI access. No repeated-work candidate or
+upfront workflow description is required. Detailed event evidence is an optional
+choice, separate from visible work-text capture (which is not in these exports).
+
+Custom calendar days use the browser's local timezone and are converted to UTC,
+including daylight-saving changes. The end date is inclusive in the picker;
+the wire-level end timestamp is exclusive.
+
+The existing JSON/XLSX/CSV ZIP route also accepts a bounded selection:
+
+```text
+GET /v1/export/csvzip?scope=range&since=2026-10-12T00:00:00Z&until=2026-10-17T00:00:00Z&include_raw=true&redact_names=true
+```
+
+Both bounds must include a timezone, and end must be after start. Bounds require
+`scope=range`. Existing `scope=current` and `scope=all` calls retain their defaults.
+Download tickets bind the dates as well as format, scope, detail and redaction.
+The same bounds apply to Activity cursor fingerprints and all exported layers.
+Canonical observations are selected by their start timestamp in `[since, until)`;
+original span durations remain intact in canonical evidence. Derived timing uses
+only the portion before the selected end, with proportional aggregate input
+estimates. An observation starting before the window is not fetched implicitly.
+`CAPTURE_MANIFEST.json.window` documents those semantics and actual observed bounds.
+
+Event CSVs append `event_id` after their existing columns, allowing an AI to cite
+specific observations without changing existing column positions. Factual context,
+tasks and work profiles remain derived views, separate from canonical evidence.
+A small or empty repeated-work index does not establish absence of automatable work.
+
+`GET /v1/analysis-preview` is a **dashboard-session-only** preparation endpoint.
+It is not an MCP history-access bypass. Local history availability is not proof
+that a hosted app has received it; hosted availability follows that deployment's
+existing sharing/enrollment boundary. Preparing or downloading an evidence package
+never changes either permission.
+
+On upgrade, the checkpointed title-protection migration revisits retained rows
+to apply the stronger person-search protection and rebuild the derived views.
+Already-tokenized titles and the remaining business context are preserved.
diff --git a/server/ai_context.py b/server/ai_context.py
index 9bfd0d9d..0e77750b 100644
--- a/server/ai_context.py
+++ b/server/ai_context.py
@@ -291,7 +291,11 @@ def learned_token(name: str) -> str | None:
def contextual_text_redactor(settings: dict[str, Any] | None = None):
settings = settings or user_settings()
- return _REDACTOR.get(_detector_state(settings), lambda: _build_detector(settings))
+ from .contextual_redaction_extensions import augment
+ return augment(_REDACTOR.get(_detector_state(settings), lambda: _build_detector(settings)), settings)
+
+
+_DOGFOOD_REDACTION_EXTENSIONS = True # factory includes extensions without HTTP import side effects
def _redact_slug_component(component: str, redact) -> str:
diff --git a/server/analysis_handoff.py b/server/analysis_handoff.py
new file mode 100644
index 00000000..5738acde
--- /dev/null
+++ b/server/analysis_handoff.py
@@ -0,0 +1,62 @@
+"""Local dashboard preview for a user-selected evidence handoff. No AI invocation."""
+from __future__ import annotations
+
+from fastapi import HTTPException, Request
+from .export_window import selection, sql_bounds
+from .exporter import _analysis_request
+from .db import connect
+
+
+def analysis_preview(request: Request, scope: str = "current", since: str | None = None,
+ until: str | None = None) -> dict:
+ # A preparation screen is not another way for an AI to read ungranted history.
+ from .secure_app import _dashboard_authenticated
+ if not _dashboard_authenticated(request):
+ raise HTTPException(status_code=403, detail="Local dashboard session required")
+ try:
+ window = selection(scope, since, until)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from None
+ clause, params = sql_bounds(window["since"], window["until"])
+ where = " WHERE " + clause if clause else ""
+ with connect() as conn:
+ grouped = conn.execute("SELECT event_type, source, COUNT(*) AS count FROM events" + where
+ + " GROUP BY event_type, source", params).fetchall()
+ available = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
+ samples = conn.execute("SELECT event_id, observed_at, app, window_title FROM events" + where
+ + (" AND " if where else " WHERE ") + "event_type='focus_span'"
+ + " ORDER BY julianday(observed_at) DESC LIMIT 6", params).fetchall()
+ bounds = conn.execute("SELECT observed_at FROM events" + where
+ + " ORDER BY julianday(observed_at), id LIMIT 1", params).fetchone()
+ last = conn.execute("SELECT observed_at FROM events" + where
+ + " ORDER BY julianday(observed_at) DESC, id DESC LIMIT 1", params).fetchone()
+ from .ai_context import redact_contextually
+ from shared.history_policy import active_ai_history_access
+ from .work_text_capture import get_policy
+ from .ai_access import ai_access_enabled
+ counts: dict[str, int] = {}
+ sources: dict[str, int] = {}
+ for row in grouped:
+ counts[row["event_type"]] = counts.get(row["event_type"], 0) + row["count"]
+ sources[row["source"]] = sources.get(row["source"], 0) + row["count"]
+ return {
+ "window": window,
+ "first_observed_at": bounds[0] if bounds else None,
+ "last_observed_at": last[0] if last else None,
+ "canonical_rows": sum(counts.values()), "event_types": counts,
+ "sources": sources, "retained_rows": available,
+ "redacted_examples": redact_contextually([dict(x) for x in samples]),
+ "local_ai_enabled": ai_access_enabled(),
+ "local_ai_history_access": active_ai_history_access(),
+ "work_text": {"included_in_activity_package": False,
+ "local_capture_enabled": bool(get_policy().get("capture_enabled"))},
+ "request_text": _analysis_request(window) + "\n\nUse the connected OWG chronology tools, without a search filter, "
+ "for this period and follow their pagination. Respect the currently granted history boundary. "
+ "If these dates are unavailable, explain the missing access rather than widening it. "
+ "An attached evidence package can also be used without MCP.",
+ "destination": "Nothing sent. Choose a file download or copy a request for your AI.",
+ "hosted_history_availability": "Not established by this local preview; hosted apps only see evidence already shared with them.",
+ "limitations": ["Gaps are unobserved intervals, not lost work.",
+ "Observed dates and event counts do not establish complete coverage.",
+ "No repeated-work candidates are required to analyze the chronology."],
+ }
diff --git a/server/analytics.py b/server/analytics.py
index 781f29d0..a1921248 100644
--- a/server/analytics.py
+++ b/server/analytics.py
@@ -905,9 +905,9 @@ def close(end_ts: float, end_reason: str, next_start_reason: str) -> None:
},
}
-def semantic_activity(limit: int = 200, since: str | None = None, *, operational: bool = False) -> list[dict[str, Any]]:
+def semantic_activity(limit: int = 200, since: str | None = None, *, operational: bool = False, _raw_events: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
loader = _operational_event_rows if operational else _event_rows
- events = loader(max(limit * 6, 500), since=since)
+ events = _raw_events if _raw_events is not None else loader(max(limit * 6, 500), since=since)
semantic: list[dict[str, Any]] = []
for e in events:
et = str(e.get("event_type") or "")
@@ -940,8 +940,8 @@ def semantic_activity(limit: int = 200, since: str | None = None, *, operational
return semantic[-max(1, min(limit, 2000)):][::-1]
-def _summary_uncached(limit: int = 10000, since: str | None = None, *, operational: bool = False) -> dict[str, Any]:
- events = _operational_event_rows(limit, since=since) if operational else _event_rows(limit, since=since)
+def _summary_uncached(limit: int = 10000, since: str | None = None, *, operational: bool = False, _raw_events: list[dict[str, Any]] | None = None) -> dict[str, Any]:
+ events = _raw_events if _raw_events is not None else (_operational_event_rows(limit, since=since) if operational else _event_rows(limit, since=since))
focus_events = [e for e in events if e.get("event_type", "focus_span") == "focus_span"]
interaction_events = [e for e in events if str(e.get("event_type", "")).startswith("screen_")]
browser_events = [e for e in events if str(e.get("event_type", "")).startswith("browser_")]
@@ -1124,7 +1124,7 @@ def _summary_uncached(limit: int = 10000, since: str | None = None, *, operation
task_data = candidate_tasks(
limit=limit,
since=since,
- _raw_events=events if not operational else None,
+ _raw_events=events if _raw_events is not None or not operational else None,
)
return {
diff --git a/server/basic_mode_routes.py b/server/basic_mode_routes.py
index d7c429ff..3a14589e 100644
--- a/server/basic_mode_routes.py
+++ b/server/basic_mode_routes.py
@@ -49,7 +49,7 @@ async def _inject_basic_mode_script(request: Request, call_next):
text = body.decode("utf-8")
except Exception:
return response
- marker = ''
+ marker = '\n'
if marker not in text:
text = text.replace("