@agent-native/core 0.114.6 → 0.114.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +15 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
- package/corpus/core/src/cli/mcp.ts +124 -0
- package/corpus/core/src/cli/skills-content/help.ts +4 -3
- package/corpus/core/src/cli/skills-content/index.ts +1 -0
- package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
- package/corpus/core/src/cli/skills.ts +115 -13
- package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
- package/corpus/core/src/mcp-client/index.ts +9 -0
- package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
- package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
- package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
- package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
- package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
- package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
- package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
- package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
- package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
- package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
- package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
- package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
- package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
- package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
- package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
- package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
- package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
- package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
- package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
- package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
- package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
- package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
- package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
- package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
- package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
- package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
- package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
- package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
- package/corpus/templates/clips/desktop/src/styles.css +956 -134
- package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
- package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
- package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
- package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
- package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
- package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
- package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
- package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
- package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
- package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
- package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
- package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
- package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/dist/cli/mcp-config-writers.d.ts +2 -2
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +6 -6
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -1
- package/dist/cli/mcp.js +79 -0
- package/dist/cli/mcp.js.map +1 -1
- package/dist/cli/skills-content/help.d.ts +1 -1
- package/dist/cli/skills-content/help.d.ts.map +1 -1
- package/dist/cli/skills-content/help.js +4 -3
- package/dist/cli/skills-content/help.js.map +1 -1
- package/dist/cli/skills-content/index.d.ts +1 -0
- package/dist/cli/skills-content/index.d.ts.map +1 -1
- package/dist/cli/skills-content/index.js +1 -0
- package/dist/cli/skills-content/index.js.map +1 -1
- package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
- package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
- package/dist/cli/skills-content/rewind-skill.js +53 -0
- package/dist/cli/skills-content/rewind-skill.js.map +1 -0
- package/dist/cli/skills.d.ts +6 -0
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +90 -14
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/screen-memory-stdio.d.ts +365 -0
- package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
- package/dist/mcp/screen-memory-stdio.js +1014 -91
- package/dist/mcp/screen-memory-stdio.js.map +1 -1
- package/dist/mcp-client/index.d.ts +1 -1
- package/dist/mcp-client/index.d.ts.map +1 -1
- package/dist/mcp-client/index.js +1 -1
- package/dist/mcp-client/index.js.map +1 -1
- package/dist/mcp-client/screen-memory-local.d.ts +90 -0
- package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
- package/dist/mcp-client/screen-memory-local.js +590 -12
- package/dist/mcp-client/screen-memory-local.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/provider-api.d.ts +4 -4
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/agent-chat-plugin.d.ts +5 -0
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +10 -9
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/mcp-config-writers.ts +6 -4
- package/src/cli/mcp.ts +124 -0
- package/src/cli/skills-content/help.ts +4 -3
- package/src/cli/skills-content/index.ts +1 -0
- package/src/cli/skills-content/rewind-skill.ts +52 -0
- package/src/cli/skills.ts +115 -13
- package/src/mcp/screen-memory-stdio.ts +1360 -104
- package/src/mcp-client/index.ts +9 -0
- package/src/mcp-client/screen-memory-local.ts +847 -13
- package/src/server/agent-chat-plugin.ts +16 -9
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
//! Local validation and activity trail for bounded Rewind evidence.
|
|
2
|
+
//!
|
|
3
|
+
//! This module never sends a network request. It only validates a bounded text
|
|
4
|
+
//! packet and records intent before a future caller is allowed to send it.
|
|
5
|
+
|
|
6
|
+
use chrono::Utc;
|
|
7
|
+
use serde::{Deserialize, Serialize};
|
|
8
|
+
use serde_json::{json, Value};
|
|
9
|
+
use std::fs::{File, OpenOptions};
|
|
10
|
+
use std::io::{BufRead, BufReader, Write};
|
|
11
|
+
use std::path::PathBuf;
|
|
12
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
13
|
+
use std::sync::Mutex;
|
|
14
|
+
use tauri::{AppHandle, Manager};
|
|
15
|
+
|
|
16
|
+
const EGRESS_LOG_NAME: &str = "egress.jsonl";
|
|
17
|
+
const MAX_QUESTION_CHARS: usize = 4_000;
|
|
18
|
+
const MAX_EVIDENCE_ITEMS: usize = 20;
|
|
19
|
+
const MAX_EXCERPT_CHARS: usize = 1_200;
|
|
20
|
+
const MAX_ID_CHARS: usize = 200;
|
|
21
|
+
const MAX_TIMESTAMP_CHARS: usize = 80;
|
|
22
|
+
const MAX_ERROR_CHARS: usize = 1_000;
|
|
23
|
+
const MAX_LIST_LIMIT: usize = 500;
|
|
24
|
+
|
|
25
|
+
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
26
|
+
static EGRESS_IO_LOCK: Mutex<()> = Mutex::new(());
|
|
27
|
+
|
|
28
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
29
|
+
#[serde(rename_all = "kebab-case")]
|
|
30
|
+
pub enum RewindEvidenceSource {
|
|
31
|
+
AppContext,
|
|
32
|
+
Transcript,
|
|
33
|
+
Ocr,
|
|
34
|
+
Chapter,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
38
|
+
#[serde(rename_all = "camelCase")]
|
|
39
|
+
pub struct RewindEvidenceForEgress {
|
|
40
|
+
pub id: String,
|
|
41
|
+
pub moment_id: String,
|
|
42
|
+
pub source_type: RewindEvidenceSource,
|
|
43
|
+
pub captured_at: Option<String>,
|
|
44
|
+
pub excerpt: String,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
48
|
+
#[serde(rename_all = "camelCase")]
|
|
49
|
+
pub struct RewindEvidencePacket {
|
|
50
|
+
pub question: String,
|
|
51
|
+
pub evidence: Vec<RewindEvidenceForEgress>,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
55
|
+
#[serde(rename_all = "kebab-case")]
|
|
56
|
+
pub enum RewindEgressState {
|
|
57
|
+
Prepared,
|
|
58
|
+
Completed,
|
|
59
|
+
Failed,
|
|
60
|
+
LocalEvidenceRead,
|
|
61
|
+
HandoffRequested,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
65
|
+
#[serde(rename_all = "camelCase")]
|
|
66
|
+
pub struct RewindEgressEvent {
|
|
67
|
+
pub request_id: String,
|
|
68
|
+
pub occurred_at: String,
|
|
69
|
+
pub state: RewindEgressState,
|
|
70
|
+
#[serde(default)]
|
|
71
|
+
pub packet: Option<RewindEvidencePacket>,
|
|
72
|
+
#[serde(default)]
|
|
73
|
+
pub operation: Option<String>,
|
|
74
|
+
#[serde(default)]
|
|
75
|
+
pub receipt: Option<Value>,
|
|
76
|
+
pub evidence_count: usize,
|
|
77
|
+
#[serde(default)]
|
|
78
|
+
pub packet_bytes: usize,
|
|
79
|
+
pub error: Option<String>,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[derive(Debug, Clone, Serialize)]
|
|
83
|
+
#[serde(rename_all = "camelCase")]
|
|
84
|
+
pub struct RewindEgressPrepared {
|
|
85
|
+
pub request_id: String,
|
|
86
|
+
pub packet: RewindEvidencePacket,
|
|
87
|
+
pub evidence_count: usize,
|
|
88
|
+
pub packet_bytes: usize,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
struct EgressStore {
|
|
92
|
+
path: PathBuf,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl EgressStore {
|
|
96
|
+
fn new(path: PathBuf) -> Self {
|
|
97
|
+
Self { path }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn append(&self, event: &RewindEgressEvent) -> Result<(), String> {
|
|
101
|
+
let _guard = EGRESS_IO_LOCK
|
|
102
|
+
.lock()
|
|
103
|
+
.map_err(|err| format!("Rewind egress log lock failed: {err}"))?;
|
|
104
|
+
self.append_unlocked(event)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn append_unlocked(&self, event: &RewindEgressEvent) -> Result<(), String> {
|
|
108
|
+
if let Some(parent) = self.path.parent() {
|
|
109
|
+
std::fs::create_dir_all(parent)
|
|
110
|
+
.map_err(|err| format!("Rewind egress log directory unavailable: {err}"))?;
|
|
111
|
+
}
|
|
112
|
+
let mut file = OpenOptions::new()
|
|
113
|
+
.create(true)
|
|
114
|
+
.append(true)
|
|
115
|
+
.open(&self.path)
|
|
116
|
+
.map_err(|err| format!("Rewind egress log unavailable: {err}"))?;
|
|
117
|
+
serde_json::to_writer(&mut file, event)
|
|
118
|
+
.map_err(|err| format!("Rewind egress event encoding failed: {err}"))?;
|
|
119
|
+
file.write_all(b"\n")
|
|
120
|
+
.and_then(|_| file.flush())
|
|
121
|
+
.map_err(|err| format!("Rewind egress event write failed: {err}"))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
fn scrub_legacy_content(&self) -> Result<(), String> {
|
|
125
|
+
let _guard = EGRESS_IO_LOCK
|
|
126
|
+
.lock()
|
|
127
|
+
.map_err(|err| format!("Rewind egress log lock failed: {err}"))?;
|
|
128
|
+
if !self.path.exists() {
|
|
129
|
+
return Ok(());
|
|
130
|
+
}
|
|
131
|
+
let file = File::open(&self.path)
|
|
132
|
+
.map_err(|err| format!("Rewind egress log unavailable: {err}"))?;
|
|
133
|
+
let mut changed = false;
|
|
134
|
+
let events = BufReader::new(file)
|
|
135
|
+
.lines()
|
|
136
|
+
.map_while(Result::ok)
|
|
137
|
+
.filter_map(|line| {
|
|
138
|
+
let mut event: Value = serde_json::from_str(&line).ok()?;
|
|
139
|
+
let object = event.as_object_mut()?;
|
|
140
|
+
if let Some(packet) = object.remove("packet") {
|
|
141
|
+
changed = true;
|
|
142
|
+
if !object.contains_key("receipt") {
|
|
143
|
+
let evidence = packet
|
|
144
|
+
.get("evidence")
|
|
145
|
+
.and_then(Value::as_array)
|
|
146
|
+
.map(|items| {
|
|
147
|
+
items
|
|
148
|
+
.iter()
|
|
149
|
+
.map(|item| {
|
|
150
|
+
json!({
|
|
151
|
+
"id": item.get("id"),
|
|
152
|
+
"momentId": item.get("momentId"),
|
|
153
|
+
"sourceType": item.get("sourceType"),
|
|
154
|
+
"capturedAt": item.get("capturedAt").unwrap_or(&Value::Null),
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
.collect::<Vec<_>>()
|
|
158
|
+
})
|
|
159
|
+
.unwrap_or_default();
|
|
160
|
+
object.insert("receipt".to_string(), json!({ "evidence": evidence }));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if object.remove("reason").is_some() {
|
|
164
|
+
changed = true;
|
|
165
|
+
}
|
|
166
|
+
object
|
|
167
|
+
.entry("packetBytes".to_string())
|
|
168
|
+
.or_insert(json!(0));
|
|
169
|
+
Some(event)
|
|
170
|
+
})
|
|
171
|
+
.collect::<Vec<_>>();
|
|
172
|
+
if !changed {
|
|
173
|
+
return Ok(());
|
|
174
|
+
}
|
|
175
|
+
let temporary_path = self
|
|
176
|
+
.path
|
|
177
|
+
.with_extension(format!("jsonl.sanitize-{}", std::process::id()));
|
|
178
|
+
let mut temporary = OpenOptions::new()
|
|
179
|
+
.create(true)
|
|
180
|
+
.truncate(true)
|
|
181
|
+
.write(true)
|
|
182
|
+
.open(&temporary_path)
|
|
183
|
+
.map_err(|err| format!("Rewind egress migration unavailable: {err}"))?;
|
|
184
|
+
for event in events {
|
|
185
|
+
serde_json::to_writer(&mut temporary, &event)
|
|
186
|
+
.map_err(|err| format!("Rewind egress migration encoding failed: {err}"))?;
|
|
187
|
+
temporary
|
|
188
|
+
.write_all(b"\n")
|
|
189
|
+
.map_err(|err| format!("Rewind egress migration write failed: {err}"))?;
|
|
190
|
+
}
|
|
191
|
+
temporary
|
|
192
|
+
.flush()
|
|
193
|
+
.map_err(|err| format!("Rewind egress migration write failed: {err}"))?;
|
|
194
|
+
std::fs::rename(&temporary_path, &self.path)
|
|
195
|
+
.map_err(|err| format!("Rewind egress migration failed: {err}"))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn read_all(&self) -> Result<Vec<RewindEgressEvent>, String> {
|
|
199
|
+
let _guard = EGRESS_IO_LOCK
|
|
200
|
+
.lock()
|
|
201
|
+
.map_err(|err| format!("Rewind egress log lock failed: {err}"))?;
|
|
202
|
+
self.read_all_unlocked()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn read_all_unlocked(&self) -> Result<Vec<RewindEgressEvent>, String> {
|
|
206
|
+
if !self.path.exists() {
|
|
207
|
+
return Ok(Vec::new());
|
|
208
|
+
}
|
|
209
|
+
let file = File::open(&self.path)
|
|
210
|
+
.map_err(|err| format!("Rewind egress log unavailable: {err}"))?;
|
|
211
|
+
Ok(BufReader::new(file)
|
|
212
|
+
.lines()
|
|
213
|
+
.map_while(Result::ok)
|
|
214
|
+
.filter_map(|line| serde_json::from_str(&line).ok())
|
|
215
|
+
.collect())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
fn terminal_event(
|
|
219
|
+
&self,
|
|
220
|
+
request_id: &str,
|
|
221
|
+
state: RewindEgressState,
|
|
222
|
+
error: Option<String>,
|
|
223
|
+
) -> Result<RewindEgressEvent, String> {
|
|
224
|
+
validate_id("request id", request_id)?;
|
|
225
|
+
let _guard = EGRESS_IO_LOCK
|
|
226
|
+
.lock()
|
|
227
|
+
.map_err(|err| format!("Rewind egress log lock failed: {err}"))?;
|
|
228
|
+
let events = self.read_all_unlocked()?;
|
|
229
|
+
let prepared = events.iter().find(|event| {
|
|
230
|
+
event.request_id == request_id && event.state == RewindEgressState::Prepared
|
|
231
|
+
});
|
|
232
|
+
let Some(prepared) = prepared else {
|
|
233
|
+
return Err("Unknown Rewind egress request id.".to_string());
|
|
234
|
+
};
|
|
235
|
+
if events.iter().any(|event| {
|
|
236
|
+
event.request_id == request_id && event.state != RewindEgressState::Prepared
|
|
237
|
+
}) {
|
|
238
|
+
return Err("Rewind egress request is already complete.".to_string());
|
|
239
|
+
}
|
|
240
|
+
let event = RewindEgressEvent {
|
|
241
|
+
request_id: request_id.to_string(),
|
|
242
|
+
occurred_at: now_iso(),
|
|
243
|
+
state,
|
|
244
|
+
packet: None,
|
|
245
|
+
operation: prepared.operation.clone(),
|
|
246
|
+
receipt: None,
|
|
247
|
+
evidence_count: prepared.evidence_count,
|
|
248
|
+
packet_bytes: prepared.packet_bytes,
|
|
249
|
+
error,
|
|
250
|
+
};
|
|
251
|
+
self.append_unlocked(&event)?;
|
|
252
|
+
Ok(event)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fn egress_store(app: &AppHandle) -> Result<EgressStore, String> {
|
|
257
|
+
let dir = app
|
|
258
|
+
.path()
|
|
259
|
+
.app_data_dir()
|
|
260
|
+
.map_err(|err| format!("app data directory unavailable: {err}"))?
|
|
261
|
+
.join("screen-memory");
|
|
262
|
+
let store = EgressStore::new(dir.join(EGRESS_LOG_NAME));
|
|
263
|
+
store.scrub_legacy_content()?;
|
|
264
|
+
Ok(store)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
fn now_iso() -> String {
|
|
268
|
+
Utc::now().to_rfc3339()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
fn next_request_id() -> String {
|
|
272
|
+
let counter = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
273
|
+
format!("egress-{}-{counter}", Utc::now().timestamp_millis())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
fn char_count(value: &str) -> usize {
|
|
277
|
+
value.chars().count()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fn validate_id(label: &str, value: &str) -> Result<(), String> {
|
|
281
|
+
if value.trim().is_empty() || char_count(value) > MAX_ID_CHARS {
|
|
282
|
+
return Err(format!("{label} must be 1-{MAX_ID_CHARS} characters."));
|
|
283
|
+
}
|
|
284
|
+
Ok(())
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fn contains_encoded_media(value: &str) -> bool {
|
|
288
|
+
let value = value.to_ascii_lowercase();
|
|
289
|
+
["data:image/", "data:video/", "data:audio/", ";base64,"]
|
|
290
|
+
.iter()
|
|
291
|
+
.any(|marker| value.contains(marker))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fn looks_like_credential_token(value: &str) -> bool {
|
|
295
|
+
let trimmed = value.trim_matches(|ch: char| {
|
|
296
|
+
ch.is_ascii_whitespace() || matches!(ch, ',' | ';' | '"' | '\'' | '(' | ')' | '[' | ']')
|
|
297
|
+
});
|
|
298
|
+
(trimmed.starts_with("sk-") && trimmed.len() >= 15)
|
|
299
|
+
|| (["ghp_", "gho_", "ghu_", "ghs_", "ghr_"]
|
|
300
|
+
.iter()
|
|
301
|
+
.any(|prefix| trimmed.starts_with(prefix))
|
|
302
|
+
&& trimmed.len() >= 16)
|
|
303
|
+
|| (trimmed.starts_with("AKIA")
|
|
304
|
+
&& trimmed.len() == 20
|
|
305
|
+
&& trimmed
|
|
306
|
+
.chars()
|
|
307
|
+
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit()))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
fn redact_obvious_credentials(value: &str) -> String {
|
|
311
|
+
let mut redact_next_bearer = false;
|
|
312
|
+
value
|
|
313
|
+
.split_whitespace()
|
|
314
|
+
.map(|token| {
|
|
315
|
+
if redact_next_bearer {
|
|
316
|
+
redact_next_bearer = false;
|
|
317
|
+
return "[REDACTED]".to_string();
|
|
318
|
+
}
|
|
319
|
+
if token.eq_ignore_ascii_case("bearer") {
|
|
320
|
+
redact_next_bearer = true;
|
|
321
|
+
return token.to_string();
|
|
322
|
+
}
|
|
323
|
+
let lower = token.to_ascii_lowercase();
|
|
324
|
+
let assignment = [
|
|
325
|
+
"api_key=",
|
|
326
|
+
"api-key=",
|
|
327
|
+
"access_token=",
|
|
328
|
+
"access-token=",
|
|
329
|
+
"password=",
|
|
330
|
+
"secret=",
|
|
331
|
+
]
|
|
332
|
+
.iter()
|
|
333
|
+
.find(|prefix| lower.starts_with(**prefix));
|
|
334
|
+
if let Some(prefix) = assignment {
|
|
335
|
+
return format!("{}[REDACTED]", &token[..prefix.len()]);
|
|
336
|
+
}
|
|
337
|
+
if looks_like_credential_token(token) {
|
|
338
|
+
"[REDACTED CREDENTIAL]".to_string()
|
|
339
|
+
} else {
|
|
340
|
+
token.to_string()
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
.collect::<Vec<_>>()
|
|
344
|
+
.join(" ")
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
fn validate_packet(
|
|
348
|
+
mut packet: RewindEvidencePacket,
|
|
349
|
+
includes_frames: bool,
|
|
350
|
+
) -> Result<RewindEvidencePacket, String> {
|
|
351
|
+
if includes_frames {
|
|
352
|
+
return Err(
|
|
353
|
+
"Frame egress requires per-question approval and is not supported by this text gate."
|
|
354
|
+
.to_string(),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
packet.question = packet.question.trim().to_string();
|
|
358
|
+
if packet.question.is_empty() || char_count(&packet.question) > MAX_QUESTION_CHARS {
|
|
359
|
+
return Err(format!(
|
|
360
|
+
"Question must be 1-{MAX_QUESTION_CHARS} characters."
|
|
361
|
+
));
|
|
362
|
+
}
|
|
363
|
+
if contains_encoded_media(&packet.question) {
|
|
364
|
+
return Err("Rewind text packets cannot contain encoded media.".to_string());
|
|
365
|
+
}
|
|
366
|
+
packet.question = redact_obvious_credentials(&packet.question);
|
|
367
|
+
if packet.evidence.is_empty() || packet.evidence.len() > MAX_EVIDENCE_ITEMS {
|
|
368
|
+
return Err(format!(
|
|
369
|
+
"Evidence packet must contain 1-{MAX_EVIDENCE_ITEMS} text items."
|
|
370
|
+
));
|
|
371
|
+
}
|
|
372
|
+
for evidence in &mut packet.evidence {
|
|
373
|
+
evidence.id = evidence.id.trim().to_string();
|
|
374
|
+
evidence.moment_id = evidence.moment_id.trim().to_string();
|
|
375
|
+
evidence.excerpt = evidence.excerpt.trim().to_string();
|
|
376
|
+
validate_id("Evidence id", &evidence.id)?;
|
|
377
|
+
validate_id("Moment id", &evidence.moment_id)?;
|
|
378
|
+
if evidence.excerpt.is_empty() || char_count(&evidence.excerpt) > MAX_EXCERPT_CHARS {
|
|
379
|
+
return Err(format!(
|
|
380
|
+
"Evidence excerpt must be 1-{MAX_EXCERPT_CHARS} characters."
|
|
381
|
+
));
|
|
382
|
+
}
|
|
383
|
+
if contains_encoded_media(&evidence.excerpt) {
|
|
384
|
+
return Err("Rewind text packets cannot contain encoded media.".to_string());
|
|
385
|
+
}
|
|
386
|
+
evidence.excerpt = redact_obvious_credentials(&evidence.excerpt);
|
|
387
|
+
if let Some(timestamp) = &mut evidence.captured_at {
|
|
388
|
+
*timestamp = timestamp.trim().to_string();
|
|
389
|
+
if timestamp.is_empty() || char_count(timestamp) > MAX_TIMESTAMP_CHARS {
|
|
390
|
+
return Err("Evidence timestamp is invalid.".to_string());
|
|
391
|
+
}
|
|
392
|
+
chrono::DateTime::parse_from_rfc3339(timestamp)
|
|
393
|
+
.map_err(|_| "Evidence timestamp must be RFC3339.".to_string())?;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
Ok(packet)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
fn prepare_evidence(
|
|
400
|
+
store: &EgressStore,
|
|
401
|
+
packet: RewindEvidencePacket,
|
|
402
|
+
includes_frames: bool,
|
|
403
|
+
) -> Result<RewindEgressPrepared, String> {
|
|
404
|
+
let packet = validate_packet(packet, includes_frames)?;
|
|
405
|
+
let packet_bytes = serde_json::to_vec(&packet)
|
|
406
|
+
.map_err(|err| format!("Rewind evidence packet encoding failed: {err}"))?
|
|
407
|
+
.len();
|
|
408
|
+
let request_id = next_request_id();
|
|
409
|
+
let event = RewindEgressEvent {
|
|
410
|
+
request_id: request_id.clone(),
|
|
411
|
+
occurred_at: now_iso(),
|
|
412
|
+
state: RewindEgressState::Prepared,
|
|
413
|
+
packet: None,
|
|
414
|
+
operation: Some("agent-query".to_string()),
|
|
415
|
+
receipt: Some(json!({
|
|
416
|
+
"evidence": packet.evidence.iter().map(|evidence| json!({
|
|
417
|
+
"id": evidence.id,
|
|
418
|
+
"momentId": evidence.moment_id,
|
|
419
|
+
"sourceType": evidence.source_type,
|
|
420
|
+
"capturedAt": evidence.captured_at,
|
|
421
|
+
})).collect::<Vec<_>>(),
|
|
422
|
+
})),
|
|
423
|
+
evidence_count: packet.evidence.len(),
|
|
424
|
+
packet_bytes,
|
|
425
|
+
error: None,
|
|
426
|
+
};
|
|
427
|
+
// This durable append is deliberately before the packet is returned to a
|
|
428
|
+
// caller that might send it.
|
|
429
|
+
store.append(&event)?;
|
|
430
|
+
Ok(RewindEgressPrepared {
|
|
431
|
+
request_id,
|
|
432
|
+
evidence_count: packet.evidence.len(),
|
|
433
|
+
packet_bytes,
|
|
434
|
+
packet,
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[tauri::command]
|
|
439
|
+
pub async fn rewind_prepare_evidence_egress(
|
|
440
|
+
app: AppHandle,
|
|
441
|
+
packet: RewindEvidencePacket,
|
|
442
|
+
includes_frames: Option<bool>,
|
|
443
|
+
) -> Result<RewindEgressPrepared, String> {
|
|
444
|
+
prepare_evidence(
|
|
445
|
+
&egress_store(&app)?,
|
|
446
|
+
packet,
|
|
447
|
+
includes_frames.unwrap_or(false),
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#[tauri::command]
|
|
452
|
+
pub async fn rewind_complete_evidence_egress(
|
|
453
|
+
app: AppHandle,
|
|
454
|
+
request_id: String,
|
|
455
|
+
) -> Result<RewindEgressEvent, String> {
|
|
456
|
+
egress_store(&app)?.terminal_event(&request_id, RewindEgressState::Completed, None)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[tauri::command]
|
|
460
|
+
pub async fn rewind_fail_evidence_egress(
|
|
461
|
+
app: AppHandle,
|
|
462
|
+
request_id: String,
|
|
463
|
+
error: String,
|
|
464
|
+
) -> Result<RewindEgressEvent, String> {
|
|
465
|
+
let error = error.trim();
|
|
466
|
+
if error.is_empty() || char_count(error) > MAX_ERROR_CHARS {
|
|
467
|
+
return Err(format!("Error must be 1-{MAX_ERROR_CHARS} characters."));
|
|
468
|
+
}
|
|
469
|
+
egress_store(&app)?.terminal_event(
|
|
470
|
+
&request_id,
|
|
471
|
+
RewindEgressState::Failed,
|
|
472
|
+
Some(error.to_string()),
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#[tauri::command]
|
|
477
|
+
pub async fn rewind_list_evidence_egress(
|
|
478
|
+
app: AppHandle,
|
|
479
|
+
limit: Option<usize>,
|
|
480
|
+
) -> Result<Vec<RewindEgressEvent>, String> {
|
|
481
|
+
let mut events = egress_store(&app)?.read_all()?;
|
|
482
|
+
events.reverse();
|
|
483
|
+
events.truncate(limit.unwrap_or(100).clamp(1, MAX_LIST_LIMIT));
|
|
484
|
+
Ok(events)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#[cfg(test)]
|
|
488
|
+
mod tests {
|
|
489
|
+
use super::*;
|
|
490
|
+
|
|
491
|
+
fn temp_store(name: &str) -> EgressStore {
|
|
492
|
+
let dir = std::env::temp_dir().join(format!(
|
|
493
|
+
"clips-rewind-egress-{name}-{}-{}",
|
|
494
|
+
std::process::id(),
|
|
495
|
+
REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed)
|
|
496
|
+
));
|
|
497
|
+
let _ = std::fs::remove_dir_all(&dir);
|
|
498
|
+
EgressStore::new(dir.join(EGRESS_LOG_NAME))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn packet() -> RewindEvidencePacket {
|
|
502
|
+
RewindEvidencePacket {
|
|
503
|
+
question: "What happened?".to_string(),
|
|
504
|
+
evidence: vec![RewindEvidenceForEgress {
|
|
505
|
+
id: "evidence-1".to_string(),
|
|
506
|
+
moment_id: "moment-1".to_string(),
|
|
507
|
+
source_type: RewindEvidenceSource::Transcript,
|
|
508
|
+
captured_at: Some("2026-07-14T12:00:00Z".to_string()),
|
|
509
|
+
excerpt: "A bounded local excerpt.".to_string(),
|
|
510
|
+
}],
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#[test]
|
|
515
|
+
fn redacts_obvious_credentials_before_logging_or_returning_packets() {
|
|
516
|
+
let store = temp_store("redaction");
|
|
517
|
+
let mut packet = packet();
|
|
518
|
+
packet.evidence[0].excerpt =
|
|
519
|
+
"api_key=super-secret Bearer abcdefghijklmnop sk-abcdefghijklmnop".into();
|
|
520
|
+
let prepared = prepare_evidence(&store, packet, false).unwrap();
|
|
521
|
+
assert_eq!(
|
|
522
|
+
prepared.packet.evidence[0].excerpt,
|
|
523
|
+
"api_key=[REDACTED] Bearer [REDACTED] [REDACTED CREDENTIAL]"
|
|
524
|
+
);
|
|
525
|
+
let logged = store.read_all().unwrap();
|
|
526
|
+
assert!(logged[0].packet.is_none());
|
|
527
|
+
assert_eq!(logged[0].operation.as_deref(), Some("agent-query"));
|
|
528
|
+
assert_eq!(
|
|
529
|
+
logged[0].receipt.as_ref().unwrap()["evidence"][0]["id"],
|
|
530
|
+
"evidence-1"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#[test]
|
|
535
|
+
fn reads_agent_neutral_chapter_and_local_evidence_activity() {
|
|
536
|
+
let chapter: RewindEgressEvent = serde_json::from_str(
|
|
537
|
+
r#"{"requestId":"chapter-1","occurredAt":"2026-01-01T00:00:00Z","state":"prepared","packet":{"question":"recent work","evidence":[{"id":"c1","momentId":"c1","sourceType":"chapter","capturedAt":"2026-01-01T00:00:00Z","excerpt":"Editor work"}]},"evidenceCount":1,"packetBytes":10,"error":null}"#,
|
|
538
|
+
)
|
|
539
|
+
.unwrap();
|
|
540
|
+
assert_eq!(
|
|
541
|
+
chapter.packet.unwrap().evidence[0].source_type,
|
|
542
|
+
RewindEvidenceSource::Chapter
|
|
543
|
+
);
|
|
544
|
+
let local: RewindEgressEvent = serde_json::from_str(
|
|
545
|
+
r#"{"requestId":"local-1","occurredAt":"2026-01-01T00:00:00Z","state":"local-evidence-read","packet":{"question":"inspect frame","evidence":[]},"evidenceCount":1,"packetBytes":0,"error":null}"#,
|
|
546
|
+
)
|
|
547
|
+
.unwrap();
|
|
548
|
+
assert_eq!(local.state, RewindEgressState::LocalEvidenceRead);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[test]
|
|
552
|
+
fn frames_fail_before_writing() {
|
|
553
|
+
let store = temp_store("frames");
|
|
554
|
+
assert!(prepare_evidence(&store, packet(), true)
|
|
555
|
+
.unwrap_err()
|
|
556
|
+
.contains("approval"));
|
|
557
|
+
assert!(store.read_all().unwrap().is_empty());
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
#[test]
|
|
561
|
+
fn prepared_event_precedes_one_terminal_event() {
|
|
562
|
+
let store = temp_store("complete");
|
|
563
|
+
let prepared = prepare_evidence(&store, packet(), false).unwrap();
|
|
564
|
+
let completed = store
|
|
565
|
+
.terminal_event(&prepared.request_id, RewindEgressState::Completed, None)
|
|
566
|
+
.unwrap();
|
|
567
|
+
let events = store.read_all().unwrap();
|
|
568
|
+
assert_eq!(events.len(), 2);
|
|
569
|
+
assert_eq!(events[0].state, RewindEgressState::Prepared);
|
|
570
|
+
assert_eq!(events[1].state, RewindEgressState::Completed);
|
|
571
|
+
assert!(events[0].packet.is_none());
|
|
572
|
+
assert!(events[0].receipt.is_some());
|
|
573
|
+
assert!(completed.packet.is_none());
|
|
574
|
+
assert!(store
|
|
575
|
+
.terminal_event(
|
|
576
|
+
&prepared.request_id,
|
|
577
|
+
RewindEgressState::Failed,
|
|
578
|
+
Some("late".into())
|
|
579
|
+
)
|
|
580
|
+
.is_err());
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
#[test]
|
|
584
|
+
fn scrubs_legacy_packet_text_while_preserving_provenance() {
|
|
585
|
+
let store = temp_store("legacy-scrub");
|
|
586
|
+
if let Some(parent) = store.path.parent() {
|
|
587
|
+
std::fs::create_dir_all(parent).unwrap();
|
|
588
|
+
}
|
|
589
|
+
std::fs::write(
|
|
590
|
+
&store.path,
|
|
591
|
+
r#"{"requestId":"legacy-1","occurredAt":"2026-01-01T00:00:00Z","state":"prepared","packet":{"question":"private question","evidence":[{"id":"e1","momentId":"m1","sourceType":"transcript","capturedAt":"2026-01-01T00:00:00Z","excerpt":"private excerpt"}]},"evidenceCount":1,"packetBytes":42,"error":null}
|
|
592
|
+
"#,
|
|
593
|
+
)
|
|
594
|
+
.unwrap();
|
|
595
|
+
|
|
596
|
+
store.scrub_legacy_content().unwrap();
|
|
597
|
+
|
|
598
|
+
let raw = std::fs::read_to_string(&store.path).unwrap();
|
|
599
|
+
assert!(!raw.contains("private question"));
|
|
600
|
+
assert!(!raw.contains("private excerpt"));
|
|
601
|
+
let events = store.read_all().unwrap();
|
|
602
|
+
assert_eq!(
|
|
603
|
+
events[0].receipt.as_ref().unwrap()["evidence"][0]["id"],
|
|
604
|
+
"e1"
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#[test]
|
|
609
|
+
fn failure_and_unknown_ids_are_auditable_and_bounded() {
|
|
610
|
+
let store = temp_store("failure");
|
|
611
|
+
assert!(store
|
|
612
|
+
.terminal_event("missing", RewindEgressState::Failed, Some("no".into()))
|
|
613
|
+
.is_err());
|
|
614
|
+
let prepared = prepare_evidence(&store, packet(), false).unwrap();
|
|
615
|
+
store
|
|
616
|
+
.terminal_event(
|
|
617
|
+
&prepared.request_id,
|
|
618
|
+
RewindEgressState::Failed,
|
|
619
|
+
Some("network unavailable".into()),
|
|
620
|
+
)
|
|
621
|
+
.unwrap();
|
|
622
|
+
assert_eq!(
|
|
623
|
+
store.read_all().unwrap()[1].state,
|
|
624
|
+
RewindEgressState::Failed
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
let mut too_many = packet();
|
|
628
|
+
too_many.evidence = (0..=MAX_EVIDENCE_ITEMS)
|
|
629
|
+
.map(|index| RewindEvidenceForEgress {
|
|
630
|
+
id: format!("e-{index}"),
|
|
631
|
+
moment_id: format!("m-{index}"),
|
|
632
|
+
source_type: RewindEvidenceSource::Ocr,
|
|
633
|
+
captured_at: None,
|
|
634
|
+
excerpt: "text".into(),
|
|
635
|
+
})
|
|
636
|
+
.collect();
|
|
637
|
+
assert!(prepare_evidence(&store, too_many, false).is_err());
|
|
638
|
+
|
|
639
|
+
let mut encoded = packet();
|
|
640
|
+
encoded.evidence[0].excerpt = "data:image/png;base64,example".into();
|
|
641
|
+
assert!(prepare_evidence(&store, encoded, false).is_err());
|
|
642
|
+
}
|
|
643
|
+
}
|