@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
@@ -1,10 +1,14 @@
1
- use crate::config::{FeatureConfig, ScreenMemoryConfig};
1
+ use crate::capture_graph::{
2
+ CaptureConsumer, CaptureGraphError, CaptureGraphState, CaptureSource, CoverageGapReason,
3
+ };
4
+ use crate::config::{FeatureConfig, RewindCaptureMode, ScreenMemoryConfig};
2
5
  use crate::native_screen::{
3
6
  self, NativeFullscreenBackend, DISK_SPACE_BLOCK_BYTES, MP4_RECORDING_MIME_TYPE,
4
7
  QUICKTIME_RECORDING_MIME_TYPE,
5
8
  };
6
9
  use chrono::{DateTime, Duration as ChronoDuration, Utc};
7
10
  use serde::{Deserialize, Serialize};
11
+ use std::collections::{BTreeSet, HashMap, VecDeque};
8
12
  use std::fs::{File, OpenOptions};
9
13
  use std::io::{BufRead, BufReader, Write};
10
14
  use std::path::{Path, PathBuf};
@@ -19,23 +23,74 @@ const SCREEN_MEMORY_EVENTS_JSONL: &str = "events.jsonl";
19
23
  const MIN_SEGMENT_SECONDS: u64 = 15;
20
24
  const MAX_SEGMENT_SECONDS: u64 = 30 * 60;
21
25
  const MIN_RETENTION_HOURS: u32 = 1;
22
- const MAX_RETENTION_HOURS: u32 = 24 * 30;
26
+ const MAX_RETENTION_HOURS: u32 = 24;
23
27
  const MIN_MAX_BYTES: u64 = 100 * 1024 * 1024;
24
28
  const MAX_MAX_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
25
29
  const ROTATOR_TICK: Duration = Duration::from_millis(500);
30
+ /// Privacy detection must not inherit the user-facing context sampling cadence.
31
+ /// This bounds how long recognized excluded pixels/audio may reach the current
32
+ /// logical segment before that entire segment is discarded.
33
+ const EXCLUSION_POLL_INTERVAL: Duration = Duration::from_millis(250);
34
+ const SCREEN_MEMORY_MCP_NAME: &str = "clips-screen-memory";
26
35
 
27
36
  static SEGMENT_COUNTER: AtomicU64 = AtomicU64::new(0);
28
37
 
29
38
  #[derive(Default)]
30
39
  pub struct ScreenMemoryState {
31
40
  inner: Mutex<ScreenMemoryRuntime>,
41
+ /// OCR and Whisper indexing share this gate so completed segments never
42
+ /// fan out into concurrent CPU/memory-heavy post-processing jobs.
43
+ indexing: Mutex<()>,
44
+ /// Serializes physical producer stop/start transitions. Callers that hold
45
+ /// this lock use `rotate_segment_inner`; ordinary callers use
46
+ /// `rotate_segment`, which acquires it for them.
47
+ transition: Mutex<()>,
32
48
  }
33
49
 
34
50
  #[derive(Default)]
35
51
  struct ScreenMemoryRuntime {
36
52
  active: Option<ActiveScreenMemorySegment>,
37
53
  worker_stop: Option<Arc<AtomicBool>>,
54
+ rewind_lease_id: Option<String>,
55
+ exclusion_active: bool,
56
+ exclusion_gap: Option<ActiveExclusionGap>,
57
+ temporary_audio_consumers: HashMap<String, TemporaryAudioConsumer>,
38
58
  last_error: Option<String>,
59
+ ocr_queue: VecDeque<ScreenMemoryOcrJob>,
60
+ ocr_worker_running: bool,
61
+ ocr_active_segment: Option<String>,
62
+ ocr_cancelled_segments: BTreeSet<String>,
63
+ transcript_queue: VecDeque<ScreenMemoryTranscriptJob>,
64
+ transcript_worker_running: bool,
65
+ transcript_active_segment: Option<String>,
66
+ transcript_cancelled_segments: BTreeSet<String>,
67
+ /// Ephemeral artifact pins. These intentionally do not survive a crash:
68
+ /// callers must have copied any artifact they need before relying on it.
69
+ segment_pins: HashMap<String, BTreeSet<String>>,
70
+ }
71
+
72
+ struct ActiveExclusionGap {
73
+ started_at: Instant,
74
+ sources: Vec<CaptureSource>,
75
+ }
76
+
77
+ fn active_exclusion_gap(
78
+ started_at: Instant,
79
+ capture_mode: RewindCaptureMode,
80
+ ) -> ActiveExclusionGap {
81
+ ActiveExclusionGap {
82
+ started_at,
83
+ sources: requested_sources(capture_mode),
84
+ }
85
+ }
86
+
87
+ struct TemporaryAudioConsumer {
88
+ graph_lease_id: String,
89
+ }
90
+
91
+ #[derive(Clone, Debug, Eq, PartialEq)]
92
+ pub(crate) struct TemporaryAudioLease {
93
+ owner_id: String,
39
94
  }
40
95
 
41
96
  struct ActiveScreenMemorySegment {
@@ -47,6 +102,21 @@ struct ActiveScreenMemorySegment {
47
102
  started_at_iso: String,
48
103
  width: Option<u32>,
49
104
  height: Option<u32>,
105
+ capture_mode: RewindCaptureMode,
106
+ exclusion_tainted: Arc<AtomicBool>,
107
+ graph_epoch_id: String,
108
+ graph_started_elapsed_ms: u64,
109
+ }
110
+
111
+ #[derive(Clone)]
112
+ struct ScreenMemoryOcrJob {
113
+ segment: ScreenMemorySegmentMetadata,
114
+ sample_interval_seconds: u64,
115
+ }
116
+
117
+ #[derive(Clone)]
118
+ struct ScreenMemoryTranscriptJob {
119
+ segment: ScreenMemorySegmentMetadata,
50
120
  }
51
121
 
52
122
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -62,8 +132,55 @@ pub struct ScreenMemorySegmentMetadata {
62
132
  pub width: Option<u32>,
63
133
  pub height: Option<u32>,
64
134
  pub bytes: u64,
135
+ #[serde(default)]
136
+ pub system_audio_path: Option<PathBuf>,
137
+ #[serde(default)]
138
+ pub microphone_path: Option<PathBuf>,
65
139
  pub corrupt: bool,
66
140
  pub error: Option<String>,
141
+ #[serde(default)]
142
+ pub capture_mode: RewindCaptureMode,
143
+ #[serde(default)]
144
+ pub exclusion_tainted: bool,
145
+ /// Monotonic offsets from the shared capture graph; wall-clock fields stay
146
+ /// for human-facing local files and backwards compatibility.
147
+ #[serde(default)]
148
+ pub graph_epoch_id: Option<String>,
149
+ #[serde(default)]
150
+ pub graph_started_elapsed_ms: u64,
151
+ #[serde(default)]
152
+ pub graph_ended_elapsed_ms: u64,
153
+ }
154
+
155
+ fn audio_sidecar_path(video_path: &Path, source: &str) -> PathBuf {
156
+ let stem = video_path
157
+ .file_stem()
158
+ .and_then(|value| value.to_str())
159
+ .unwrap_or("segment");
160
+ video_path.with_file_name(format!("{stem}.{source}.wav"))
161
+ }
162
+
163
+ fn existing_audio_sidecars(
164
+ video_path: &Path,
165
+ mode: RewindCaptureMode,
166
+ ) -> (Option<PathBuf>, Option<PathBuf>) {
167
+ if mode != RewindCaptureMode::VisualsAudio {
168
+ return (None, None);
169
+ }
170
+ let system = audio_sidecar_path(video_path, "system");
171
+ let microphone = audio_sidecar_path(video_path, "microphone");
172
+ (
173
+ system.exists().then_some(system),
174
+ microphone.exists().then_some(microphone),
175
+ )
176
+ }
177
+
178
+ fn media_unit_bytes(video_path: &Path, system: Option<&Path>, microphone: Option<&Path>) -> u64 {
179
+ [Some(video_path), system, microphone]
180
+ .into_iter()
181
+ .flatten()
182
+ .filter_map(|path| std::fs::metadata(path).ok().map(|metadata| metadata.len()))
183
+ .fold(0u64, u64::saturating_add)
67
184
  }
68
185
 
69
186
  #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -74,6 +191,14 @@ pub struct ScreenMemoryEvent {
74
191
  pub window_title: Option<String>,
75
192
  pub bundle_id: Option<String>,
76
193
  pub source: String,
194
+ /// Present only for explicit coverage-gap markers. Kept optional so old
195
+ /// event logs remain readable and ordinary context rows stay compact.
196
+ #[serde(default, skip_serializing_if = "Option::is_none")]
197
+ pub coverage_gap_reason: Option<String>,
198
+ /// A small local semantic summary, never a raw accessibility tree. Older
199
+ /// events intentionally deserialize without this field.
200
+ #[serde(default, skip_serializing_if = "Option::is_none")]
201
+ pub accessibility: Option<crate::accessibility::AccessibilityFingerprint>,
77
202
  }
78
203
 
79
204
  #[derive(Debug, Clone, Serialize)]
@@ -116,6 +241,8 @@ pub struct ScreenMemoryStatus {
116
241
  pub active_segment: Option<ScreenMemoryActiveSegment>,
117
242
  pub recent_segments: Vec<ScreenMemorySegmentMetadata>,
118
243
  pub last_error: Option<String>,
244
+ pub exclusion_active: bool,
245
+ pub coverage: String,
119
246
  }
120
247
 
121
248
  #[derive(Debug, Clone, Serialize)]
@@ -153,6 +280,29 @@ pub fn sync_from_config(app: &AppHandle, feature_config: &FeatureConfig) {
153
280
  return;
154
281
  }
155
282
 
283
+ let effective_mode = effective_capture_mode(app, config.capture_mode);
284
+ let mode_changed_while_active = app
285
+ .try_state::<ScreenMemoryState>()
286
+ .and_then(|state| {
287
+ state
288
+ .inner
289
+ .lock()
290
+ .ok()
291
+ .and_then(|runtime| runtime.active.as_ref().map(|active| active.capture_mode))
292
+ })
293
+ .is_some_and(|active_mode| active_mode != effective_mode);
294
+ if mode_changed_while_active {
295
+ // Reconfigure the one physical producer at a segment boundary rather
296
+ // than letting its audio contract drift under an existing lease.
297
+ // This is deliberately unlike an ordinary rotation: the requested
298
+ // source set changed, so retaining the old lease would leave the graph
299
+ // claiming the previous mode even after the producer restarts.
300
+ if let Err(err) = rotate_segment(app, &config) {
301
+ record_error(app, err);
302
+ }
303
+ return;
304
+ }
305
+
156
306
  if let Err(err) = ensure_running(app, &config) {
157
307
  record_error(app, err);
158
308
  }
@@ -191,6 +341,8 @@ pub async fn screen_memory_pause(app: AppHandle) -> Result<ScreenMemoryStatus, S
191
341
  feature_config.screen_memory.paused = true;
192
342
  feature_config.screen_memory = normalize_screen_memory_config(feature_config.screen_memory);
193
343
  crate::config::set_feature_config(app.clone(), feature_config).await?;
344
+ append_event(&app, coverage_gap_event("user-paused"));
345
+ let _ = refresh_rewind_chapters(&app);
194
346
  build_status(&app)
195
347
  }
196
348
 
@@ -201,6 +353,8 @@ pub async fn screen_memory_stop(app: AppHandle) -> Result<ScreenMemoryStatus, St
201
353
  feature_config.screen_memory.paused = false;
202
354
  feature_config.screen_memory = normalize_screen_memory_config(feature_config.screen_memory);
203
355
  crate::config::set_feature_config(app.clone(), feature_config).await?;
356
+ append_event(&app, coverage_gap_event("user-disabled"));
357
+ let _ = refresh_rewind_chapters(&app);
204
358
  build_status(&app)
205
359
  }
206
360
 
@@ -242,6 +396,159 @@ pub async fn screen_memory_open_folder(app: AppHandle) -> Result<(), String> {
242
396
  crate::clips::open_local_recording_folder(dir.to_string_lossy().to_string())
243
397
  }
244
398
 
399
+ #[derive(Debug, Clone, Serialize)]
400
+ #[serde(rename_all = "camelCase")]
401
+ pub struct ScreenMemoryAgentConnectionStatus {
402
+ pub client: String,
403
+ pub configured: bool,
404
+ pub config_path: String,
405
+ pub store_dir: String,
406
+ }
407
+
408
+ fn home_dir() -> Result<PathBuf, String> {
409
+ std::env::var_os("HOME")
410
+ .map(PathBuf::from)
411
+ .ok_or_else(|| "Could not resolve the current user's home directory.".to_string())
412
+ }
413
+
414
+ fn toml_quote(value: &str) -> String {
415
+ format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
416
+ }
417
+
418
+ fn codex_screen_memory_block(store_dir: &Path) -> String {
419
+ let args = [
420
+ "-y",
421
+ "@agent-native/core@latest",
422
+ "mcp",
423
+ "screen-memory",
424
+ "--dir",
425
+ &store_dir.to_string_lossy(),
426
+ ];
427
+ format!(
428
+ "[mcp_servers.\"{SCREEN_MEMORY_MCP_NAME}\"]\ncommand = \"npx\"\nargs = [{}]\n",
429
+ args.iter()
430
+ .map(|arg| toml_quote(arg))
431
+ .collect::<Vec<_>>()
432
+ .join(", ")
433
+ )
434
+ }
435
+
436
+ fn table_header(line: &str) -> Option<&str> {
437
+ let trimmed = line.trim();
438
+ if !trimmed.starts_with('[') {
439
+ return None;
440
+ }
441
+ let end = trimmed.find(']')?;
442
+ Some(trimmed[1..end].trim())
443
+ }
444
+
445
+ fn is_screen_memory_table(header: &str) -> bool {
446
+ header == format!("mcp_servers.\"{SCREEN_MEMORY_MCP_NAME}\"")
447
+ || header == format!("mcp_servers.{SCREEN_MEMORY_MCP_NAME}")
448
+ || header.starts_with(&format!("mcp_servers.\"{SCREEN_MEMORY_MCP_NAME}\"."))
449
+ || header.starts_with(&format!("mcp_servers.{SCREEN_MEMORY_MCP_NAME}."))
450
+ }
451
+
452
+ fn replace_codex_screen_memory_block(existing: &str, block: &str) -> String {
453
+ let lines = existing.lines().collect::<Vec<_>>();
454
+ let mut kept = Vec::new();
455
+ let mut index = 0;
456
+ while index < lines.len() {
457
+ if table_header(lines[index]).is_some_and(is_screen_memory_table) {
458
+ index += 1;
459
+ while index < lines.len() && table_header(lines[index]).is_none() {
460
+ index += 1;
461
+ }
462
+ continue;
463
+ }
464
+ kept.push(lines[index]);
465
+ index += 1;
466
+ }
467
+ let base = kept.join("\n").trim_end().to_string();
468
+ if base.is_empty() {
469
+ format!("{block}\n")
470
+ } else {
471
+ format!("{base}\n\n{block}\n")
472
+ }
473
+ }
474
+
475
+ fn write_atomic(path: &Path, contents: &[u8]) -> Result<(), String> {
476
+ if let Some(parent) = path.parent() {
477
+ std::fs::create_dir_all(parent)
478
+ .map_err(|err| format!("Could not create {}: {err}", parent.display()))?;
479
+ }
480
+ let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
481
+ std::fs::write(&temporary, contents)
482
+ .map_err(|err| format!("Could not write {}: {err}", temporary.display()))?;
483
+ std::fs::rename(&temporary, path)
484
+ .map_err(|err| format!("Could not replace {}: {err}", path.display()))
485
+ }
486
+
487
+ fn agent_connection_config_path(client: &str) -> Result<PathBuf, String> {
488
+ let home = home_dir()?;
489
+ match client {
490
+ "codex" => Ok(home.join(".codex/config.toml")),
491
+ "claude-code" => Ok(home.join(".claude.json")),
492
+ _ => Err("Supported agents are Codex and Claude Code.".to_string()),
493
+ }
494
+ }
495
+
496
+ fn install_agent_connection(client: &str, store_dir: &Path) -> Result<PathBuf, String> {
497
+ let config_path = agent_connection_config_path(client)?;
498
+ if client == "codex" {
499
+ let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
500
+ let next =
501
+ replace_codex_screen_memory_block(&existing, &codex_screen_memory_block(store_dir));
502
+ write_atomic(&config_path, next.as_bytes())?;
503
+ } else {
504
+ let mut root = std::fs::read_to_string(&config_path)
505
+ .ok()
506
+ .and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
507
+ .unwrap_or_else(|| serde_json::json!({}));
508
+ let object = root
509
+ .as_object_mut()
510
+ .ok_or_else(|| format!("{} is not a JSON object.", config_path.display()))?;
511
+ let servers = object
512
+ .entry("mcpServers")
513
+ .or_insert_with(|| serde_json::json!({}))
514
+ .as_object_mut()
515
+ .ok_or_else(|| "Claude Code mcpServers is not a JSON object.".to_string())?;
516
+ servers.insert(
517
+ SCREEN_MEMORY_MCP_NAME.to_string(),
518
+ serde_json::json!({
519
+ "command": "npx",
520
+ "args": [
521
+ "-y",
522
+ "@agent-native/core@latest",
523
+ "mcp",
524
+ "screen-memory",
525
+ "--dir",
526
+ store_dir.to_string_lossy()
527
+ ]
528
+ }),
529
+ );
530
+ let bytes = serde_json::to_vec_pretty(&root)
531
+ .map_err(|err| format!("Could not encode Claude Code configuration: {err}"))?;
532
+ write_atomic(&config_path, &bytes)?;
533
+ }
534
+ Ok(config_path)
535
+ }
536
+
537
+ #[tauri::command]
538
+ pub async fn screen_memory_install_agent_connection(
539
+ app: AppHandle,
540
+ client: String,
541
+ ) -> Result<ScreenMemoryAgentConnectionStatus, String> {
542
+ let store_dir = screen_memory_dir(&app)?;
543
+ let config_path = install_agent_connection(&client, &store_dir)?;
544
+ Ok(ScreenMemoryAgentConnectionStatus {
545
+ client,
546
+ configured: true,
547
+ config_path: config_path.to_string_lossy().to_string(),
548
+ store_dir: store_dir.to_string_lossy().to_string(),
549
+ })
550
+ }
551
+
245
552
  #[tauri::command]
246
553
  pub async fn screen_memory_delete(
247
554
  app: AppHandle,
@@ -279,9 +586,21 @@ fn normalize_screen_memory_config(mut config: ScreenMemoryConfig) -> ScreenMemor
279
586
  config.sample_interval_seconds = config
280
587
  .sample_interval_seconds
281
588
  .clamp(1, config.segment_seconds);
589
+ config.excluded_bundle_ids = normalize_excluded_bundle_ids(config.excluded_bundle_ids);
282
590
  config
283
591
  }
284
592
 
593
+ fn normalize_excluded_bundle_ids(bundle_ids: Vec<String>) -> Vec<String> {
594
+ let mut normalized = bundle_ids
595
+ .into_iter()
596
+ .map(|bundle_id| bundle_id.trim().to_ascii_lowercase())
597
+ .filter(|bundle_id| !bundle_id.is_empty())
598
+ .collect::<Vec<_>>();
599
+ normalized.sort();
600
+ normalized.dedup();
601
+ normalized
602
+ }
603
+
285
604
  fn screen_memory_available() -> bool {
286
605
  #[cfg(target_os = "macos")]
287
606
  {
@@ -301,6 +620,12 @@ fn screen_memory_dir(app: &AppHandle) -> Result<PathBuf, String> {
301
620
  .join(SCREEN_MEMORY_DIR);
302
621
  std::fs::create_dir_all(&dir)
303
622
  .map_err(|e| format!("screen memory directory unavailable: {e}"))?;
623
+ #[cfg(unix)]
624
+ {
625
+ use std::os::unix::fs::PermissionsExt;
626
+ std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
627
+ .map_err(|e| format!("screen memory directory protection failed: {e}"))?;
628
+ }
304
629
  Ok(dir)
305
630
  }
306
631
 
@@ -308,10 +633,229 @@ fn screen_memory_events_path(app: &AppHandle) -> Result<PathBuf, String> {
308
633
  Ok(screen_memory_dir(app)?.join(SCREEN_MEMORY_EVENTS_JSONL))
309
634
  }
310
635
 
636
+ fn agent_handoffs_dir(app: &AppHandle) -> Result<PathBuf, String> {
637
+ let dir = screen_memory_dir(app)?.join("agent-handoffs");
638
+ std::fs::create_dir_all(&dir)
639
+ .map_err(|err| format!("agent handoff directory unavailable: {err}"))?;
640
+ Ok(dir)
641
+ }
642
+
643
+ fn agent_handoff_path(app: &AppHandle, request_id: &str) -> Result<PathBuf, String> {
644
+ let safe = sanitize_segment_id(request_id);
645
+ if safe != request_id || !safe.starts_with("handoff-") {
646
+ return Err("Invalid Rewind handoff request ID.".to_string());
647
+ }
648
+ Ok(agent_handoffs_dir(app)?.join(format!("{safe}.json")))
649
+ }
650
+
651
+ fn read_agent_handoff(path: &Path) -> Result<serde_json::Value, String> {
652
+ serde_json::from_slice(
653
+ &std::fs::read(path).map_err(|err| format!("Could not read {}: {err}", path.display()))?,
654
+ )
655
+ .map_err(|err| format!("Could not parse {}: {err}", path.display()))
656
+ }
657
+
658
+ fn write_agent_handoff(path: &Path, value: &serde_json::Value) -> Result<(), String> {
659
+ let bytes = serde_json::to_vec_pretty(value)
660
+ .map_err(|err| format!("Could not encode agent handoff: {err}"))?;
661
+ write_atomic(path, &bytes)
662
+ }
663
+
664
+ #[tauri::command]
665
+ pub async fn screen_memory_next_agent_handoff(
666
+ app: AppHandle,
667
+ ) -> Result<Option<serde_json::Value>, String> {
668
+ let mut pending = Vec::new();
669
+ for entry in std::fs::read_dir(agent_handoffs_dir(&app)?)
670
+ .map_err(|err| format!("Could not list agent handoffs: {err}"))?
671
+ {
672
+ let path = entry.map_err(|err| err.to_string())?.path();
673
+ if path.extension().and_then(|value| value.to_str()) != Some("json") {
674
+ continue;
675
+ }
676
+ let value = match read_agent_handoff(&path) {
677
+ Ok(value) => value,
678
+ Err(_) => continue,
679
+ };
680
+ if value.get("status").and_then(|value| value.as_str()) == Some("pending") {
681
+ let requested_at = value
682
+ .get("requestedAt")
683
+ .and_then(|value| value.as_str())
684
+ .unwrap_or_default()
685
+ .to_string();
686
+ pending.push((requested_at, value));
687
+ }
688
+ }
689
+ pending.sort_by(|left, right| left.0.cmp(&right.0));
690
+ Ok(pending.into_iter().next().map(|(_, value)| value))
691
+ }
692
+
693
+ #[tauri::command]
694
+ pub async fn screen_memory_update_agent_handoff(
695
+ app: AppHandle,
696
+ request_id: String,
697
+ status: String,
698
+ result: Option<serde_json::Value>,
699
+ error: Option<String>,
700
+ ) -> Result<(), String> {
701
+ if !matches!(
702
+ status.as_str(),
703
+ "processing" | "ready" | "declined" | "failed"
704
+ ) {
705
+ return Err("Invalid Rewind handoff status.".to_string());
706
+ }
707
+ let path = agent_handoff_path(&app, &request_id)?;
708
+ let mut value = read_agent_handoff(&path)?;
709
+ let object = value
710
+ .as_object_mut()
711
+ .ok_or_else(|| "Rewind handoff is not an object.".to_string())?;
712
+ object.insert("status".to_string(), serde_json::json!(status));
713
+ object.insert("updatedAt".to_string(), serde_json::json!(now_iso()));
714
+ scrub_terminal_handoff_fields(object, &status);
715
+ if let Some(result) = result.and_then(|value| value.as_object().cloned()) {
716
+ for (key, value) in result {
717
+ if matches!(
718
+ key.as_str(),
719
+ "recordingId" | "agentUrl" | "contextUrl" | "expiresAt" | "autoDeleteAt"
720
+ ) {
721
+ object.insert(key, value);
722
+ }
723
+ }
724
+ }
725
+ if let Some(error) = error {
726
+ object.insert(
727
+ "error".to_string(),
728
+ serde_json::json!(error.chars().take(1_000).collect::<String>()),
729
+ );
730
+ }
731
+ write_agent_handoff(&path, &value)
732
+ }
733
+
734
+ fn scrub_terminal_handoff_fields(
735
+ object: &mut serde_json::Map<String, serde_json::Value>,
736
+ status: &str,
737
+ ) {
738
+ if matches!(status, "ready" | "declined" | "failed") {
739
+ // The request reason is useful while the user is reviewing or the
740
+ // handoff is processing, but it must not become a second durable
741
+ // transcript once the request reaches a terminal state.
742
+ object.remove("reason");
743
+ }
744
+ }
745
+
746
+ #[tauri::command]
747
+ pub async fn screen_memory_due_agent_handoffs(
748
+ app: AppHandle,
749
+ ) -> Result<Vec<serde_json::Value>, String> {
750
+ let now = Utc::now();
751
+ let mut due = Vec::new();
752
+ for entry in std::fs::read_dir(agent_handoffs_dir(&app)?)
753
+ .map_err(|err| format!("Could not list agent handoffs: {err}"))?
754
+ {
755
+ let path = entry.map_err(|err| err.to_string())?.path();
756
+ if path.extension().and_then(|value| value.to_str()) != Some("json") {
757
+ continue;
758
+ }
759
+ let value = match read_agent_handoff(&path) {
760
+ Ok(value) => value,
761
+ Err(_) => continue,
762
+ };
763
+ if value.get("status").and_then(|value| value.as_str()) != Some("ready")
764
+ || value.get("autoDeletedAt").is_some()
765
+ {
766
+ continue;
767
+ }
768
+ let Some(auto_delete_at) = value
769
+ .get("autoDeleteAt")
770
+ .and_then(|value| value.as_str())
771
+ .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
772
+ else {
773
+ continue;
774
+ };
775
+ if auto_delete_at.with_timezone(&Utc) <= now {
776
+ due.push(serde_json::json!({
777
+ "requestId": value.get("requestId"),
778
+ "recordingId": value.get("recordingId")
779
+ }));
780
+ }
781
+ }
782
+ Ok(due)
783
+ }
784
+
785
+ #[tauri::command]
786
+ pub async fn screen_memory_mark_agent_handoff_deleted(
787
+ app: AppHandle,
788
+ request_id: String,
789
+ ) -> Result<(), String> {
790
+ let path = agent_handoff_path(&app, &request_id)?;
791
+ let mut value = read_agent_handoff(&path)?;
792
+ let object = value
793
+ .as_object_mut()
794
+ .ok_or_else(|| "Rewind handoff is not an object.".to_string())?;
795
+ object.insert("status".to_string(), serde_json::json!("deleted"));
796
+ object.insert("autoDeletedAt".to_string(), serde_json::json!(now_iso()));
797
+ write_agent_handoff(&path, &value)
798
+ }
799
+
800
+ #[tauri::command]
801
+ pub async fn screen_memory_cancel_agent_handoff_cleanup(
802
+ app: AppHandle,
803
+ request_id: String,
804
+ ) -> Result<(), String> {
805
+ let path = agent_handoff_path(&app, &request_id)?;
806
+ let mut value = read_agent_handoff(&path)?;
807
+ let object = value
808
+ .as_object_mut()
809
+ .ok_or_else(|| "Rewind handoff is not an object.".to_string())?;
810
+ object.remove("autoDeleteAt");
811
+ object.insert(
812
+ "cleanupCanceledAt".to_string(),
813
+ serde_json::json!(now_iso()),
814
+ );
815
+ object.insert(
816
+ "cleanupCanceledReason".to_string(),
817
+ serde_json::json!("promoted"),
818
+ );
819
+ write_agent_handoff(&path, &value)
820
+ }
821
+
311
822
  fn segment_metadata_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
312
823
  Ok(screen_memory_dir(app)?.join(format!("{}.json", sanitize_segment_id(segment_id))))
313
824
  }
314
825
 
826
+ fn segment_ocr_rows_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
827
+ Ok(screen_memory_dir(app)?.join(format!("{}.ocr.jsonl", sanitize_segment_id(segment_id))))
828
+ }
829
+
830
+ fn segment_ocr_status_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
831
+ Ok(screen_memory_dir(app)?.join(format!(
832
+ "{}.ocr-status.json",
833
+ sanitize_segment_id(segment_id)
834
+ )))
835
+ }
836
+
837
+ fn segment_transcript_rows_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
838
+ Ok(transcript_sidecar_paths(&screen_memory_dir(app)?, segment_id).0)
839
+ }
840
+
841
+ fn segment_transcript_status_path(app: &AppHandle, segment_id: &str) -> Result<PathBuf, String> {
842
+ Ok(transcript_sidecar_paths(&screen_memory_dir(app)?, segment_id).1)
843
+ }
844
+
845
+ fn transcript_sidecar_paths(dir: &Path, segment_id: &str) -> (PathBuf, PathBuf) {
846
+ let id = sanitize_segment_id(segment_id);
847
+ (
848
+ dir.join(format!("{id}.transcript.jsonl")),
849
+ dir.join(format!("{id}.transcript-status.json")),
850
+ )
851
+ }
852
+
853
+ fn remove_transcript_sidecars(dir: &Path, segment_id: &str) -> Result<(), String> {
854
+ let (rows, status) = transcript_sidecar_paths(dir, segment_id);
855
+ remove_file_if_exists(&rows)?;
856
+ remove_file_if_exists(&status)
857
+ }
858
+
315
859
  fn segment_media_path(
316
860
  app: &AppHandle,
317
861
  segment_id: &str,
@@ -344,7 +888,7 @@ fn next_segment_id() -> String {
344
888
  fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
345
889
  let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
346
890
  let storage_dir = screen_memory_dir(app)?;
347
- let (active_segment, last_error) = {
891
+ let (active_segment, last_error, exclusion_active) = {
348
892
  let state = app.state::<ScreenMemoryState>();
349
893
  let guard = state.inner.lock().map_err(|e| e.to_string())?;
350
894
  (
@@ -361,6 +905,7 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
361
905
  height: active.height,
362
906
  }),
363
907
  guard.last_error.clone(),
908
+ guard.exclusion_active,
364
909
  )
365
910
  };
366
911
  let state = if !config.enabled {
@@ -373,6 +918,8 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
373
918
  ScreenMemoryRuntimeState::Idle
374
919
  };
375
920
 
921
+ let producer_active = active_segment.is_some();
922
+ let coverage = coverage_language(&config, exclusion_active, producer_active);
376
923
  Ok(ScreenMemoryStatus {
377
924
  available: screen_memory_available(),
378
925
  state,
@@ -381,10 +928,359 @@ fn build_status(app: &AppHandle) -> Result<ScreenMemoryStatus, String> {
381
928
  active_segment,
382
929
  recent_segments: recent_segments(app, Some(5))?,
383
930
  last_error,
931
+ exclusion_active,
932
+ coverage,
384
933
  })
385
934
  }
386
935
 
936
+ fn coverage_language(
937
+ config: &ScreenMemoryConfig,
938
+ exclusion_active: bool,
939
+ producer_active: bool,
940
+ ) -> String {
941
+ if exclusion_active {
942
+ return "Capture stopped for an excluded app. The entire in-flight media segment was discarded, and this interval is recorded as a source coverage gap.".to_string();
943
+ }
944
+ if !config.enabled {
945
+ return "Rewind capture is disabled. Existing local segments remain available.".to_string();
946
+ }
947
+ if config.paused {
948
+ return "Rewind capture is paused. Existing local segments remain available, and no new source coverage is being retained.".to_string();
949
+ }
950
+ if producer_active {
951
+ return "Rewind is retaining local media coverage. Excluded applications stop capture and create explicit source coverage gaps.".to_string();
952
+ }
953
+ "Rewind is not currently retaining source coverage.".to_string()
954
+ }
955
+
956
+ pub(crate) fn rewind_clip_compatible(app: &AppHandle) -> Result<bool, String> {
957
+ let status = build_status(app)?;
958
+ Ok(status.available
959
+ && matches!(status.state, ScreenMemoryRuntimeState::Recording)
960
+ && status.last_error.is_none())
961
+ }
962
+
963
+ pub(crate) fn rewind_clip_sources(app: &AppHandle) -> Vec<CaptureSource> {
964
+ let capture_mode = app
965
+ .try_state::<ScreenMemoryState>()
966
+ .and_then(|state| {
967
+ state
968
+ .inner
969
+ .lock()
970
+ .ok()
971
+ .and_then(|runtime| runtime.active.as_ref().map(|active| active.capture_mode))
972
+ })
973
+ .unwrap_or_else(|| {
974
+ let base_mode = crate::config::feature_config(app)
975
+ .screen_memory
976
+ .capture_mode;
977
+ effective_capture_mode(app, base_mode)
978
+ });
979
+ requested_sources(capture_mode)
980
+ }
981
+
982
+ fn effective_mode(
983
+ base_mode: RewindCaptureMode,
984
+ temporary_audio_consumers: usize,
985
+ ) -> RewindCaptureMode {
986
+ if temporary_audio_consumers > 0 {
987
+ RewindCaptureMode::VisualsAudio
988
+ } else {
989
+ base_mode
990
+ }
991
+ }
992
+
993
+ fn effective_capture_mode(app: &AppHandle, base_mode: RewindCaptureMode) -> RewindCaptureMode {
994
+ let temporary_audio_consumers = app
995
+ .try_state::<ScreenMemoryState>()
996
+ .and_then(|state| {
997
+ state
998
+ .inner
999
+ .lock()
1000
+ .ok()
1001
+ .map(|runtime| runtime.temporary_audio_consumers.len())
1002
+ })
1003
+ .unwrap_or(0);
1004
+ effective_mode(base_mode, temporary_audio_consumers)
1005
+ }
1006
+
1007
+ fn temporary_audio_lease_available(
1008
+ enabled: bool,
1009
+ paused: bool,
1010
+ producer_active: bool,
1011
+ include_mic: bool,
1012
+ include_system_audio: bool,
1013
+ ) -> bool {
1014
+ enabled && !paused && producer_active && (include_mic || include_system_audio)
1015
+ }
1016
+
1017
+ /// Temporarily upgrades the active Rewind producer to its audio-capable mode.
1018
+ /// The persisted Screen Memory mode is never changed. Repeated acquisition by
1019
+ /// the same stable owner is idempotent; distinct owners refcount the effective
1020
+ /// audio demand through separate graph leases.
1021
+ pub(crate) fn acquire_temporary_audio_consumer(
1022
+ app: &AppHandle,
1023
+ owner_id: &str,
1024
+ consumer: CaptureConsumer,
1025
+ include_mic: bool,
1026
+ include_system_audio: bool,
1027
+ ) -> Result<Option<TemporaryAudioLease>, String> {
1028
+ if owner_id.trim().is_empty() {
1029
+ return Err("temporary audio consumer owner id cannot be empty".into());
1030
+ }
1031
+ let state = app.state::<ScreenMemoryState>();
1032
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1033
+ let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1034
+ let (mut producer_active, already_present, exclusion_active) = {
1035
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
1036
+ (
1037
+ runtime.active.is_some(),
1038
+ runtime.temporary_audio_consumers.contains_key(owner_id),
1039
+ runtime.exclusion_active,
1040
+ )
1041
+ };
1042
+ // Config writes become visible before their sync callback completes. If a
1043
+ // meeting arrives in that narrow window, finish stopping the old producer
1044
+ // under the same transition lock before returning `None`; the caller can
1045
+ // then open the legacy recorder without ever overlapping Rewind.
1046
+ if !config.enabled || config.paused {
1047
+ if producer_active {
1048
+ stop_active_segment_inner(app)?;
1049
+ }
1050
+ return Ok(None);
1051
+ }
1052
+ if exclusion_active {
1053
+ return Ok(None);
1054
+ }
1055
+ if !producer_active {
1056
+ ensure_running_inner(app, &config)?;
1057
+ producer_active = true;
1058
+ }
1059
+ if already_present {
1060
+ return Ok(Some(TemporaryAudioLease {
1061
+ owner_id: owner_id.to_owned(),
1062
+ }));
1063
+ }
1064
+ if !temporary_audio_lease_available(
1065
+ config.enabled,
1066
+ config.paused,
1067
+ producer_active,
1068
+ include_mic,
1069
+ include_system_audio,
1070
+ ) {
1071
+ return Ok(None);
1072
+ }
1073
+
1074
+ let mut sources = Vec::new();
1075
+ if include_mic {
1076
+ sources.push(CaptureSource::Microphone);
1077
+ }
1078
+ if include_system_audio {
1079
+ sources.push(CaptureSource::SystemAudio);
1080
+ }
1081
+ let graph_lease = app
1082
+ .state::<CaptureGraphState>()
1083
+ .0
1084
+ .lock()
1085
+ .map_err(|error| error.to_string())?
1086
+ .start_consumer(consumer, sources)
1087
+ .map_err(capture_graph_error)?;
1088
+ {
1089
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
1090
+ runtime.temporary_audio_consumers.insert(
1091
+ owner_id.to_owned(),
1092
+ TemporaryAudioConsumer {
1093
+ graph_lease_id: graph_lease.id.clone(),
1094
+ },
1095
+ );
1096
+ }
1097
+
1098
+ let needs_upgrade = state
1099
+ .inner
1100
+ .lock()
1101
+ .map_err(|error| error.to_string())?
1102
+ .active
1103
+ .as_ref()
1104
+ .is_some_and(|active| active.capture_mode != RewindCaptureMode::VisualsAudio);
1105
+ if needs_upgrade {
1106
+ if let Err(error) = rotate_segment_inner(app, &config) {
1107
+ rollback_temporary_audio_consumer(app, owner_id);
1108
+ return Err(format!("rewind-audio-upgrade-failed: {error}"));
1109
+ }
1110
+ }
1111
+ Ok(Some(TemporaryAudioLease {
1112
+ owner_id: owner_id.to_owned(),
1113
+ }))
1114
+ }
1115
+
1116
+ pub(crate) fn release_temporary_audio_consumer(
1117
+ app: &AppHandle,
1118
+ lease: TemporaryAudioLease,
1119
+ ) -> Result<(), String> {
1120
+ let state = app.state::<ScreenMemoryState>();
1121
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1122
+ let Some(consumer) = state
1123
+ .inner
1124
+ .lock()
1125
+ .map_err(|error| error.to_string())?
1126
+ .temporary_audio_consumers
1127
+ .remove(&lease.owner_id)
1128
+ else {
1129
+ return Ok(());
1130
+ };
1131
+ end_graph_lease(app, &consumer.graph_lease_id);
1132
+
1133
+ let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1134
+ if !config.enabled || config.paused {
1135
+ return Ok(());
1136
+ }
1137
+ let desired = effective_capture_mode(app, config.capture_mode);
1138
+ let needs_restore = state
1139
+ .inner
1140
+ .lock()
1141
+ .map_err(|error| error.to_string())?
1142
+ .active
1143
+ .as_ref()
1144
+ .is_some_and(|active| active.capture_mode != desired);
1145
+ if needs_restore {
1146
+ rotate_segment_inner(app, &config)?;
1147
+ }
1148
+ Ok(())
1149
+ }
1150
+
1151
+ fn rollback_temporary_audio_consumer(app: &AppHandle, owner_id: &str) {
1152
+ let consumer = app
1153
+ .state::<ScreenMemoryState>()
1154
+ .inner
1155
+ .lock()
1156
+ .ok()
1157
+ .and_then(|mut runtime| runtime.temporary_audio_consumers.remove(owner_id));
1158
+ if let Some(consumer) = consumer {
1159
+ end_graph_lease(app, &consumer.graph_lease_id);
1160
+ }
1161
+ }
1162
+
1163
+ fn end_graph_lease(app: &AppHandle, lease_id: &str) {
1164
+ if let Ok(mut graph) = app.state::<CaptureGraphState>().0.lock() {
1165
+ if let Err(error) = graph.end_consumer(lease_id) {
1166
+ eprintln!("[clips-tray] temporary audio graph lease close failed: {error}");
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ /// Fence the rolling producer at an exact media-fragment edge and persist the
1172
+ /// closed logical segment while keeping the replacement capture active.
1173
+ pub(crate) fn fence_active_for_clip(
1174
+ app: &AppHandle,
1175
+ ) -> Result<ScreenMemorySegmentMetadata, String> {
1176
+ let state = app.state::<ScreenMemoryState>();
1177
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1178
+ let active = {
1179
+ let active = state
1180
+ .inner
1181
+ .lock()
1182
+ .map_err(|error| error.to_string())?
1183
+ .active
1184
+ .take();
1185
+ active
1186
+ }
1187
+ .ok_or_else(|| "rewind-not-compatible: Screen Memory is not recording".to_string())?;
1188
+ if active.exclusion_tainted.load(Ordering::SeqCst) {
1189
+ let gap = active_exclusion_gap(active.started_at, active.capture_mode);
1190
+ let segment_id = active.id.clone();
1191
+ {
1192
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
1193
+ runtime.exclusion_active = true;
1194
+ runtime.exclusion_gap = Some(gap);
1195
+ }
1196
+ discard_active_segment(active);
1197
+ discard_segment_artifacts(app, &segment_id)?;
1198
+ end_rewind_lease(app);
1199
+ let _ = app.emit(SCREEN_MEMORY_EVENT, ());
1200
+ return Err(
1201
+ "rewind-not-compatible: recognized privacy exclusion created a coverage gap".into(),
1202
+ );
1203
+ }
1204
+ match fence_rotate_segment(app, active) {
1205
+ Ok((segment, next)) => {
1206
+ write_segment_metadata(app, &segment)?;
1207
+ install_rotated_segment(app, next)?;
1208
+ Ok(segment)
1209
+ }
1210
+ Err(active) => {
1211
+ if let Ok(mut runtime) = app.state::<ScreenMemoryState>().inner.lock() {
1212
+ runtime.active = Some(active);
1213
+ }
1214
+ Err("rewind-not-compatible: active Screen Memory backend cannot fence".into())
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ /// Prepare an independent Clip artifact writer on the already-running Rewind
1220
+ /// producer. The transition lock keeps the backend stable while the writer is
1221
+ /// installed; no samples enter the Clip until its handle is activated at zero.
1222
+ #[cfg(target_os = "macos")]
1223
+ pub(crate) fn prepare_shared_clip_sink(
1224
+ app: &AppHandle,
1225
+ path: PathBuf,
1226
+ include_mic: bool,
1227
+ include_system_audio: bool,
1228
+ has_camera: bool,
1229
+ upload: native_screen::ClipLiveUploadConfig,
1230
+ ) -> Result<native_screen::SharedClipSink, String> {
1231
+ let state = app.state::<ScreenMemoryState>();
1232
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1233
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
1234
+ let active = runtime
1235
+ .active
1236
+ .as_ref()
1237
+ .ok_or_else(|| "rewind-not-compatible: Screen Memory is not recording".to_string())?;
1238
+ if active.exclusion_tainted.load(Ordering::SeqCst) {
1239
+ return Err(not_compatible_clip_sink(
1240
+ "recognized privacy exclusion created a coverage gap",
1241
+ ));
1242
+ }
1243
+ let width = active
1244
+ .width
1245
+ .ok_or_else(|| not_compatible_clip_sink("Screen Memory width is unavailable"))?;
1246
+ let height = active
1247
+ .height
1248
+ .ok_or_else(|| not_compatible_clip_sink("Screen Memory height is unavailable"))?;
1249
+ native_screen::prepare_shared_clip_sink(
1250
+ app,
1251
+ &active.backend,
1252
+ native_screen::SharedClipSinkRequest {
1253
+ path,
1254
+ width,
1255
+ height,
1256
+ include_mic,
1257
+ include_system_audio,
1258
+ has_camera,
1259
+ upload,
1260
+ },
1261
+ )
1262
+ }
1263
+
1264
+ #[cfg(target_os = "macos")]
1265
+ fn not_compatible_clip_sink(detail: &str) -> String {
1266
+ format!("rewind-not-compatible: {detail}")
1267
+ }
1268
+
387
1269
  fn ensure_running(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
1270
+ let state = app.state::<ScreenMemoryState>();
1271
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1272
+ ensure_running_inner(app, config)
1273
+ }
1274
+
1275
+ fn ensure_running_inner(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
1276
+ // An incompatible ordinary Clip owns the physical capture graph until its
1277
+ // final suspension lease releases. Config sync and temporary audio demand
1278
+ // must not silently restart Rewind in the middle of that handoff.
1279
+ if crate::rewind_capture_suspension::is_active(app) {
1280
+ return Ok(());
1281
+ }
1282
+ let mut config = config.clone();
1283
+ config.capture_mode = effective_capture_mode(app, config.capture_mode);
388
1284
  if !screen_memory_available() {
389
1285
  return Err("Screen Memory capture is currently macOS-only.".to_string());
390
1286
  }
@@ -392,12 +1288,12 @@ fn ensure_running(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
392
1288
  {
393
1289
  let state = app.state::<ScreenMemoryState>();
394
1290
  let guard = state.inner.lock().map_err(|e| e.to_string())?;
395
- if guard.active.is_some() {
1291
+ if guard.active.is_some() || guard.exclusion_active {
396
1292
  return Ok(());
397
1293
  }
398
1294
  }
399
1295
 
400
- let active = start_new_segment(app)?;
1296
+ let active = start_new_segment(app, config.capture_mode)?;
401
1297
  let stop = Arc::new(AtomicBool::new(false));
402
1298
  {
403
1299
  let state = app.state::<ScreenMemoryState>();
@@ -412,8 +1308,13 @@ fn ensure_running(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
412
1308
  guard.last_error = None;
413
1309
  }
414
1310
 
1311
+ if let Err(err) = start_rewind_lease(app, config.capture_mode) {
1312
+ let _ = stop_active_segment_inner(app);
1313
+ return Err(err);
1314
+ }
1315
+
415
1316
  spawn_rotator(app.clone(), stop);
416
- prune_segments(app, config)?;
1317
+ prune_segments(app, &config)?;
417
1318
  let _ = app.emit(SCREEN_MEMORY_EVENT, ());
418
1319
  Ok(())
419
1320
  }
@@ -422,8 +1323,10 @@ fn spawn_rotator(app: AppHandle, stop: Arc<AtomicBool>) {
422
1323
  std::thread::spawn(move || loop {
423
1324
  let config =
424
1325
  normalize_screen_memory_config(crate::config::feature_config(&app).screen_memory);
425
- if wait_for_rotation(&app, &stop, &config) {
426
- return;
1326
+ match wait_for_rotation(&app, &stop, &config) {
1327
+ RotationWait::Stop => return,
1328
+ RotationWait::Resumed => continue,
1329
+ RotationWait::Rotate => {}
427
1330
  }
428
1331
  let config =
429
1332
  normalize_screen_memory_config(crate::config::feature_config(&app).screen_memory);
@@ -436,49 +1339,377 @@ fn spawn_rotator(app: AppHandle, stop: Arc<AtomicBool>) {
436
1339
  });
437
1340
  }
438
1341
 
439
- fn wait_for_rotation(app: &AppHandle, stop: &AtomicBool, config: &ScreenMemoryConfig) -> bool {
1342
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1343
+ enum RotationWait {
1344
+ Stop,
1345
+ Rotate,
1346
+ Resumed,
1347
+ }
1348
+
1349
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
1350
+ enum ExclusionTransition {
1351
+ KeepCapturing,
1352
+ Suspend,
1353
+ StaySuspended,
1354
+ Resume,
1355
+ }
1356
+
1357
+ fn exclusion_transition(exclusion_active: bool, recognized: bool) -> ExclusionTransition {
1358
+ match (exclusion_active, recognized) {
1359
+ (false, false) => ExclusionTransition::KeepCapturing,
1360
+ (false, true) => ExclusionTransition::Suspend,
1361
+ (true, true) => ExclusionTransition::StaySuspended,
1362
+ (true, false) => ExclusionTransition::Resume,
1363
+ }
1364
+ }
1365
+
1366
+ fn exclusion_resume_allowed(
1367
+ enabled: bool,
1368
+ paused: bool,
1369
+ stop_requested: bool,
1370
+ recognized: bool,
1371
+ exclusion_active: bool,
1372
+ producer_active: bool,
1373
+ ) -> bool {
1374
+ enabled && !paused && !stop_requested && !recognized && exclusion_active && !producer_active
1375
+ }
1376
+
1377
+ fn wait_for_rotation(
1378
+ app: &AppHandle,
1379
+ stop: &AtomicBool,
1380
+ config: &ScreenMemoryConfig,
1381
+ ) -> RotationWait {
440
1382
  let deadline = Instant::now() + Duration::from_secs(config.segment_seconds);
441
- let sample_interval = Duration::from_secs(config.sample_interval_seconds.max(1));
442
1383
  let mut next_sample = Instant::now();
1384
+ let mut next_exclusion_poll = Instant::now();
443
1385
  loop {
444
1386
  if stop.load(Ordering::Relaxed) {
445
- return true;
1387
+ return RotationWait::Stop;
446
1388
  }
447
1389
  if Instant::now() >= deadline {
448
- return false;
1390
+ return RotationWait::Rotate;
449
1391
  }
450
- if Instant::now() >= next_sample {
451
- append_event(app, sample_active_window());
452
- next_sample = Instant::now() + sample_interval;
453
- }
454
- let remaining = deadline.saturating_duration_since(Instant::now());
1392
+ let now = Instant::now();
1393
+ if now >= next_exclusion_poll {
1394
+ // Exclusion edits are privacy controls, so they must take effect on
1395
+ // the next 250 ms poll rather than waiting for the current media
1396
+ // segment (up to five minutes by default) to rotate.
1397
+ let live_config =
1398
+ normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1399
+ let (event, exclusion_reason) = sample_active_window(&live_config);
1400
+ if exclusion_transition(false, exclusion_reason.is_some())
1401
+ == ExclusionTransition::Suspend
1402
+ {
1403
+ // A concurrent stop/fence that wins the transition lock must
1404
+ // still fail closed after recognition.
1405
+ mark_active_segment_exclusion_tainted(app);
1406
+ append_event(
1407
+ app,
1408
+ coverage_gap_event(exclusion_reason.unwrap_or("privacy-exclusion")),
1409
+ );
1410
+ if let Err(error) = suspend_for_exclusion(app) {
1411
+ record_error(app, error);
1412
+ }
1413
+ if wait_for_exclusion_to_clear(app, stop) {
1414
+ return RotationWait::Stop;
1415
+ }
1416
+ // A resumed producer begins a fresh logical segment and gets a
1417
+ // fresh rotation deadline in the outer worker loop.
1418
+ return RotationWait::Resumed;
1419
+ }
1420
+ if now >= next_sample {
1421
+ append_event(app, event);
1422
+ next_sample = now + Duration::from_secs(live_config.sample_interval_seconds.max(1));
1423
+ }
1424
+ next_exclusion_poll = now + EXCLUSION_POLL_INTERVAL;
1425
+ }
1426
+ let remaining = deadline.saturating_duration_since(Instant::now());
455
1427
  let until_sample = next_sample.saturating_duration_since(Instant::now());
456
- std::thread::sleep(remaining.min(until_sample).min(ROTATOR_TICK));
1428
+ let until_exclusion = next_exclusion_poll.saturating_duration_since(Instant::now());
1429
+ std::thread::sleep(
1430
+ remaining
1431
+ .min(until_sample)
1432
+ .min(until_exclusion)
1433
+ .min(ROTATOR_TICK),
1434
+ );
457
1435
  }
458
1436
  }
459
1437
 
460
- fn sample_active_window() -> ScreenMemoryEvent {
1438
+ fn mark_active_segment_exclusion_tainted(app: &AppHandle) {
1439
+ if let Some(state) = app.try_state::<ScreenMemoryState>() {
1440
+ if let Ok(runtime) = state.inner.lock() {
1441
+ if let Some(active) = runtime.active.as_ref() {
1442
+ active.exclusion_tainted.store(true, Ordering::SeqCst);
1443
+ }
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ fn sample_active_window(config: &ScreenMemoryConfig) -> (ScreenMemoryEvent, Option<&'static str>) {
461
1449
  #[cfg(target_os = "macos")]
462
1450
  {
463
- let context = crate::accessibility::macos::active_window_context_impl();
464
- ScreenMemoryEvent {
465
- captured_at: now_iso(),
466
- app_name: context.app_name,
467
- window_title: context.window_title,
468
- bundle_id: context.bundle_id,
469
- source: context.source,
1451
+ let (context, owner_pid) =
1452
+ crate::accessibility::macos::active_window_context_with_pid_impl();
1453
+ if let Some(reason) = event_exclusion_reason(
1454
+ config,
1455
+ context.bundle_id.as_deref(),
1456
+ context.window_title.as_deref(),
1457
+ ) {
1458
+ return (coverage_gap_event(reason), Some(reason));
470
1459
  }
1460
+ (
1461
+ ScreenMemoryEvent {
1462
+ captured_at: now_iso(),
1463
+ app_name: context.app_name,
1464
+ window_title: context.window_title,
1465
+ bundle_id: context.bundle_id,
1466
+ source: context.source,
1467
+ coverage_gap_reason: None,
1468
+ // Exclusion was checked above; this silent AX read never
1469
+ // prompts and degrades to None when macOS denies/unreadable.
1470
+ accessibility: owner_pid
1471
+ .and_then(crate::accessibility::macos::semantic_fingerprint_for_pid_impl),
1472
+ },
1473
+ None,
1474
+ )
471
1475
  }
472
1476
  #[cfg(not(target_os = "macos"))]
473
1477
  {
474
- ScreenMemoryEvent {
475
- captured_at: now_iso(),
476
- app_name: None,
477
- window_title: None,
478
- bundle_id: None,
479
- source: "unsupported".to_string(),
1478
+ (
1479
+ ScreenMemoryEvent {
1480
+ captured_at: now_iso(),
1481
+ app_name: None,
1482
+ window_title: None,
1483
+ bundle_id: None,
1484
+ source: "unsupported".to_string(),
1485
+ coverage_gap_reason: None,
1486
+ accessibility: None,
1487
+ },
1488
+ None,
1489
+ )
1490
+ }
1491
+ }
1492
+
1493
+ fn event_exclusion_reason(
1494
+ config: &ScreenMemoryConfig,
1495
+ bundle_id: Option<&str>,
1496
+ window_title: Option<&str>,
1497
+ ) -> Option<&'static str> {
1498
+ let normalized_bundle_id = bundle_id.map(|value| value.trim().to_ascii_lowercase());
1499
+ if normalized_bundle_id.as_deref().is_some_and(|bundle_id| {
1500
+ config
1501
+ .excluded_bundle_ids
1502
+ .iter()
1503
+ .any(|excluded| excluded.eq_ignore_ascii_case(bundle_id))
1504
+ }) {
1505
+ return Some("excluded-bundle-id");
1506
+ }
1507
+
1508
+ if config.exclude_private_windows
1509
+ && window_title.is_some_and(|title| {
1510
+ let title = title.to_ascii_lowercase();
1511
+ [
1512
+ "incognito",
1513
+ "inprivate",
1514
+ "private browsing",
1515
+ "private window",
1516
+ ]
1517
+ .iter()
1518
+ .any(|marker| title.contains(marker))
1519
+ })
1520
+ {
1521
+ return Some("excluded-private-window");
1522
+ }
1523
+
1524
+ None
1525
+ }
1526
+
1527
+ fn coverage_gap_event(reason: &str) -> ScreenMemoryEvent {
1528
+ ScreenMemoryEvent {
1529
+ captured_at: now_iso(),
1530
+ app_name: None,
1531
+ window_title: None,
1532
+ bundle_id: None,
1533
+ source: "coverage-gap".to_string(),
1534
+ coverage_gap_reason: Some(reason.to_string()),
1535
+ accessibility: None,
1536
+ }
1537
+ }
1538
+
1539
+ /// Stop the one physical producer and erase the complete logical segment that
1540
+ /// may contain excluded media. The rotator worker deliberately remains alive:
1541
+ /// while this state is active it does nothing except poll the recognized-window
1542
+ /// signal until a safe replacement can be started under the transition lock.
1543
+ fn suspend_for_exclusion(app: &AppHandle) -> Result<(), String> {
1544
+ let state = app.state::<ScreenMemoryState>();
1545
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1546
+ let active = {
1547
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
1548
+ if runtime.exclusion_active {
1549
+ return Ok(());
480
1550
  }
1551
+ let worker_running = runtime
1552
+ .worker_stop
1553
+ .as_ref()
1554
+ .is_some_and(|stop| !stop.load(Ordering::Relaxed));
1555
+ let config = crate::config::feature_config(app).screen_memory;
1556
+ if !worker_running || !config.enabled || config.paused {
1557
+ return Ok(());
1558
+ }
1559
+ let active = runtime.active.take();
1560
+ runtime.exclusion_active = true;
1561
+ runtime.exclusion_gap = Some(match active.as_ref() {
1562
+ Some(active) => active_exclusion_gap(
1563
+ // The whole segment is discarded, so coverage ended when that
1564
+ // segment began—not merely when the excluded window was noticed.
1565
+ active.started_at,
1566
+ active.capture_mode,
1567
+ ),
1568
+ None => active_exclusion_gap(
1569
+ Instant::now(),
1570
+ effective_mode(config.capture_mode, runtime.temporary_audio_consumers.len()),
1571
+ ),
1572
+ });
1573
+ active
1574
+ };
1575
+
1576
+ if let Some(active) = active {
1577
+ let segment_id = active.id.clone();
1578
+ discard_active_segment(active);
1579
+ discard_segment_artifacts(app, &segment_id)?;
481
1580
  }
1581
+ end_rewind_lease(app);
1582
+ let _ = app.emit(SCREEN_MEMORY_EVENT, ());
1583
+ Ok(())
1584
+ }
1585
+
1586
+ fn wait_for_exclusion_to_clear(app: &AppHandle, stop: &AtomicBool) -> bool {
1587
+ loop {
1588
+ if stop.load(Ordering::Relaxed) {
1589
+ return true;
1590
+ }
1591
+ let config =
1592
+ normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1593
+ if !config.enabled || config.paused {
1594
+ return true;
1595
+ }
1596
+ let recognized = sample_active_window(&config).1.is_some();
1597
+ if exclusion_transition(true, recognized) == ExclusionTransition::Resume {
1598
+ match resume_after_exclusion(app, &config, stop) {
1599
+ Ok(true) => return false,
1600
+ Ok(false) => {}
1601
+ Err(error) => record_error(app, error),
1602
+ }
1603
+ }
1604
+ std::thread::sleep(EXCLUSION_POLL_INTERVAL);
1605
+ }
1606
+ }
1607
+
1608
+ /// Returns true only after one replacement producer is installed. A second
1609
+ /// recognition check is made while holding the producer transition lock so a
1610
+ /// config callback, temporary-audio request, or stop cannot race a second start.
1611
+ fn resume_after_exclusion(
1612
+ app: &AppHandle,
1613
+ _config: &ScreenMemoryConfig,
1614
+ stop: &AtomicBool,
1615
+ ) -> Result<bool, String> {
1616
+ let state = app.state::<ScreenMemoryState>();
1617
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1618
+ if crate::rewind_capture_suspension::is_active(app) {
1619
+ return Ok(false);
1620
+ }
1621
+ let current = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1622
+ let recognized = sample_active_window(&current).1.is_some();
1623
+ let (exclusion_active, producer_active) = {
1624
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
1625
+ (runtime.exclusion_active, runtime.active.is_some())
1626
+ };
1627
+ if !exclusion_resume_allowed(
1628
+ current.enabled,
1629
+ current.paused,
1630
+ stop.load(Ordering::Relaxed),
1631
+ recognized,
1632
+ exclusion_active,
1633
+ producer_active,
1634
+ ) {
1635
+ return Ok(producer_active);
1636
+ }
1637
+
1638
+ let mode = effective_capture_mode(app, current.capture_mode);
1639
+ let next = start_new_segment(app, mode)?;
1640
+ if let Err(error) = install_rotated_segment(app, next) {
1641
+ let failed = state
1642
+ .inner
1643
+ .lock()
1644
+ .ok()
1645
+ .and_then(|mut runtime| runtime.active.take());
1646
+ if let Some(failed) = failed {
1647
+ discard_active_segment(failed);
1648
+ }
1649
+ end_rewind_lease(app);
1650
+ return Err(error);
1651
+ }
1652
+
1653
+ let gap = {
1654
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
1655
+ runtime.exclusion_active = false;
1656
+ runtime.exclusion_gap.take()
1657
+ };
1658
+ if let Some(gap) = gap {
1659
+ record_source_coverage_gap(app, gap, Instant::now());
1660
+ }
1661
+ let _ = app.emit(SCREEN_MEMORY_EVENT, ());
1662
+ Ok(true)
1663
+ }
1664
+
1665
+ fn record_source_coverage_gap(app: &AppHandle, gap: ActiveExclusionGap, ended_at: Instant) {
1666
+ let graph_state = app.state::<CaptureGraphState>();
1667
+ let Ok(mut graph) = graph_state.0.lock() else {
1668
+ return;
1669
+ };
1670
+ for source in gap.sources {
1671
+ if let Err(error) = graph.record_coverage_gap_at(
1672
+ source,
1673
+ CoverageGapReason::PrivacyExclusion,
1674
+ gap.started_at,
1675
+ ended_at,
1676
+ ) {
1677
+ eprintln!("[clips-tray] exclusion coverage gap record failed: {error}");
1678
+ }
1679
+ }
1680
+ }
1681
+
1682
+ fn discard_segment_artifacts(app: &AppHandle, segment_id: &str) -> Result<(), String> {
1683
+ let state = app.state::<ScreenMemoryState>();
1684
+ {
1685
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
1686
+ runtime.ocr_queue.retain(|job| job.segment.id != segment_id);
1687
+ runtime
1688
+ .ocr_cancelled_segments
1689
+ .insert(segment_id.to_string());
1690
+ runtime
1691
+ .transcript_queue
1692
+ .retain(|job| job.segment.id != segment_id);
1693
+ runtime
1694
+ .transcript_cancelled_segments
1695
+ .insert(segment_id.to_string());
1696
+ }
1697
+ let dir = screen_memory_dir(app)?;
1698
+ let (transcript_rows, transcript_status) = transcript_sidecar_paths(&dir, segment_id);
1699
+ remove_discarded_sidecars(&[
1700
+ segment_metadata_path(app, segment_id)?,
1701
+ segment_ocr_rows_path(app, segment_id)?,
1702
+ segment_ocr_status_path(app, segment_id)?,
1703
+ transcript_rows,
1704
+ transcript_status,
1705
+ ])
1706
+ }
1707
+
1708
+ fn remove_discarded_sidecars(paths: &[PathBuf]) -> Result<(), String> {
1709
+ for path in paths {
1710
+ remove_file_if_exists(path)?;
1711
+ }
1712
+ Ok(())
482
1713
  }
483
1714
 
484
1715
  fn append_event(app: &AppHandle, event: ScreenMemoryEvent) {
@@ -501,6 +1732,14 @@ fn append_event_inner(app: &AppHandle, event: &ScreenMemoryEvent) -> Result<(),
501
1732
  }
502
1733
 
503
1734
  fn rotate_segment(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
1735
+ let state = app.state::<ScreenMemoryState>();
1736
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1737
+ rotate_segment_inner(app, config)
1738
+ }
1739
+
1740
+ fn rotate_segment_inner(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), String> {
1741
+ let mut config = config.clone();
1742
+ config.capture_mode = effective_capture_mode(app, config.capture_mode);
504
1743
  let active = {
505
1744
  let state = app.state::<ScreenMemoryState>();
506
1745
  let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
@@ -508,18 +1747,96 @@ fn rotate_segment(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
508
1747
  };
509
1748
 
510
1749
  if let Some(active) = active {
511
- match finalize_active_segment(active) {
512
- Ok(segment) => {
513
- write_segment_metadata(app, &segment)?;
514
- prune_segments(app, config)?;
1750
+ if active.exclusion_tainted.load(Ordering::SeqCst) {
1751
+ let gap = active_exclusion_gap(active.started_at, active.capture_mode);
1752
+ let segment_id = active.id.clone();
1753
+ {
1754
+ let state = app.state::<ScreenMemoryState>();
1755
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
1756
+ runtime.exclusion_active = true;
1757
+ runtime.exclusion_gap = Some(gap);
1758
+ }
1759
+ discard_active_segment(active);
1760
+ discard_segment_artifacts(app, &segment_id)?;
1761
+ end_rewind_lease(app);
1762
+ let _ = app.emit(SCREEN_MEMORY_EVENT, ());
1763
+ return Ok(());
1764
+ }
1765
+ // A normal rotation on the segmented custom SCK backend only fences
1766
+ // the fMP4 writer. The stream and physical producer stay alive; a
1767
+ // mode change must still stop/restart because its source contract has
1768
+ // changed.
1769
+ if active.capture_mode == config.capture_mode {
1770
+ match fence_rotate_segment(app, active) {
1771
+ Ok((segment, next)) => {
1772
+ // The physical stream is already writing the next file.
1773
+ // Do not let an auxiliary metadata/OCR failure drop it.
1774
+ if let Err(error) = write_segment_metadata(app, &segment) {
1775
+ record_error(app, error);
1776
+ } else {
1777
+ enqueue_segment_ocr(app, segment.clone(), config.sample_interval_seconds);
1778
+ if let Err(error) = prune_segments(app, &config) {
1779
+ record_error(app, error);
1780
+ }
1781
+ }
1782
+ return install_rotated_segment(app, next);
1783
+ }
1784
+ Err(active) => {
1785
+ // Non-segmented fallback and failed fences retain the
1786
+ // old stop/start behavior. A failed fence never creates
1787
+ // metadata for a path that has not closed.
1788
+ match finalize_active_segment(app, active) {
1789
+ Ok(segment) => {
1790
+ write_segment_metadata(app, &segment)?;
1791
+ enqueue_segment_ocr(
1792
+ app,
1793
+ segment.clone(),
1794
+ config.sample_interval_seconds,
1795
+ );
1796
+ prune_segments(app, &config)?;
1797
+ }
1798
+ Err(err) => {
1799
+ record_error(app, err);
1800
+ end_rewind_lease(app);
1801
+ }
1802
+ }
1803
+ }
515
1804
  }
516
- Err(err) => {
517
- record_error(app, err);
1805
+ } else {
1806
+ // The current graph lease must describe the actual producer source
1807
+ // set. End it before the safe stop/start mode transition;
1808
+ // `install_rotated_segment` starts the replacement lease from the
1809
+ // new effective mode.
1810
+ end_rewind_lease(app);
1811
+ match finalize_active_segment(app, active) {
1812
+ Ok(segment) => {
1813
+ write_segment_metadata(app, &segment)?;
1814
+ enqueue_segment_ocr(app, segment.clone(), config.sample_interval_seconds);
1815
+ prune_segments(app, &config)?;
1816
+ }
1817
+ Err(err) => {
1818
+ record_error(app, err);
1819
+ // The rolling producer has reported an error; its lease must
1820
+ // not imply uninterrupted coverage across the replacement.
1821
+ end_rewind_lease(app);
1822
+ }
518
1823
  }
519
1824
  }
520
1825
  }
521
1826
 
522
- let mut next = Some(start_new_segment(app)?);
1827
+ let next = match start_new_segment(app, config.capture_mode) {
1828
+ Ok(next) => next,
1829
+ Err(err) => {
1830
+ end_rewind_lease(app);
1831
+ return Err(err);
1832
+ }
1833
+ };
1834
+ install_rotated_segment(app, next)
1835
+ }
1836
+
1837
+ fn install_rotated_segment(app: &AppHandle, next: ActiveScreenMemorySegment) -> Result<(), String> {
1838
+ let capture_mode = next.capture_mode;
1839
+ let mut next = Some(next);
523
1840
  let installed = {
524
1841
  let state = app.state::<ScreenMemoryState>();
525
1842
  let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
@@ -542,28 +1859,155 @@ fn rotate_segment(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
542
1859
  }
543
1860
  }
544
1861
 
1862
+ if installed {
1863
+ let needs_lease = app
1864
+ .state::<ScreenMemoryState>()
1865
+ .inner
1866
+ .lock()
1867
+ .ok()
1868
+ .is_some_and(|runtime| runtime.rewind_lease_id.is_none());
1869
+ if needs_lease {
1870
+ start_rewind_lease(app, capture_mode)?;
1871
+ }
1872
+ }
1873
+
545
1874
  let _ = app.emit(SCREEN_MEMORY_EVENT, ());
546
1875
  Ok(())
547
1876
  }
548
1877
 
1878
+ /// Returns the fenced metadata plus the replacement logical segment while
1879
+ /// preserving the one physical custom SCK backend. On unsupported/failing
1880
+ /// backends it returns the untouched active segment for stop/start fallback.
1881
+ fn fence_rotate_segment(
1882
+ app: &AppHandle,
1883
+ active: ActiveScreenMemorySegment,
1884
+ ) -> Result<(ScreenMemorySegmentMetadata, ActiveScreenMemorySegment), ActiveScreenMemorySegment> {
1885
+ #[cfg(not(target_os = "macos"))]
1886
+ {
1887
+ let _ = app;
1888
+ Err(active)
1889
+ }
1890
+ #[cfg(target_os = "macos")]
1891
+ {
1892
+ let next_id = next_segment_id();
1893
+ let Ok(next_path) = segment_media_path(app, &next_id, "mp4") else {
1894
+ return Err(active);
1895
+ };
1896
+ let Ok(fence) = active.backend.request_fragment_fence(next_path.clone()) else {
1897
+ return Err(active);
1898
+ };
1899
+ let Ok(closed) =
1900
+ NativeFullscreenBackend::await_fragment_fence(fence, Duration::from_secs(15))
1901
+ else {
1902
+ return Err(active);
1903
+ };
1904
+ let boundary = Instant::now();
1905
+ let Ok(segment) = finalize_fenced_segment(&active, closed.path, boundary) else {
1906
+ return Err(active);
1907
+ };
1908
+ let next = ActiveScreenMemorySegment {
1909
+ id: next_id,
1910
+ path: next_path,
1911
+ mime_type: MP4_RECORDING_MIME_TYPE,
1912
+ backend: active.backend,
1913
+ started_at: boundary,
1914
+ started_at_iso: now_iso(),
1915
+ width: active.width,
1916
+ height: active.height,
1917
+ capture_mode: active.capture_mode,
1918
+ exclusion_tainted: Arc::new(AtomicBool::new(false)),
1919
+ graph_epoch_id: active.graph_epoch_id,
1920
+ graph_started_elapsed_ms: graph_elapsed_ms(app, boundary),
1921
+ };
1922
+ Ok((segment, next))
1923
+ }
1924
+ }
1925
+
549
1926
  fn stop_active_segment(app: &AppHandle) -> Result<Option<ScreenMemorySegmentMetadata>, String> {
550
- let active = {
1927
+ let state = app.state::<ScreenMemoryState>();
1928
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1929
+ stop_active_segment_inner(app)
1930
+ }
1931
+
1932
+ /// Temporarily yields the one physical producer to an ordinary recorder while
1933
+ /// leaving the persisted Rewind enabled/paused setting untouched. An active
1934
+ /// privacy exclusion is already physically suspended and remains authoritative.
1935
+ pub(crate) fn suspend_physical_capture(app: &AppHandle) -> Result<(), String> {
1936
+ let state = app.state::<ScreenMemoryState>();
1937
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1938
+ let exclusion_active = state
1939
+ .inner
1940
+ .lock()
1941
+ .map_err(|error| error.to_string())?
1942
+ .exclusion_active;
1943
+ if !exclusion_active {
1944
+ stop_active_segment_inner(app)?;
1945
+ }
1946
+ Ok(())
1947
+ }
1948
+
1949
+ /// Restores Rewind after an ordinary recorder releases the producer. This is a
1950
+ /// no-op while disabled, paused, or privacy-excluded and therefore cannot start
1951
+ /// a competing producer or bypass the exclusion poller.
1952
+ pub(crate) fn resume_physical_capture(app: &AppHandle) -> Result<(), String> {
1953
+ let state = app.state::<ScreenMemoryState>();
1954
+ let _transition = state.transition.lock().map_err(|error| error.to_string())?;
1955
+ let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
1956
+ let exclusion_active = state
1957
+ .inner
1958
+ .lock()
1959
+ .map_err(|error| error.to_string())?
1960
+ .exclusion_active;
1961
+ if config.enabled && !config.paused && !exclusion_active {
1962
+ ensure_running_inner(app, &config)?;
1963
+ }
1964
+ Ok(())
1965
+ }
1966
+
1967
+ fn stop_active_segment_inner(
1968
+ app: &AppHandle,
1969
+ ) -> Result<Option<ScreenMemorySegmentMetadata>, String> {
1970
+ let (active, exclusion_gap) = {
551
1971
  let state = app.state::<ScreenMemoryState>();
552
1972
  let mut guard = state.inner.lock().map_err(|e| e.to_string())?;
553
1973
  if let Some(stop) = guard.worker_stop.take() {
554
1974
  stop.store(true, Ordering::Relaxed);
555
1975
  }
556
- guard.active.take()
1976
+ guard.exclusion_active = false;
1977
+ (guard.active.take(), guard.exclusion_gap.take())
557
1978
  };
558
1979
 
1980
+ if let Some(gap) = exclusion_gap {
1981
+ record_source_coverage_gap(app, gap, Instant::now());
1982
+ }
1983
+
559
1984
  let Some(active) = active else {
1985
+ end_rewind_lease(app);
1986
+ let _ = app.emit(SCREEN_MEMORY_EVENT, ());
560
1987
  return Ok(None);
561
1988
  };
562
1989
 
563
- let segment = finalize_active_segment(active)?;
564
- write_segment_metadata(app, &segment)?;
565
- let config = normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
566
- prune_segments(app, &config)?;
1990
+ if active.exclusion_tainted.load(Ordering::SeqCst) {
1991
+ let gap = active_exclusion_gap(active.started_at, active.capture_mode);
1992
+ let segment_id = active.id.clone();
1993
+ discard_active_segment(active);
1994
+ discard_segment_artifacts(app, &segment_id)?;
1995
+ record_source_coverage_gap(app, gap, Instant::now());
1996
+ end_rewind_lease(app);
1997
+ let _ = app.emit(SCREEN_MEMORY_EVENT, ());
1998
+ return Ok(None);
1999
+ }
2000
+
2001
+ let result = finalize_active_segment(app, active).and_then(|segment| {
2002
+ write_segment_metadata(app, &segment)?;
2003
+ let config =
2004
+ normalize_screen_memory_config(crate::config::feature_config(app).screen_memory);
2005
+ enqueue_segment_ocr(app, segment.clone(), config.sample_interval_seconds);
2006
+ prune_segments(app, &config)?;
2007
+ Ok(segment)
2008
+ });
2009
+ end_rewind_lease(app);
2010
+ let segment = result?;
567
2011
  let _ = app.emit(SCREEN_MEMORY_EVENT, ());
568
2012
  Ok(Some(segment))
569
2013
  }
@@ -584,7 +2028,10 @@ fn stop_active_segment_if_matches(app: &AppHandle, segment_id: &str) -> Result<(
584
2028
  Ok(())
585
2029
  }
586
2030
 
587
- fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, String> {
2031
+ fn start_new_segment(
2032
+ app: &AppHandle,
2033
+ capture_mode: RewindCaptureMode,
2034
+ ) -> Result<ActiveScreenMemorySegment, String> {
588
2035
  #[cfg(not(target_os = "macos"))]
589
2036
  {
590
2037
  let _ = app;
@@ -610,10 +2057,12 @@ fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, Strin
610
2057
  let _ = std::fs::remove_file(&mp4_path);
611
2058
  let (fallback_width, fallback_height) = native_screen::primary_monitor_size(app);
612
2059
 
613
- match native_screen::start_screencapturekit_backend_at(
2060
+ let visuals_audio = capture_mode == RewindCaptureMode::VisualsAudio;
2061
+ match native_screen::start_segmented_custom_screencapturekit_backend_at(
2062
+ app,
614
2063
  &mp4_path,
615
- false,
616
- false,
2064
+ visuals_audio,
2065
+ visuals_audio,
617
2066
  None,
618
2067
  None,
619
2068
  target_display_id,
@@ -630,9 +2079,20 @@ fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, Strin
630
2079
  started_at_iso: now_iso(),
631
2080
  width: width.or(fallback_width),
632
2081
  height: height.or(fallback_height),
2082
+ capture_mode,
2083
+ exclusion_tainted: Arc::new(AtomicBool::new(false)),
2084
+ graph_epoch_id: graph_epoch_id(app),
2085
+ graph_started_elapsed_ms: graph_elapsed_ms(app, Instant::now()),
633
2086
  });
634
2087
  }
635
2088
  Err(sck_err) => {
2089
+ if visuals_audio {
2090
+ record_requested_source_gaps(app, capture_mode);
2091
+ let _ = std::fs::remove_file(&mp4_path);
2092
+ return Err(format!(
2093
+ "Screen Memory visuals + audio could not start its required ScreenCaptureKit screen, system-audio, and microphone sources: {sck_err}"
2094
+ ));
2095
+ }
636
2096
  eprintln!(
637
2097
  "[clips-tray] Screen Memory ScreenCaptureKit unavailable; falling back to screencapture: {sck_err}"
638
2098
  );
@@ -658,22 +2118,109 @@ fn start_new_segment(app: &AppHandle) -> Result<ActiveScreenMemorySegment, Strin
658
2118
  started_at_iso: now_iso(),
659
2119
  width: width.or(fallback_width),
660
2120
  height: height.or(fallback_height),
2121
+ capture_mode,
2122
+ exclusion_tainted: Arc::new(AtomicBool::new(false)),
2123
+ graph_epoch_id: graph_epoch_id(app),
2124
+ graph_started_elapsed_ms: graph_elapsed_ms(app, Instant::now()),
661
2125
  })
662
2126
  }
663
2127
  }
664
2128
 
2129
+ fn requested_sources(capture_mode: RewindCaptureMode) -> Vec<CaptureSource> {
2130
+ match capture_mode {
2131
+ RewindCaptureMode::Visuals => vec![CaptureSource::Screen],
2132
+ RewindCaptureMode::VisualsAudio => vec![
2133
+ CaptureSource::Screen,
2134
+ CaptureSource::SystemAudio,
2135
+ CaptureSource::Microphone,
2136
+ ],
2137
+ }
2138
+ }
2139
+
2140
+ fn start_rewind_lease(app: &AppHandle, capture_mode: RewindCaptureMode) -> Result<(), String> {
2141
+ let lease = {
2142
+ let graph = app.state::<CaptureGraphState>();
2143
+ let mut graph = graph.0.lock().map_err(|err| err.to_string())?;
2144
+ graph
2145
+ .start_consumer(CaptureConsumer::Rewind, requested_sources(capture_mode))
2146
+ .map_err(capture_graph_error)?
2147
+ };
2148
+ let state = app.state::<ScreenMemoryState>();
2149
+ let mut runtime = state.inner.lock().map_err(|err| err.to_string())?;
2150
+ runtime.rewind_lease_id = Some(lease.id);
2151
+ Ok(())
2152
+ }
2153
+
2154
+ fn end_rewind_lease(app: &AppHandle) {
2155
+ let lease_id = app
2156
+ .state::<ScreenMemoryState>()
2157
+ .inner
2158
+ .lock()
2159
+ .ok()
2160
+ .and_then(|mut runtime| runtime.rewind_lease_id.take());
2161
+ if let Some(lease_id) = lease_id {
2162
+ let graph_state = app.state::<CaptureGraphState>();
2163
+ if let Ok(mut graph) = graph_state.0.lock() {
2164
+ if let Err(err) = graph.end_consumer(&lease_id) {
2165
+ eprintln!("[clips-tray] Screen Memory Rewind lease close failed: {err}");
2166
+ }
2167
+ };
2168
+ }
2169
+ }
2170
+
2171
+ fn record_requested_source_gaps(app: &AppHandle, capture_mode: RewindCaptureMode) {
2172
+ let now = Instant::now();
2173
+ let graph_state = app.state::<CaptureGraphState>();
2174
+ let Ok(mut graph) = graph_state.0.lock() else {
2175
+ return;
2176
+ };
2177
+ for source in requested_sources(capture_mode) {
2178
+ if let Err(err) =
2179
+ graph.record_coverage_gap_at(source, CoverageGapReason::ProducerUnavailable, now, now)
2180
+ {
2181
+ eprintln!("[clips-tray] Screen Memory coverage gap record failed: {err}");
2182
+ }
2183
+ }
2184
+ }
2185
+
2186
+ fn capture_graph_error(error: CaptureGraphError) -> String {
2187
+ format!("Screen Memory capture graph error: {error}")
2188
+ }
2189
+
2190
+ fn graph_elapsed_ms(app: &AppHandle, instant: Instant) -> u64 {
2191
+ app.try_state::<CaptureGraphState>()
2192
+ .and_then(|state| {
2193
+ state
2194
+ .0
2195
+ .lock()
2196
+ .ok()
2197
+ .and_then(|graph| graph.status_at(instant).ok())
2198
+ })
2199
+ .map(|status| status.graph_elapsed_ms)
2200
+ .unwrap_or_default()
2201
+ }
2202
+
2203
+ fn graph_epoch_id(app: &AppHandle) -> String {
2204
+ app.try_state::<CaptureGraphState>()
2205
+ .and_then(|state| state.0.lock().ok().map(|graph| graph.epoch_id()))
2206
+ .unwrap_or_default()
2207
+ }
2208
+
665
2209
  fn discard_active_segment(mut active: ActiveScreenMemorySegment) {
666
2210
  let _ = native_screen::stop_native_recording(&mut active.backend, false);
667
- let _ = std::fs::remove_file(active.path);
2211
+ let _ = std::fs::remove_file(&active.path);
2212
+ let _ = std::fs::remove_file(audio_sidecar_path(&active.path, "system"));
2213
+ let _ = std::fs::remove_file(audio_sidecar_path(&active.path, "microphone"));
668
2214
  }
669
2215
 
670
2216
  fn finalize_active_segment(
2217
+ app: &AppHandle,
671
2218
  mut active: ActiveScreenMemorySegment,
672
2219
  ) -> Result<ScreenMemorySegmentMetadata, String> {
673
2220
  let stop_error = native_screen::stop_native_recording(&mut active.backend, true).err();
674
2221
  let ended_at = now_iso();
675
2222
  let duration_ms = active.started_at.elapsed().as_millis();
676
- let bytes = std::fs::metadata(&active.path)
2223
+ let video_bytes = std::fs::metadata(&active.path)
677
2224
  .map_err(|e| {
678
2225
  let suffix = stop_error
679
2226
  .as_ref()
@@ -682,10 +2229,20 @@ fn finalize_active_segment(
682
2229
  format!("Screen Memory segment missing{suffix}: {e}")
683
2230
  })?
684
2231
  .len();
685
- if bytes == 0 {
2232
+ if video_bytes == 0 {
686
2233
  let _ = std::fs::remove_file(&active.path);
687
- return Err("Screen Memory segment produced an empty file.".to_string());
2234
+ return Err(stop_error.unwrap_or_else(|| {
2235
+ "Screen Memory segment produced an empty file with no more specific backend error."
2236
+ .to_string()
2237
+ }));
688
2238
  }
2239
+ let (system_audio_path, microphone_path) =
2240
+ existing_audio_sidecars(&active.path, active.capture_mode);
2241
+ let bytes = media_unit_bytes(
2242
+ &active.path,
2243
+ system_audio_path.as_deref(),
2244
+ microphone_path.as_deref(),
2245
+ );
689
2246
 
690
2247
  let corrupt = if active.mime_type == MP4_RECORDING_MIME_TYPE
691
2248
  || active.mime_type == QUICKTIME_RECORDING_MIME_TYPE
@@ -718,11 +2275,476 @@ fn finalize_active_segment(
718
2275
  width: active.width,
719
2276
  height: active.height,
720
2277
  bytes,
2278
+ system_audio_path,
2279
+ microphone_path,
721
2280
  corrupt,
722
2281
  error,
2282
+ capture_mode: active.capture_mode,
2283
+ exclusion_tainted: active.exclusion_tainted.load(Ordering::Relaxed),
2284
+ graph_epoch_id: Some(active.graph_epoch_id),
2285
+ graph_started_elapsed_ms: active.graph_started_elapsed_ms,
2286
+ graph_ended_elapsed_ms: graph_elapsed_ms(app, Instant::now()),
723
2287
  })
724
2288
  }
725
2289
 
2290
+ fn finalize_fenced_segment(
2291
+ active: &ActiveScreenMemorySegment,
2292
+ closed_path: PathBuf,
2293
+ ended_at: Instant,
2294
+ ) -> Result<ScreenMemorySegmentMetadata, String> {
2295
+ let video_bytes = std::fs::metadata(&closed_path)
2296
+ .map_err(|error| format!("fenced Screen Memory segment missing: {error}"))?
2297
+ .len();
2298
+ if video_bytes == 0 {
2299
+ return Err("fenced Screen Memory segment produced an empty file.".to_string());
2300
+ }
2301
+ let (system_audio_path, microphone_path) =
2302
+ existing_audio_sidecars(&closed_path, active.capture_mode);
2303
+ let bytes = media_unit_bytes(
2304
+ &closed_path,
2305
+ system_audio_path.as_deref(),
2306
+ microphone_path.as_deref(),
2307
+ );
2308
+ Ok(ScreenMemorySegmentMetadata {
2309
+ id: active.id.clone(),
2310
+ file_name: closed_path
2311
+ .file_name()
2312
+ .map(|value| value.to_string_lossy().to_string())
2313
+ .unwrap_or_default(),
2314
+ path: closed_path.clone(),
2315
+ mime_type: MP4_RECORDING_MIME_TYPE.to_string(),
2316
+ started_at: active.started_at_iso.clone(),
2317
+ ended_at: now_iso(),
2318
+ duration_ms: active.started_at.elapsed().as_millis(),
2319
+ width: active.width,
2320
+ height: active.height,
2321
+ bytes,
2322
+ system_audio_path,
2323
+ microphone_path,
2324
+ // fMP4 finalization is asynchronous. A fence proves a fragment
2325
+ // boundary, not that every player accepts the logical file yet.
2326
+ corrupt: native_screen::mp4_has_moov(&closed_path) == Some(false),
2327
+ error: None,
2328
+ capture_mode: active.capture_mode,
2329
+ exclusion_tainted: active.exclusion_tainted.load(Ordering::Relaxed),
2330
+ graph_epoch_id: Some(active.graph_epoch_id.clone()),
2331
+ graph_started_elapsed_ms: active.graph_started_elapsed_ms,
2332
+ graph_ended_elapsed_ms: active
2333
+ .graph_started_elapsed_ms
2334
+ .saturating_add(ended_at.duration_since(active.started_at).as_millis() as u64),
2335
+ })
2336
+ }
2337
+
2338
+ fn enqueue_segment_ocr(
2339
+ app: &AppHandle,
2340
+ segment: ScreenMemorySegmentMetadata,
2341
+ sample_interval_seconds: u64,
2342
+ ) {
2343
+ if !segment_derivatives_eligible(&segment) {
2344
+ return;
2345
+ }
2346
+ if let Err(error) = write_ocr_status(
2347
+ app,
2348
+ &segment.id,
2349
+ &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::pending(),
2350
+ ) {
2351
+ record_error(app, error);
2352
+ return;
2353
+ }
2354
+ let should_start = app.try_state::<ScreenMemoryState>().is_some_and(|state| {
2355
+ state
2356
+ .inner
2357
+ .lock()
2358
+ .map(|mut runtime| {
2359
+ if runtime.ocr_cancelled_segments.contains(&segment.id) {
2360
+ return false;
2361
+ }
2362
+ runtime.ocr_queue.push_back(ScreenMemoryOcrJob {
2363
+ segment,
2364
+ sample_interval_seconds,
2365
+ });
2366
+ if runtime.ocr_worker_running {
2367
+ false
2368
+ } else {
2369
+ runtime.ocr_worker_running = true;
2370
+ true
2371
+ }
2372
+ })
2373
+ .unwrap_or(false)
2374
+ });
2375
+ if should_start {
2376
+ let app = app.clone();
2377
+ std::thread::spawn(move || run_ocr_queue(app));
2378
+ }
2379
+ }
2380
+
2381
+ fn run_ocr_queue(app: AppHandle) {
2382
+ loop {
2383
+ let Some(state) = app.try_state::<ScreenMemoryState>() else {
2384
+ return;
2385
+ };
2386
+ let job = {
2387
+ let Ok(mut runtime) = state.inner.lock() else {
2388
+ return;
2389
+ };
2390
+ let Some(job) = take_next_ocr_job(&mut runtime) else {
2391
+ return;
2392
+ };
2393
+ job
2394
+ };
2395
+ let state = app.state::<ScreenMemoryState>();
2396
+ let Ok(_indexing) = state.indexing.lock() else {
2397
+ return;
2398
+ };
2399
+ let _ = run_ocr_job(&app, job);
2400
+ if let Ok(mut runtime) = state.inner.lock() {
2401
+ if let Some(segment_id) = runtime.ocr_active_segment.take() {
2402
+ runtime.ocr_cancelled_segments.remove(&segment_id);
2403
+ }
2404
+ };
2405
+ }
2406
+ }
2407
+
2408
+ fn take_next_ocr_job(runtime: &mut ScreenMemoryRuntime) -> Option<ScreenMemoryOcrJob> {
2409
+ let job = runtime.ocr_queue.pop_front();
2410
+ if let Some(job) = job.as_ref() {
2411
+ runtime.ocr_active_segment = Some(job.segment.id.clone());
2412
+ } else {
2413
+ runtime.ocr_worker_running = false;
2414
+ }
2415
+ job
2416
+ }
2417
+
2418
+ fn write_ocr_status(
2419
+ app: &AppHandle,
2420
+ segment_id: &str,
2421
+ status: &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus,
2422
+ ) -> Result<(), String> {
2423
+ let data = serde_json::to_vec_pretty(status)
2424
+ .map_err(|err| format!("screen memory OCR status encode failed: {err}"))?;
2425
+ let state = app.state::<ScreenMemoryState>();
2426
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
2427
+ if runtime.ocr_cancelled_segments.contains(segment_id) {
2428
+ return Ok(());
2429
+ }
2430
+ std::fs::write(segment_ocr_status_path(app, segment_id)?, data)
2431
+ .map_err(|err| format!("screen memory OCR status write failed: {err}"))
2432
+ }
2433
+
2434
+ fn write_ocr_rows(
2435
+ app: &AppHandle,
2436
+ segment_id: &str,
2437
+ rows: &[crate::screen_memory_ocr::ScreenMemoryOcrRow],
2438
+ ) -> Result<(), String> {
2439
+ let state = app.state::<ScreenMemoryState>();
2440
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
2441
+ if runtime.ocr_cancelled_segments.contains(segment_id) {
2442
+ return Ok(());
2443
+ }
2444
+ let rows_path = segment_ocr_rows_path(app, segment_id)?;
2445
+ let mut file = File::create(rows_path)
2446
+ .map_err(|err| format!("screen memory OCR rows open failed: {err}"))?;
2447
+ for row in rows {
2448
+ serde_json::to_writer(&mut file, row)
2449
+ .map_err(|err| format!("screen memory OCR row encode failed: {err}"))?;
2450
+ file.write_all(b"\n")
2451
+ .map_err(|err| format!("screen memory OCR row write failed: {err}"))?;
2452
+ }
2453
+ Ok(())
2454
+ }
2455
+
2456
+ fn run_ocr_job(app: &AppHandle, job: ScreenMemoryOcrJob) -> Result<(), String> {
2457
+ let started_at = now_iso();
2458
+ if !job.segment.path.exists() {
2459
+ return Ok(());
2460
+ }
2461
+ if job.segment.exclusion_tainted {
2462
+ return write_ocr_status(
2463
+ app,
2464
+ &job.segment.id,
2465
+ &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::skipped(now_iso()),
2466
+ );
2467
+ }
2468
+ if job.segment.corrupt || job.segment.error.is_some() {
2469
+ return write_ocr_status(
2470
+ app,
2471
+ &job.segment.id,
2472
+ &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::skipped(now_iso()),
2473
+ );
2474
+ }
2475
+ write_ocr_status(
2476
+ app,
2477
+ &job.segment.id,
2478
+ &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::indexing(started_at.clone()),
2479
+ )?;
2480
+ let attempted = crate::screen_memory_ocr::requested_frame_offsets(
2481
+ job.segment.duration_ms as u64,
2482
+ job.sample_interval_seconds,
2483
+ )
2484
+ .len();
2485
+ match crate::screen_memory_ocr::recognize_segment(
2486
+ &job.segment.path,
2487
+ &job.segment.id,
2488
+ DateTime::parse_from_rfc3339(&job.segment.started_at)
2489
+ .map_err(|_| "segment start timestamp unavailable".to_string())?
2490
+ .with_timezone(&Utc),
2491
+ job.sample_interval_seconds,
2492
+ ) {
2493
+ Ok(rows) => {
2494
+ write_ocr_rows(app, &job.segment.id, &rows)?;
2495
+ let result = write_ocr_status(
2496
+ app,
2497
+ &job.segment.id,
2498
+ &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::ready(
2499
+ attempted,
2500
+ rows.len(),
2501
+ started_at,
2502
+ now_iso(),
2503
+ ),
2504
+ );
2505
+ let _ = refresh_rewind_chapters(app);
2506
+ result
2507
+ }
2508
+ Err(error) => {
2509
+ let result = write_ocr_status(
2510
+ app,
2511
+ &job.segment.id,
2512
+ &crate::screen_memory_ocr::ScreenMemoryOcrIndexStatus::failed(
2513
+ attempted,
2514
+ 0,
2515
+ started_at,
2516
+ now_iso(),
2517
+ error,
2518
+ ),
2519
+ );
2520
+ let _ = refresh_rewind_chapters(app);
2521
+ result
2522
+ }
2523
+ }
2524
+ }
2525
+
2526
+ fn enqueue_segment_transcript(app: &AppHandle, segment: ScreenMemorySegmentMetadata) {
2527
+ use crate::screen_memory_transcript::ScreenMemoryTranscriptStatus;
2528
+
2529
+ if !segment_transcript_eligible(&segment) {
2530
+ if let Err(error) = write_transcript_status(
2531
+ app,
2532
+ &segment.id,
2533
+ &ScreenMemoryTranscriptStatus::skipped(now_iso()),
2534
+ ) {
2535
+ record_error(app, error);
2536
+ }
2537
+ return;
2538
+ }
2539
+ if let Err(error) =
2540
+ write_transcript_status(app, &segment.id, &ScreenMemoryTranscriptStatus::pending())
2541
+ {
2542
+ record_error(app, error);
2543
+ return;
2544
+ }
2545
+ let should_start = app.try_state::<ScreenMemoryState>().is_some_and(|state| {
2546
+ state
2547
+ .inner
2548
+ .lock()
2549
+ .map(|mut runtime| {
2550
+ if runtime.transcript_cancelled_segments.contains(&segment.id) {
2551
+ return false;
2552
+ }
2553
+ runtime
2554
+ .transcript_queue
2555
+ .push_back(ScreenMemoryTranscriptJob { segment });
2556
+ if runtime.transcript_worker_running {
2557
+ false
2558
+ } else {
2559
+ runtime.transcript_worker_running = true;
2560
+ true
2561
+ }
2562
+ })
2563
+ .unwrap_or(false)
2564
+ });
2565
+ if should_start {
2566
+ let app = app.clone();
2567
+ std::thread::spawn(move || run_transcript_queue(app));
2568
+ }
2569
+ }
2570
+
2571
+ fn segment_derivatives_eligible(segment: &ScreenMemorySegmentMetadata) -> bool {
2572
+ !segment.exclusion_tainted
2573
+ }
2574
+
2575
+ fn segment_transcript_eligible(segment: &ScreenMemorySegmentMetadata) -> bool {
2576
+ segment.capture_mode == RewindCaptureMode::VisualsAudio
2577
+ && segment_derivatives_eligible(segment)
2578
+ && !segment.corrupt
2579
+ && segment.error.is_none()
2580
+ && segment
2581
+ .system_audio_path
2582
+ .as_ref()
2583
+ .is_some_and(|path| path.exists())
2584
+ && segment
2585
+ .microphone_path
2586
+ .as_ref()
2587
+ .is_some_and(|path| path.exists())
2588
+ }
2589
+
2590
+ fn run_transcript_queue(app: AppHandle) {
2591
+ loop {
2592
+ let Some(state) = app.try_state::<ScreenMemoryState>() else {
2593
+ return;
2594
+ };
2595
+ let job = {
2596
+ let Ok(mut runtime) = state.inner.lock() else {
2597
+ return;
2598
+ };
2599
+ let Some(job) = take_next_transcript_job(&mut runtime) else {
2600
+ return;
2601
+ };
2602
+ job
2603
+ };
2604
+ let state = app.state::<ScreenMemoryState>();
2605
+ let Ok(_indexing) = state.indexing.lock() else {
2606
+ return;
2607
+ };
2608
+ let _ = run_transcript_job(&app, job);
2609
+ if let Ok(mut runtime) = state.inner.lock() {
2610
+ if let Some(segment_id) = runtime.transcript_active_segment.take() {
2611
+ runtime.transcript_cancelled_segments.remove(&segment_id);
2612
+ }
2613
+ };
2614
+ }
2615
+ }
2616
+
2617
+ fn take_next_transcript_job(
2618
+ runtime: &mut ScreenMemoryRuntime,
2619
+ ) -> Option<ScreenMemoryTranscriptJob> {
2620
+ let job = runtime.transcript_queue.pop_front();
2621
+ if let Some(job) = job.as_ref() {
2622
+ runtime.transcript_active_segment = Some(job.segment.id.clone());
2623
+ } else {
2624
+ runtime.transcript_worker_running = false;
2625
+ }
2626
+ job
2627
+ }
2628
+
2629
+ fn write_transcript_status(
2630
+ app: &AppHandle,
2631
+ segment_id: &str,
2632
+ status: &crate::screen_memory_transcript::ScreenMemoryTranscriptStatus,
2633
+ ) -> Result<(), String> {
2634
+ let data = serde_json::to_vec_pretty(status)
2635
+ .map_err(|err| format!("screen memory transcript status encode failed: {err}"))?;
2636
+ let state = app.state::<ScreenMemoryState>();
2637
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
2638
+ if runtime.transcript_cancelled_segments.contains(segment_id) {
2639
+ return Ok(());
2640
+ }
2641
+ std::fs::write(segment_transcript_status_path(app, segment_id)?, data)
2642
+ .map_err(|err| format!("screen memory transcript status write failed: {err}"))
2643
+ }
2644
+
2645
+ fn write_transcript_rows(
2646
+ app: &AppHandle,
2647
+ segment_id: &str,
2648
+ rows: &[crate::screen_memory_transcript::ScreenMemoryTranscriptRow],
2649
+ ) -> Result<(), String> {
2650
+ let state = app.state::<ScreenMemoryState>();
2651
+ let runtime = state.inner.lock().map_err(|error| error.to_string())?;
2652
+ if runtime.transcript_cancelled_segments.contains(segment_id) {
2653
+ return Ok(());
2654
+ }
2655
+ let rows_path = segment_transcript_rows_path(app, segment_id)?;
2656
+ let mut file = File::create(rows_path)
2657
+ .map_err(|err| format!("screen memory transcript rows open failed: {err}"))?;
2658
+ for row in rows {
2659
+ serde_json::to_writer(&mut file, row)
2660
+ .map_err(|err| format!("screen memory transcript row encode failed: {err}"))?;
2661
+ file.write_all(b"\n")
2662
+ .map_err(|err| format!("screen memory transcript row write failed: {err}"))?;
2663
+ }
2664
+ Ok(())
2665
+ }
2666
+
2667
+ fn run_transcript_job(app: &AppHandle, job: ScreenMemoryTranscriptJob) -> Result<(), String> {
2668
+ use crate::screen_memory_transcript::ScreenMemoryTranscriptStatus;
2669
+
2670
+ // Re-check eligibility after dequeue: retention/deletion and legacy data
2671
+ // may have changed while the serialized worker was occupied with OCR.
2672
+ if !segment_transcript_eligible(&job.segment) || !job.segment.path.exists() {
2673
+ return write_transcript_status(
2674
+ app,
2675
+ &job.segment.id,
2676
+ &ScreenMemoryTranscriptStatus::skipped(now_iso()),
2677
+ );
2678
+ }
2679
+ let started_at = now_iso();
2680
+ write_transcript_status(
2681
+ app,
2682
+ &job.segment.id,
2683
+ &ScreenMemoryTranscriptStatus::transcribing(started_at.clone()),
2684
+ )?;
2685
+ let segment_started_at = match DateTime::parse_from_rfc3339(&job.segment.started_at) {
2686
+ Ok(value) => value.with_timezone(&Utc),
2687
+ Err(_) => {
2688
+ return write_transcript_status(
2689
+ app,
2690
+ &job.segment.id,
2691
+ &ScreenMemoryTranscriptStatus::failed(
2692
+ started_at,
2693
+ now_iso(),
2694
+ "segment start timestamp unavailable",
2695
+ ),
2696
+ )
2697
+ }
2698
+ };
2699
+ let duration_ms = u64::try_from(job.segment.duration_ms)
2700
+ .unwrap_or(crate::screen_memory_transcript::MAX_DURATION_MS.saturating_add(1));
2701
+ let mut rows = Vec::new();
2702
+ for (path, source) in [
2703
+ (job.segment.system_audio_path.as_ref(), "system-audio"),
2704
+ (job.segment.microphone_path.as_ref(), "microphone"),
2705
+ ] {
2706
+ let Some(path) = path else {
2707
+ return write_transcript_status(
2708
+ app,
2709
+ &job.segment.id,
2710
+ &ScreenMemoryTranscriptStatus::failed(
2711
+ started_at,
2712
+ now_iso(),
2713
+ format!("{source} sidecar is unavailable"),
2714
+ ),
2715
+ );
2716
+ };
2717
+ match crate::screen_memory_transcript::transcribe_segment(
2718
+ app,
2719
+ path,
2720
+ &job.segment.id,
2721
+ source,
2722
+ 0,
2723
+ segment_started_at,
2724
+ duration_ms,
2725
+ None,
2726
+ ) {
2727
+ Ok(mut source_rows) => rows.append(&mut source_rows),
2728
+ Err(error) => {
2729
+ return write_transcript_status(
2730
+ app,
2731
+ &job.segment.id,
2732
+ &ScreenMemoryTranscriptStatus::failed(started_at, now_iso(), error),
2733
+ )
2734
+ }
2735
+ }
2736
+ }
2737
+ rows.sort_by_key(|row| (row.start_ms, row.end_ms, row.source.clone()));
2738
+ write_transcript_rows(app, &job.segment.id, &rows)?;
2739
+ let result = write_transcript_status(
2740
+ app,
2741
+ &job.segment.id,
2742
+ &ScreenMemoryTranscriptStatus::ready(rows.len(), started_at, now_iso()),
2743
+ );
2744
+ let _ = refresh_rewind_chapters(app);
2745
+ result
2746
+ }
2747
+
726
2748
  fn write_segment_metadata(
727
2749
  app: &AppHandle,
728
2750
  segment: &ScreenMemorySegmentMetadata,
@@ -730,7 +2752,33 @@ fn write_segment_metadata(
730
2752
  let path = segment_metadata_path(app, &segment.id)?;
731
2753
  let data = serde_json::to_vec_pretty(segment)
732
2754
  .map_err(|e| format!("screen memory metadata encode failed: {e}"))?;
733
- std::fs::write(path, data).map_err(|e| format!("screen memory metadata write failed: {e}"))
2755
+ std::fs::write(path, data).map_err(|e| format!("screen memory metadata write failed: {e}"))?;
2756
+ crate::rewind_clip::pin_finalized_segment_if_active(app, segment)?;
2757
+ enqueue_segment_transcript(app, segment.clone());
2758
+ refresh_rewind_chapters(app)?;
2759
+ Ok(())
2760
+ }
2761
+
2762
+ fn refresh_rewind_chapters(app: &AppHandle) -> Result<(), String> {
2763
+ let events = read_screen_memory_events(app)?;
2764
+ crate::rewind_chapters::rebuild(
2765
+ &screen_memory_dir(app)?,
2766
+ recent_segments(app, None)?,
2767
+ events,
2768
+ )
2769
+ }
2770
+
2771
+ fn read_screen_memory_events(app: &AppHandle) -> Result<Vec<ScreenMemoryEvent>, String> {
2772
+ let path = screen_memory_events_path(app)?;
2773
+ if !path.exists() {
2774
+ return Ok(Vec::new());
2775
+ }
2776
+ let file = File::open(path).map_err(|e| format!("screen memory events open failed: {e}"))?;
2777
+ Ok(BufReader::new(file)
2778
+ .lines()
2779
+ .map_while(Result::ok)
2780
+ .filter_map(|line| serde_json::from_str(&line).ok())
2781
+ .collect())
734
2782
  }
735
2783
 
736
2784
  fn read_segment_metadata_path(path: &Path) -> Result<ScreenMemorySegmentMetadata, String> {
@@ -769,6 +2817,91 @@ fn recent_segments(
769
2817
  Ok(segments)
770
2818
  }
771
2819
 
2820
+ /// All finalized local segments, newest metadata included. This intentionally
2821
+ /// exposes no mutation or upload behavior; bounded local consumers apply their
2822
+ /// own coverage and privacy checks before reading adjacent indexes.
2823
+ pub(crate) fn finalized_segments(
2824
+ app: &AppHandle,
2825
+ ) -> Result<Vec<ScreenMemorySegmentMetadata>, String> {
2826
+ recent_segments(app, None)
2827
+ }
2828
+
2829
+ /// Finalized logical segments overlapping a shared capture-graph interval.
2830
+ /// This is local metadata only; callers must pin before assembling an artifact.
2831
+ pub(crate) fn finalized_segments_in_graph_interval(
2832
+ app: &AppHandle,
2833
+ started_elapsed_ms: u64,
2834
+ ended_elapsed_ms: u64,
2835
+ ) -> Result<Vec<ScreenMemorySegmentMetadata>, String> {
2836
+ if ended_elapsed_ms < started_elapsed_ms {
2837
+ return Ok(Vec::new());
2838
+ }
2839
+ let current_epoch = graph_epoch_id(app);
2840
+ recent_segments(app, None).map(|segments| {
2841
+ segments
2842
+ .into_iter()
2843
+ .filter(|segment| {
2844
+ segment_overlaps_graph_interval(
2845
+ segment,
2846
+ &current_epoch,
2847
+ started_elapsed_ms,
2848
+ ended_elapsed_ms,
2849
+ )
2850
+ })
2851
+ .collect()
2852
+ })
2853
+ }
2854
+
2855
+ fn segment_overlaps_graph_interval(
2856
+ segment: &ScreenMemorySegmentMetadata,
2857
+ current_epoch: &str,
2858
+ started_elapsed_ms: u64,
2859
+ ended_elapsed_ms: u64,
2860
+ ) -> bool {
2861
+ segment.graph_epoch_id.as_deref() == Some(current_epoch)
2862
+ && segment.graph_started_elapsed_ms <= ended_elapsed_ms
2863
+ && segment.graph_ended_elapsed_ms >= started_elapsed_ms
2864
+ }
2865
+
2866
+ /// Keep a finalized local segment from retention pruning while an in-flight
2867
+ /// artifact copies it. Pin state is intentionally process-local.
2868
+ pub(crate) fn pin_segment(app: &AppHandle, segment_id: &str, pin_id: &str) -> Result<(), String> {
2869
+ let state = app.state::<ScreenMemoryState>();
2870
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
2871
+ runtime
2872
+ .segment_pins
2873
+ .entry(sanitize_segment_id(segment_id))
2874
+ .or_default()
2875
+ .insert(pin_id.to_owned());
2876
+ Ok(())
2877
+ }
2878
+
2879
+ pub(crate) fn unpin_segment(app: &AppHandle, segment_id: &str, pin_id: &str) -> Result<(), String> {
2880
+ let state = app.state::<ScreenMemoryState>();
2881
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
2882
+ let segment_id = sanitize_segment_id(segment_id);
2883
+ if let Some(pins) = runtime.segment_pins.get_mut(&segment_id) {
2884
+ pins.remove(pin_id);
2885
+ if pins.is_empty() {
2886
+ runtime.segment_pins.remove(&segment_id);
2887
+ }
2888
+ }
2889
+ Ok(())
2890
+ }
2891
+
2892
+ fn segment_is_pinned(app: &AppHandle, segment_id: &str) -> bool {
2893
+ app.try_state::<ScreenMemoryState>()
2894
+ .and_then(|state| {
2895
+ state.inner.lock().ok().map(|runtime| {
2896
+ runtime
2897
+ .segment_pins
2898
+ .get(segment_id)
2899
+ .is_some_and(|pins| !pins.is_empty())
2900
+ })
2901
+ })
2902
+ .unwrap_or(false)
2903
+ }
2904
+
772
2905
  fn query_screen_memory(
773
2906
  app: &AppHandle,
774
2907
  query: Option<String>,
@@ -855,19 +2988,7 @@ fn recent_events(
855
2988
  }
856
2989
 
857
2990
  fn export_recent(app: &AppHandle, minutes: u64) -> Result<ScreenMemoryExportResult, String> {
858
- let minutes = minutes.clamp(1, 24 * 30 * 60);
859
- let cutoff = Utc::now() - ChronoDuration::minutes(minutes as i64);
860
- let segments = recent_segments(app, None)?
861
- .into_iter()
862
- .filter(|segment| {
863
- DateTime::parse_from_rfc3339(&segment.ended_at)
864
- .map(|value| value.with_timezone(&Utc) >= cutoff)
865
- .unwrap_or(true)
866
- })
867
- .collect::<Vec<_>>();
868
- if segments.is_empty() {
869
- return Err("No Screen Memory segments are available for that range.".to_string());
870
- }
2991
+ let minutes = minutes.clamp(1, 5);
871
2992
 
872
2993
  let folder = app
873
2994
  .path()
@@ -878,29 +2999,28 @@ fn export_recent(app: &AppHandle, minutes: u64) -> Result<ScreenMemoryExportResu
878
2999
  .join(Utc::now().format("%Y-%m-%d-%H%M%S").to_string());
879
3000
  std::fs::create_dir_all(&folder)
880
3001
  .map_err(|e| format!("screen memory export folder unavailable: {e}"))?;
881
-
882
- let mut files = Vec::new();
883
- for (index, segment) in segments.iter().rev().enumerate() {
884
- let extension = segment
885
- .path
886
- .extension()
887
- .and_then(|value| value.to_str())
888
- .unwrap_or("mp4");
889
- let file_name = format!("segment-{:02}.{extension}", index + 1);
890
- let destination = folder.join(&file_name);
891
- let _ = std::fs::remove_file(&destination);
892
- std::fs::copy(&segment.path, &destination)
893
- .map_err(|e| format!("screen memory export copy failed: {e}"))?;
894
- let bytes = std::fs::metadata(&destination)
895
- .map_err(|e| format!("screen memory export metadata failed: {e}"))?
896
- .len();
897
- files.push(ScreenMemoryExportFile {
898
- path: destination.to_string_lossy().to_string(),
899
- file_name,
900
- bytes,
901
- mime_type: segment.mime_type.clone(),
902
- });
903
- }
3002
+ let file_name = format!("previous-{minutes}-minutes.mp4");
3003
+ let destination = folder.join(&file_name);
3004
+ let artifact = match crate::rewind_clip::materialize_recent_exact(
3005
+ app,
3006
+ Duration::from_secs(minutes * 60),
3007
+ destination.clone(),
3008
+ ) {
3009
+ Ok(artifact) => artifact,
3010
+ Err(error) => {
3011
+ let _ = std::fs::remove_dir(&folder);
3012
+ return Err(error);
3013
+ }
3014
+ };
3015
+ let bytes = std::fs::metadata(&artifact.path)
3016
+ .map_err(|e| format!("screen memory export metadata failed: {e}"))?
3017
+ .len();
3018
+ let files = vec![ScreenMemoryExportFile {
3019
+ path: artifact.path.to_string_lossy().to_string(),
3020
+ file_name,
3021
+ bytes,
3022
+ mime_type: artifact.mime_type.to_string(),
3023
+ }];
904
3024
 
905
3025
  Ok(ScreenMemoryExportResult {
906
3026
  folder_path: folder.to_string_lossy().to_string(),
@@ -918,14 +3038,125 @@ fn prune_segments(app: &AppHandle, config: &ScreenMemoryConfig) -> Result<(), St
918
3038
  .ok();
919
3039
  let expired = ended_at.map(|value| value < cutoff).unwrap_or(false);
920
3040
  kept_bytes = kept_bytes.saturating_add(segment.bytes);
921
- if expired || kept_bytes > config.max_bytes {
3041
+ if (expired || kept_bytes > config.max_bytes) && !segment_is_pinned(app, &segment.id) {
922
3042
  let _ = delete_segment(app, &segment.id);
923
3043
  }
924
3044
  }
3045
+ prune_events(app, cutoff)?;
3046
+ refresh_rewind_chapters(app)?;
925
3047
  Ok(())
926
3048
  }
927
3049
 
3050
+ fn prune_events(app: &AppHandle, cutoff: DateTime<Utc>) -> Result<(), String> {
3051
+ let path = screen_memory_events_path(app)?;
3052
+ if !path.exists() {
3053
+ return Ok(());
3054
+ }
3055
+ let file = File::open(&path).map_err(|e| format!("screen memory events open failed: {e}"))?;
3056
+ let retained = BufReader::new(file)
3057
+ .lines()
3058
+ .map_while(Result::ok)
3059
+ .filter_map(|line| serde_json::from_str::<ScreenMemoryEvent>(&line).ok())
3060
+ .collect::<Vec<_>>();
3061
+ let mut coverage = recent_segments(app, None)?
3062
+ .into_iter()
3063
+ .filter_map(|segment| {
3064
+ Some((
3065
+ DateTime::parse_from_rfc3339(&segment.started_at)
3066
+ .ok()?
3067
+ .with_timezone(&Utc),
3068
+ DateTime::parse_from_rfc3339(&segment.ended_at)
3069
+ .ok()?
3070
+ .with_timezone(&Utc),
3071
+ ))
3072
+ })
3073
+ .collect::<Vec<_>>();
3074
+ if let Some(active) = app
3075
+ .state::<ScreenMemoryState>()
3076
+ .inner
3077
+ .lock()
3078
+ .ok()
3079
+ .and_then(|runtime| {
3080
+ runtime
3081
+ .active
3082
+ .as_ref()
3083
+ .map(|active| active.started_at_iso.clone())
3084
+ })
3085
+ .and_then(|started_at| DateTime::parse_from_rfc3339(&started_at).ok())
3086
+ {
3087
+ coverage.push((active.with_timezone(&Utc), Utc::now()));
3088
+ }
3089
+ let retained = retain_events_with_media_coverage(retained, cutoff, &coverage);
3090
+ let temporary_path = path.with_extension("jsonl.tmp");
3091
+ let mut file = File::create(&temporary_path)
3092
+ .map_err(|e| format!("screen memory events rewrite failed: {e}"))?;
3093
+ for event in retained {
3094
+ serde_json::to_writer(&mut file, &event)
3095
+ .map_err(|e| format!("screen memory event encode failed: {e}"))?;
3096
+ file.write_all(b"\n")
3097
+ .map_err(|e| format!("screen memory event write failed: {e}"))?;
3098
+ }
3099
+ std::fs::rename(&temporary_path, &path)
3100
+ .map_err(|e| format!("screen memory events replace failed: {e}"))
3101
+ }
3102
+
3103
+ fn retain_events_since(
3104
+ events: Vec<ScreenMemoryEvent>,
3105
+ cutoff: DateTime<Utc>,
3106
+ ) -> Vec<ScreenMemoryEvent> {
3107
+ events
3108
+ .into_iter()
3109
+ .filter(|event| {
3110
+ DateTime::parse_from_rfc3339(&event.captured_at)
3111
+ .map(|captured_at| captured_at.with_timezone(&Utc) >= cutoff)
3112
+ .unwrap_or(false)
3113
+ })
3114
+ .collect()
3115
+ }
3116
+
3117
+ fn retain_events_with_media_coverage(
3118
+ events: Vec<ScreenMemoryEvent>,
3119
+ cutoff: DateTime<Utc>,
3120
+ coverage: &[(DateTime<Utc>, DateTime<Utc>)],
3121
+ ) -> Vec<ScreenMemoryEvent> {
3122
+ retain_events_since(events, cutoff)
3123
+ .into_iter()
3124
+ .filter(|event| {
3125
+ if event.source == "coverage-gap" {
3126
+ return true;
3127
+ }
3128
+ let Ok(captured_at) = DateTime::parse_from_rfc3339(&event.captured_at) else {
3129
+ return false;
3130
+ };
3131
+ let captured_at = captured_at.with_timezone(&Utc);
3132
+ coverage.iter().any(|(started_at, ended_at)| {
3133
+ captured_at >= *started_at && captured_at <= *ended_at
3134
+ })
3135
+ })
3136
+ .collect()
3137
+ }
3138
+
928
3139
  fn delete_segment(app: &AppHandle, segment_id: &str) -> Result<ScreenMemoryDeleteResult, String> {
3140
+ let state = app.try_state::<ScreenMemoryState>();
3141
+ if let Some(state) = state.as_ref() {
3142
+ if let Ok(mut runtime) = state.inner.lock() {
3143
+ runtime.ocr_queue.retain(|job| job.segment.id != segment_id);
3144
+ runtime.ocr_cancelled_segments.insert(segment_id.to_owned());
3145
+ runtime
3146
+ .transcript_queue
3147
+ .retain(|job| job.segment.id != segment_id);
3148
+ runtime
3149
+ .transcript_cancelled_segments
3150
+ .insert(segment_id.to_owned());
3151
+ }
3152
+ }
3153
+ // Wait for a currently-running derivative job to observe cancellation
3154
+ // before removing the source and its sidecars. This makes Clear and
3155
+ // retention deletion a real boundary instead of a hopeful suggestion.
3156
+ let _indexing = state
3157
+ .as_ref()
3158
+ .map(|state| state.indexing.lock().map_err(|error| error.to_string()))
3159
+ .transpose()?;
929
3160
  let metadata_path = segment_metadata_path(app, segment_id)?;
930
3161
  let segment = read_segment_metadata_path(&metadata_path).ok();
931
3162
  let mut deleted_segments = 0_usize;
@@ -933,9 +3164,26 @@ fn delete_segment(app: &AppHandle, segment_id: &str) -> Result<ScreenMemoryDelet
933
3164
  if let Some(segment) = segment {
934
3165
  deleted_bytes = segment.bytes;
935
3166
  remove_file_if_exists(&segment.path)?;
3167
+ if let Some(path) = segment.system_audio_path.as_ref() {
3168
+ remove_file_if_exists(path)?;
3169
+ }
3170
+ if let Some(path) = segment.microphone_path.as_ref() {
3171
+ remove_file_if_exists(path)?;
3172
+ }
936
3173
  deleted_segments = 1;
937
3174
  }
938
3175
  remove_file_if_exists(&metadata_path)?;
3176
+ remove_file_if_exists(&segment_ocr_rows_path(app, segment_id)?)?;
3177
+ remove_file_if_exists(&segment_ocr_status_path(app, segment_id)?)?;
3178
+ remove_transcript_sidecars(&screen_memory_dir(app)?, segment_id)?;
3179
+ refresh_rewind_chapters(app)?;
3180
+ crate::rewind_clip::clear_preview_artifacts(app)?;
3181
+ if let Some(state) = state.as_ref() {
3182
+ if let Ok(mut runtime) = state.inner.lock() {
3183
+ runtime.ocr_cancelled_segments.remove(segment_id);
3184
+ runtime.transcript_cancelled_segments.remove(segment_id);
3185
+ }
3186
+ }
939
3187
  Ok(ScreenMemoryDeleteResult {
940
3188
  deleted_segments,
941
3189
  deleted_bytes,
@@ -943,12 +3191,48 @@ fn delete_segment(app: &AppHandle, segment_id: &str) -> Result<ScreenMemoryDelet
943
3191
  }
944
3192
 
945
3193
  fn delete_all_segments(app: &AppHandle) -> Result<ScreenMemoryDeleteResult, String> {
3194
+ let state = app.try_state::<ScreenMemoryState>();
3195
+ if let Some(state) = state.as_ref() {
3196
+ let mut runtime = state.inner.lock().map_err(|error| error.to_string())?;
3197
+ let queued_ocr: Vec<_> = runtime
3198
+ .ocr_queue
3199
+ .iter()
3200
+ .map(|job| job.segment.id.clone())
3201
+ .collect();
3202
+ let queued_transcripts: Vec<_> = runtime
3203
+ .transcript_queue
3204
+ .iter()
3205
+ .map(|job| job.segment.id.clone())
3206
+ .collect();
3207
+ runtime.ocr_cancelled_segments.extend(queued_ocr);
3208
+ runtime
3209
+ .transcript_cancelled_segments
3210
+ .extend(queued_transcripts);
3211
+ if let Some(segment_id) = runtime.ocr_active_segment.clone() {
3212
+ runtime.ocr_cancelled_segments.insert(segment_id);
3213
+ }
3214
+ if let Some(segment_id) = runtime.transcript_active_segment.clone() {
3215
+ runtime.transcript_cancelled_segments.insert(segment_id);
3216
+ }
3217
+ runtime.ocr_queue.clear();
3218
+ runtime.transcript_queue.clear();
3219
+ }
3220
+ let _indexing = state
3221
+ .as_ref()
3222
+ .map(|state| state.indexing.lock().map_err(|error| error.to_string()))
3223
+ .transpose()?;
946
3224
  let dir = screen_memory_dir(app)?;
947
3225
  let mut deleted_segments = 0_usize;
948
3226
  let mut deleted_bytes = 0_u64;
949
3227
  for segment in recent_segments(app, None)? {
950
3228
  deleted_bytes = deleted_bytes.saturating_add(segment.bytes);
951
3229
  remove_file_if_exists(&segment.path)?;
3230
+ if let Some(path) = segment.system_audio_path.as_ref() {
3231
+ remove_file_if_exists(path)?;
3232
+ }
3233
+ if let Some(path) = segment.microphone_path.as_ref() {
3234
+ remove_file_if_exists(path)?;
3235
+ }
952
3236
  remove_file_if_exists(&segment_metadata_path(app, &segment.id)?)?;
953
3237
  deleted_segments += 1;
954
3238
  }
@@ -961,6 +3245,14 @@ fn delete_all_segments(app: &AppHandle) -> Result<ScreenMemoryDeleteResult, Stri
961
3245
  remove_file_if_exists(&path)?;
962
3246
  }
963
3247
  }
3248
+ crate::rewind_chapters::clear(&dir)?;
3249
+ crate::rewind_clip::clear_preview_artifacts(app)?;
3250
+ if let Some(state) = state.as_ref() {
3251
+ if let Ok(mut runtime) = state.inner.lock() {
3252
+ runtime.ocr_cancelled_segments.clear();
3253
+ runtime.transcript_cancelled_segments.clear();
3254
+ }
3255
+ }
964
3256
  Ok(ScreenMemoryDeleteResult {
965
3257
  deleted_segments,
966
3258
  deleted_bytes,
@@ -988,3 +3280,520 @@ fn record_error(app: &AppHandle, error: String) {
988
3280
  fn now_iso() -> String {
989
3281
  Utc::now().to_rfc3339()
990
3282
  }
3283
+
3284
+ #[cfg(test)]
3285
+ mod tests {
3286
+ use super::*;
3287
+
3288
+ #[test]
3289
+ fn codex_agent_connection_replaces_only_its_own_mcp_footprint() {
3290
+ let existing = r#"model = "gpt-5.6"
3291
+
3292
+ [mcp_servers."clips-screen-memory"]
3293
+ command = "old"
3294
+ args = ["old"]
3295
+
3296
+ [mcp_servers."clips-screen-memory".env]
3297
+ OLD = "value"
3298
+
3299
+ [mcp_servers.other]
3300
+ command = "keep"
3301
+ "#;
3302
+ let block = codex_screen_memory_block(Path::new("/tmp/Clips Alpha/screen-memory"));
3303
+ let next = replace_codex_screen_memory_block(existing, &block);
3304
+ assert_eq!(next.matches("clips-screen-memory").count(), 1);
3305
+ assert!(!next.contains("OLD"));
3306
+ assert!(next.contains("[mcp_servers.other]"));
3307
+ assert!(next.contains("/tmp/Clips Alpha/screen-memory"));
3308
+ assert!(next.contains("@agent-native/core@latest"));
3309
+ }
3310
+
3311
+ #[test]
3312
+ fn codex_agent_connection_appends_to_an_existing_config() {
3313
+ let block = codex_screen_memory_block(Path::new("/tmp/screen-memory"));
3314
+ let next = replace_codex_screen_memory_block("model = \"gpt-5.6\"\n", &block);
3315
+ assert!(next.starts_with("model = \"gpt-5.6\""));
3316
+ assert!(next.contains("command = \"npx\""));
3317
+ assert!(next.contains("mcp\", \"screen-memory"));
3318
+ }
3319
+
3320
+ fn event(captured_at: &str) -> ScreenMemoryEvent {
3321
+ ScreenMemoryEvent {
3322
+ captured_at: captured_at.to_string(),
3323
+ app_name: Some("Example".to_string()),
3324
+ window_title: Some("Example window".to_string()),
3325
+ bundle_id: Some("example.app".to_string()),
3326
+ source: "test".to_string(),
3327
+ coverage_gap_reason: None,
3328
+ accessibility: None,
3329
+ }
3330
+ }
3331
+
3332
+ fn segment(id: &str) -> ScreenMemorySegmentMetadata {
3333
+ ScreenMemorySegmentMetadata {
3334
+ id: id.into(),
3335
+ path: PathBuf::from(format!("{id}.mp4")),
3336
+ file_name: format!("{id}.mp4"),
3337
+ mime_type: "video/mp4".into(),
3338
+ started_at: "2026-07-19T10:00:00Z".into(),
3339
+ ended_at: "2026-07-19T10:00:01Z".into(),
3340
+ duration_ms: 1_000,
3341
+ width: Some(1280),
3342
+ height: Some(720),
3343
+ bytes: 1,
3344
+ system_audio_path: None,
3345
+ microphone_path: None,
3346
+ corrupt: false,
3347
+ error: None,
3348
+ capture_mode: RewindCaptureMode::VisualsAudio,
3349
+ exclusion_tainted: false,
3350
+ graph_epoch_id: Some("test-epoch".into()),
3351
+ graph_started_elapsed_ms: 0,
3352
+ graph_ended_elapsed_ms: 1_000,
3353
+ }
3354
+ }
3355
+
3356
+ #[test]
3357
+ fn derivative_dequeue_marks_the_job_active_in_the_same_runtime_mutation() {
3358
+ let mut runtime = ScreenMemoryRuntime {
3359
+ ocr_worker_running: true,
3360
+ transcript_worker_running: true,
3361
+ ..ScreenMemoryRuntime::default()
3362
+ };
3363
+ runtime.ocr_queue.push_back(ScreenMemoryOcrJob {
3364
+ segment: segment("ocr"),
3365
+ sample_interval_seconds: 10,
3366
+ });
3367
+ runtime
3368
+ .transcript_queue
3369
+ .push_back(ScreenMemoryTranscriptJob {
3370
+ segment: segment("transcript"),
3371
+ });
3372
+
3373
+ assert_eq!(take_next_ocr_job(&mut runtime).unwrap().segment.id, "ocr");
3374
+ assert_eq!(runtime.ocr_active_segment.as_deref(), Some("ocr"));
3375
+ assert_eq!(
3376
+ take_next_transcript_job(&mut runtime).unwrap().segment.id,
3377
+ "transcript"
3378
+ );
3379
+ assert_eq!(
3380
+ runtime.transcript_active_segment.as_deref(),
3381
+ Some("transcript")
3382
+ );
3383
+ }
3384
+
3385
+ #[test]
3386
+ fn normalizes_bounds_and_excluded_bundle_ids() {
3387
+ let config = normalize_screen_memory_config(ScreenMemoryConfig {
3388
+ retention_hours: 100,
3389
+ max_bytes: 1,
3390
+ segment_seconds: 1,
3391
+ sample_interval_seconds: 100,
3392
+ excluded_bundle_ids: vec![
3393
+ " COM.Example.Secret ".to_string(),
3394
+ "com.example.secret".to_string(),
3395
+ " ".to_string(),
3396
+ ],
3397
+ ..ScreenMemoryConfig::default()
3398
+ });
3399
+
3400
+ assert_eq!(config.retention_hours, MAX_RETENTION_HOURS);
3401
+ assert_eq!(config.max_bytes, MIN_MAX_BYTES);
3402
+ assert_eq!(config.segment_seconds, MIN_SEGMENT_SECONDS);
3403
+ assert_eq!(config.sample_interval_seconds, MIN_SEGMENT_SECONDS);
3404
+ assert_eq!(config.excluded_bundle_ids, vec!["com.example.secret"]);
3405
+ }
3406
+
3407
+ #[test]
3408
+ fn excludes_bundle_ids_without_retaining_context() {
3409
+ let config = ScreenMemoryConfig::default();
3410
+ assert_eq!(
3411
+ event_exclusion_reason(&config, Some("COM.1PASSWORD.1PASSWORD"), Some("vault")),
3412
+ Some("excluded-bundle-id")
3413
+ );
3414
+ assert_eq!(
3415
+ event_exclusion_reason(
3416
+ &config,
3417
+ Some("com.example.browser"),
3418
+ Some("Search - Incognito")
3419
+ ),
3420
+ None
3421
+ );
3422
+ let private_window_config = ScreenMemoryConfig {
3423
+ exclude_private_windows: true,
3424
+ ..ScreenMemoryConfig::default()
3425
+ };
3426
+ assert_eq!(
3427
+ event_exclusion_reason(
3428
+ &private_window_config,
3429
+ Some("com.example.browser"),
3430
+ Some("Search - Incognito")
3431
+ ),
3432
+ Some("excluded-private-window")
3433
+ );
3434
+ let gap = coverage_gap_event("privacy-exclusion");
3435
+ assert_eq!(gap.source, "coverage-gap");
3436
+ assert_eq!(
3437
+ gap.coverage_gap_reason.as_deref(),
3438
+ Some("privacy-exclusion")
3439
+ );
3440
+ assert!(gap.app_name.is_none() && gap.window_title.is_none() && gap.bundle_id.is_none());
3441
+ assert!(gap.accessibility.is_none());
3442
+ }
3443
+
3444
+ #[test]
3445
+ fn old_event_jsonl_lines_parse_without_accessibility() {
3446
+ let event: ScreenMemoryEvent = serde_json::from_str(
3447
+ r#"{"capturedAt":"2026-07-18T12:00:00Z","appName":"Mail","windowTitle":"Inbox","bundleId":"com.apple.mail","source":"core-graphics"}"#,
3448
+ )
3449
+ .expect("old event line remains readable");
3450
+ assert_eq!(event.app_name.as_deref(), Some("Mail"));
3451
+ assert!(event.coverage_gap_reason.is_none());
3452
+ assert!(event.accessibility.is_none());
3453
+ }
3454
+
3455
+ #[test]
3456
+ fn recognized_exclusion_transitions_suspend_stay_suspended_and_resume() {
3457
+ assert_eq!(
3458
+ exclusion_transition(false, false),
3459
+ ExclusionTransition::KeepCapturing
3460
+ );
3461
+ assert_eq!(
3462
+ exclusion_transition(false, true),
3463
+ ExclusionTransition::Suspend
3464
+ );
3465
+ assert_eq!(
3466
+ exclusion_transition(true, true),
3467
+ ExclusionTransition::StaySuspended
3468
+ );
3469
+ assert_eq!(
3470
+ exclusion_transition(true, false),
3471
+ ExclusionTransition::Resume
3472
+ );
3473
+ }
3474
+
3475
+ #[test]
3476
+ fn exclusion_resume_requires_one_stopped_producer_and_safe_live_config() {
3477
+ assert!(exclusion_resume_allowed(
3478
+ true, false, false, false, true, false
3479
+ ));
3480
+ assert!(!exclusion_resume_allowed(
3481
+ true, false, false, false, true, true
3482
+ ));
3483
+ assert!(!exclusion_resume_allowed(
3484
+ true, false, false, true, true, false
3485
+ ));
3486
+ assert!(!exclusion_resume_allowed(
3487
+ true, true, false, false, true, false
3488
+ ));
3489
+ assert!(!exclusion_resume_allowed(
3490
+ true, false, true, false, true, false
3491
+ ));
3492
+ }
3493
+
3494
+ #[test]
3495
+ fn exclusion_gap_covers_discarded_segment_start_and_every_effective_source() {
3496
+ let started_at = Instant::now() - Duration::from_secs(7);
3497
+ let gap = active_exclusion_gap(started_at, RewindCaptureMode::VisualsAudio);
3498
+ assert_eq!(gap.started_at, started_at);
3499
+ assert_eq!(
3500
+ gap.sources,
3501
+ vec![
3502
+ CaptureSource::Screen,
3503
+ CaptureSource::SystemAudio,
3504
+ CaptureSource::Microphone
3505
+ ]
3506
+ );
3507
+ }
3508
+
3509
+ #[test]
3510
+ fn discarded_segment_sidecars_are_removed_as_one_retention_unit() {
3511
+ let dir = std::env::temp_dir().join(format!(
3512
+ "clips-exclusion-discard-{}-{}",
3513
+ std::process::id(),
3514
+ SEGMENT_COUNTER.fetch_add(1, Ordering::Relaxed)
3515
+ ));
3516
+ std::fs::create_dir_all(&dir).unwrap();
3517
+ let paths: Vec<_> = [
3518
+ "segment.json",
3519
+ "segment.ocr.jsonl",
3520
+ "segment.ocr-status.json",
3521
+ "segment.transcript.jsonl",
3522
+ "segment.transcript-status.json",
3523
+ ]
3524
+ .into_iter()
3525
+ .map(|name| dir.join(name))
3526
+ .collect();
3527
+ for path in &paths {
3528
+ std::fs::write(path, b"private-derived-data").unwrap();
3529
+ }
3530
+
3531
+ remove_discarded_sidecars(&paths).unwrap();
3532
+
3533
+ assert!(paths.iter().all(|path| !path.exists()));
3534
+ std::fs::remove_dir_all(dir).unwrap();
3535
+ }
3536
+
3537
+ #[test]
3538
+ fn segment_metadata_preserves_exclusion_taint_and_defaults_legacy_rows() {
3539
+ let tainted: ScreenMemorySegmentMetadata = serde_json::from_value(serde_json::json!({
3540
+ "id": "segment-1", "path": "/tmp/segment.mp4", "fileName": "segment.mp4",
3541
+ "mimeType": "video/mp4", "startedAt": "2026-07-14T12:00:00Z",
3542
+ "endedAt": "2026-07-14T12:01:00Z", "durationMs": 60000, "width": 1,
3543
+ "height": 1, "bytes": 1, "corrupt": false, "error": null,
3544
+ "exclusionTainted": true
3545
+ }))
3546
+ .unwrap();
3547
+ assert!(tainted.exclusion_tainted);
3548
+ assert_eq!(tainted.graph_started_elapsed_ms, 0);
3549
+ assert_eq!(tainted.graph_ended_elapsed_ms, 0);
3550
+ assert_eq!(tainted.capture_mode, RewindCaptureMode::Visuals);
3551
+
3552
+ let legacy: ScreenMemorySegmentMetadata = serde_json::from_value(serde_json::json!({
3553
+ "id": "segment-2", "path": "/tmp/segment.mp4", "fileName": "segment.mp4",
3554
+ "mimeType": "video/mp4", "startedAt": "2026-07-14T12:00:00Z",
3555
+ "endedAt": "2026-07-14T12:01:00Z", "durationMs": 60000, "width": null,
3556
+ "height": null, "bytes": 1, "corrupt": false, "error": null
3557
+ }))
3558
+ .unwrap();
3559
+ assert!(!legacy.exclusion_tainted);
3560
+ assert_eq!(legacy.capture_mode, RewindCaptureMode::Visuals);
3561
+ assert_eq!(legacy.graph_epoch_id, None);
3562
+ assert_eq!(legacy.graph_started_elapsed_ms, 0);
3563
+ assert_eq!(legacy.graph_ended_elapsed_ms, 0);
3564
+
3565
+ let mut current = legacy.clone();
3566
+ current.graph_epoch_id = Some("current-epoch".into());
3567
+ current.graph_started_elapsed_ms = 100;
3568
+ current.graph_ended_elapsed_ms = 200;
3569
+ assert!(segment_overlaps_graph_interval(
3570
+ &current,
3571
+ "current-epoch",
3572
+ 150,
3573
+ 250
3574
+ ));
3575
+ assert!(!segment_overlaps_graph_interval(
3576
+ &current,
3577
+ "stale-epoch",
3578
+ 150,
3579
+ 250
3580
+ ));
3581
+ assert!(!segment_overlaps_graph_interval(
3582
+ &legacy,
3583
+ "current-epoch",
3584
+ 0,
3585
+ 1
3586
+ ));
3587
+ }
3588
+
3589
+ #[test]
3590
+ fn transcript_lifecycle_only_indexes_clean_visuals_audio_segments() {
3591
+ let system = std::env::temp_dir().join(format!(
3592
+ "clips-transcript-system-{}.wav",
3593
+ std::process::id()
3594
+ ));
3595
+ let microphone = std::env::temp_dir().join(format!(
3596
+ "clips-transcript-microphone-{}.wav",
3597
+ std::process::id()
3598
+ ));
3599
+ std::fs::write(&system, b"audio").unwrap();
3600
+ std::fs::write(&microphone, b"audio").unwrap();
3601
+ let mut segment: ScreenMemorySegmentMetadata = serde_json::from_value(serde_json::json!({
3602
+ "id": "segment-1", "path": "/tmp/segment.mp4", "fileName": "segment.mp4",
3603
+ "mimeType": "video/mp4", "startedAt": "2026-07-14T12:00:00Z",
3604
+ "endedAt": "2026-07-14T12:01:00Z", "durationMs": 60000, "width": 1,
3605
+ "height": 1, "bytes": 1, "corrupt": false, "error": null,
3606
+ "captureMode": "visuals-audio",
3607
+ "systemAudioPath": system.clone(),
3608
+ "microphonePath": microphone.clone()
3609
+ }))
3610
+ .unwrap();
3611
+ assert!(segment_transcript_eligible(&segment));
3612
+ segment.exclusion_tainted = true;
3613
+ assert!(!segment_derivatives_eligible(&segment));
3614
+ assert!(!segment_transcript_eligible(&segment));
3615
+ segment.exclusion_tainted = false;
3616
+ segment.corrupt = true;
3617
+ assert!(!segment_transcript_eligible(&segment));
3618
+ segment.corrupt = false;
3619
+ segment.error = Some("capture failed".into());
3620
+ assert!(!segment_transcript_eligible(&segment));
3621
+ segment.error = None;
3622
+ segment.capture_mode = RewindCaptureMode::Visuals;
3623
+ assert!(!segment_transcript_eligible(&segment));
3624
+ let _ = std::fs::remove_file(system);
3625
+ let _ = std::fs::remove_file(microphone);
3626
+ }
3627
+
3628
+ #[test]
3629
+ fn status_explains_recognized_media_exclusion_and_coverage_gap() {
3630
+ let config = ScreenMemoryConfig {
3631
+ enabled: true,
3632
+ paused: false,
3633
+ ..ScreenMemoryConfig::default()
3634
+ };
3635
+ let coverage = coverage_language(&config, true, false);
3636
+ assert!(coverage.contains("entire in-flight media segment was discarded"));
3637
+ assert!(coverage.contains("source coverage gap"));
3638
+ }
3639
+
3640
+ #[test]
3641
+ fn transcript_sidecars_are_deleted_together() {
3642
+ let dir = std::env::temp_dir().join(format!(
3643
+ "clips-transcript-sidecars-{}-{}",
3644
+ std::process::id(),
3645
+ SEGMENT_COUNTER.fetch_add(1, Ordering::Relaxed)
3646
+ ));
3647
+ std::fs::create_dir_all(&dir).unwrap();
3648
+ let (rows, status) = transcript_sidecar_paths(&dir, "segment/one");
3649
+ std::fs::write(&rows, b"{}\n").unwrap();
3650
+ std::fs::write(&status, b"{}").unwrap();
3651
+ remove_transcript_sidecars(&dir, "segment/one").unwrap();
3652
+ assert!(!rows.exists());
3653
+ assert!(!status.exists());
3654
+ std::fs::remove_dir_all(dir).unwrap();
3655
+ }
3656
+
3657
+ #[test]
3658
+ fn retains_only_parseable_events_within_the_retention_window() {
3659
+ let cutoff = DateTime::parse_from_rfc3339("2026-07-14T12:00:00Z")
3660
+ .unwrap()
3661
+ .with_timezone(&Utc);
3662
+ let events = retain_events_since(
3663
+ vec![
3664
+ event("2026-07-14T11:59:59Z"),
3665
+ event("2026-07-14T12:00:00Z"),
3666
+ event("not-a-date"),
3667
+ ],
3668
+ cutoff,
3669
+ );
3670
+
3671
+ assert_eq!(events.len(), 1);
3672
+ assert_eq!(events[0].captured_at, "2026-07-14T12:00:00Z");
3673
+ }
3674
+
3675
+ #[test]
3676
+ fn app_context_is_pruned_with_media_while_coverage_gaps_remain_visible() {
3677
+ let cutoff = DateTime::parse_from_rfc3339("2026-07-14T12:00:00Z")
3678
+ .unwrap()
3679
+ .with_timezone(&Utc);
3680
+ let coverage = [(
3681
+ DateTime::parse_from_rfc3339("2026-07-14T12:05:00Z")
3682
+ .unwrap()
3683
+ .with_timezone(&Utc),
3684
+ DateTime::parse_from_rfc3339("2026-07-14T12:10:00Z")
3685
+ .unwrap()
3686
+ .with_timezone(&Utc),
3687
+ )];
3688
+ let mut gap = event("2026-07-14T12:03:00Z");
3689
+ gap.source = "coverage-gap".into();
3690
+ let events = retain_events_with_media_coverage(
3691
+ vec![
3692
+ event("2026-07-14T12:02:00Z"),
3693
+ gap,
3694
+ event("2026-07-14T12:06:00Z"),
3695
+ ],
3696
+ cutoff,
3697
+ &coverage,
3698
+ );
3699
+
3700
+ assert_eq!(events.len(), 2);
3701
+ assert_eq!(events[0].source, "coverage-gap");
3702
+ assert_eq!(events[1].captured_at, "2026-07-14T12:06:00Z");
3703
+ }
3704
+
3705
+ #[test]
3706
+ fn temporary_audio_refcounts_restore_the_persisted_base_mode() {
3707
+ assert_eq!(
3708
+ effective_mode(RewindCaptureMode::Visuals, 0),
3709
+ RewindCaptureMode::Visuals
3710
+ );
3711
+ assert_eq!(
3712
+ effective_mode(RewindCaptureMode::Visuals, 1),
3713
+ RewindCaptureMode::VisualsAudio
3714
+ );
3715
+ assert_eq!(
3716
+ effective_mode(RewindCaptureMode::Visuals, 2),
3717
+ RewindCaptureMode::VisualsAudio
3718
+ );
3719
+ assert_eq!(
3720
+ effective_mode(RewindCaptureMode::VisualsAudio, 0),
3721
+ RewindCaptureMode::VisualsAudio
3722
+ );
3723
+
3724
+ let mut runtime = ScreenMemoryRuntime::default();
3725
+ runtime.temporary_audio_consumers.insert(
3726
+ "meeting-whisper".into(),
3727
+ TemporaryAudioConsumer {
3728
+ graph_lease_id: "meeting-lease".into(),
3729
+ },
3730
+ );
3731
+ runtime.temporary_audio_consumers.insert(
3732
+ "rewind-clip".into(),
3733
+ TemporaryAudioConsumer {
3734
+ graph_lease_id: "clip-lease".into(),
3735
+ },
3736
+ );
3737
+ assert_eq!(
3738
+ requested_sources(effective_mode(
3739
+ RewindCaptureMode::Visuals,
3740
+ runtime.temporary_audio_consumers.len()
3741
+ )),
3742
+ vec![
3743
+ CaptureSource::Screen,
3744
+ CaptureSource::SystemAudio,
3745
+ CaptureSource::Microphone
3746
+ ]
3747
+ );
3748
+ runtime.temporary_audio_consumers.remove("rewind-clip");
3749
+ assert_eq!(
3750
+ effective_mode(
3751
+ RewindCaptureMode::Visuals,
3752
+ runtime.temporary_audio_consumers.len()
3753
+ ),
3754
+ RewindCaptureMode::VisualsAudio
3755
+ );
3756
+ runtime.temporary_audio_consumers.remove("meeting-whisper");
3757
+ assert_eq!(
3758
+ effective_mode(
3759
+ RewindCaptureMode::Visuals,
3760
+ runtime.temporary_audio_consumers.len()
3761
+ ),
3762
+ RewindCaptureMode::Visuals
3763
+ );
3764
+ }
3765
+
3766
+ #[test]
3767
+ fn temporary_audio_requires_a_live_unpaused_rewind_producer() {
3768
+ assert!(temporary_audio_lease_available(
3769
+ true, false, true, true, false
3770
+ ));
3771
+ assert!(!temporary_audio_lease_available(
3772
+ false, false, true, true, true
3773
+ ));
3774
+ assert!(!temporary_audio_lease_available(
3775
+ true, true, true, true, true
3776
+ ));
3777
+ assert!(!temporary_audio_lease_available(
3778
+ true, false, false, true, true
3779
+ ));
3780
+ assert!(!temporary_audio_lease_available(
3781
+ true, false, true, false, false
3782
+ ));
3783
+ }
3784
+
3785
+ #[test]
3786
+ fn terminal_handoffs_do_not_retain_agent_reason_text() {
3787
+ let mut object = serde_json::json!({
3788
+ "status": "pending",
3789
+ "reason": "private words from the requested interval"
3790
+ })
3791
+ .as_object()
3792
+ .unwrap()
3793
+ .clone();
3794
+ scrub_terminal_handoff_fields(&mut object, "processing");
3795
+ assert!(object.contains_key("reason"));
3796
+ scrub_terminal_handoff_fields(&mut object, "declined");
3797
+ assert!(!object.contains_key("reason"));
3798
+ }
3799
+ }