@agent-native/core 0.114.5 → 0.114.7

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.
Files changed (210) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  11. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  12. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  13. package/corpus/core/src/mcp-client/index.ts +9 -0
  14. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  15. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  16. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  17. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  18. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  19. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  20. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  21. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  22. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  23. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  24. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  25. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  26. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  27. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  28. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  29. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
  30. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  31. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  32. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  33. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  34. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  35. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  47. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  51. package/corpus/templates/clips/desktop/package.json +1 -0
  52. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  53. package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
  54. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  57. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  58. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  60. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  61. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  62. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  63. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  64. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
  99. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  100. package/corpus/templates/content/AGENTS.md +4 -1
  101. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  102. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  103. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  104. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  105. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  106. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  107. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  108. package/corpus/templates/content/actions/delete-document.ts +233 -6
  109. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  110. package/corpus/templates/content/actions/get-document.ts +4 -1
  111. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  112. package/corpus/templates/content/actions/list-documents.ts +1 -0
  113. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  114. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  115. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  116. package/corpus/templates/content/actions/pull-document.ts +15 -2
  117. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  118. package/corpus/templates/content/actions/restore-document.ts +28 -0
  119. package/corpus/templates/content/actions/search-documents.ts +2 -1
  120. package/corpus/templates/content/actions/view-screen.ts +2 -1
  121. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  122. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  123. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  124. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  125. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  126. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  127. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  128. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  129. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  130. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  131. package/corpus/templates/content/app/i18n-data.ts +83 -2
  132. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  133. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  134. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  135. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  136. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  137. package/corpus/templates/content/parity/matrix.md +1 -1
  138. package/corpus/templates/content/parity/matrix.ts +3 -0
  139. package/corpus/templates/content/server/db/schema.ts +2 -0
  140. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  141. package/corpus/templates/content/server/plugins/db.ts +52 -0
  142. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  143. package/corpus/templates/content/shared/api.ts +10 -0
  144. package/corpus/toolkit/CHANGELOG.md +6 -0
  145. package/corpus/toolkit/package.json +1 -1
  146. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  147. package/dist/cli/mcp-config-writers.d.ts +2 -2
  148. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  149. package/dist/cli/mcp-config-writers.js +6 -6
  150. package/dist/cli/mcp-config-writers.js.map +1 -1
  151. package/dist/cli/mcp.d.ts +14 -0
  152. package/dist/cli/mcp.d.ts.map +1 -1
  153. package/dist/cli/mcp.js +79 -0
  154. package/dist/cli/mcp.js.map +1 -1
  155. package/dist/cli/skills-content/help.d.ts +1 -1
  156. package/dist/cli/skills-content/help.d.ts.map +1 -1
  157. package/dist/cli/skills-content/help.js +4 -3
  158. package/dist/cli/skills-content/help.js.map +1 -1
  159. package/dist/cli/skills-content/index.d.ts +1 -0
  160. package/dist/cli/skills-content/index.d.ts.map +1 -1
  161. package/dist/cli/skills-content/index.js +1 -0
  162. package/dist/cli/skills-content/index.js.map +1 -1
  163. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  164. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  165. package/dist/cli/skills-content/rewind-skill.js +53 -0
  166. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  167. package/dist/cli/skills.d.ts +6 -0
  168. package/dist/cli/skills.d.ts.map +1 -1
  169. package/dist/cli/skills.js +90 -14
  170. package/dist/cli/skills.js.map +1 -1
  171. package/dist/client/resources/ResourceTree.d.ts +3 -1
  172. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  173. package/dist/client/resources/ResourceTree.js +4 -4
  174. package/dist/client/resources/ResourceTree.js.map +1 -1
  175. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  176. package/dist/client/resources/ResourcesPanel.js +55 -6
  177. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  178. package/dist/collab/routes.d.ts +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  181. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.js +1014 -91
  183. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  184. package/dist/mcp-client/index.d.ts +1 -1
  185. package/dist/mcp-client/index.d.ts.map +1 -1
  186. package/dist/mcp-client/index.js +1 -1
  187. package/dist/mcp-client/index.js.map +1 -1
  188. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  189. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  190. package/dist/mcp-client/screen-memory-local.js +590 -12
  191. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  192. package/dist/notifications/routes.d.ts +2 -2
  193. package/dist/observability/routes.d.ts +5 -5
  194. package/dist/progress/routes.d.ts +1 -1
  195. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  196. package/dist/resources/handlers.d.ts +1 -1
  197. package/dist/secrets/routes.d.ts +9 -9
  198. package/dist/server/transcribe-voice.d.ts +1 -1
  199. package/package.json +2 -2
  200. package/src/cli/mcp-config-writers.ts +6 -4
  201. package/src/cli/mcp.ts +124 -0
  202. package/src/cli/skills-content/help.ts +4 -3
  203. package/src/cli/skills-content/index.ts +1 -0
  204. package/src/cli/skills-content/rewind-skill.ts +52 -0
  205. package/src/cli/skills.ts +115 -13
  206. package/src/client/resources/ResourceTree.tsx +11 -5
  207. package/src/client/resources/ResourcesPanel.tsx +141 -18
  208. package/src/mcp/screen-memory-stdio.ts +1360 -104
  209. package/src/mcp-client/index.ts +9 -0
  210. package/src/mcp-client/screen-memory-local.ts +847 -13
@@ -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
+ }