@agent-native/core 0.114.6 → 0.114.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +15 -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/mcp/screen-memory-stdio.ts +1360 -104
  11. package/corpus/core/src/mcp-client/index.ts +9 -0
  12. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  13. package/corpus/core/src/server/agent-chat-plugin.ts +16 -9
  14. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +19 -7
  15. package/corpus/templates/analytics/app/hooks/use-chart-tooltip-portal.ts +60 -0
  16. package/corpus/templates/analytics/app/routes/chart.tsx +1 -1
  17. package/corpus/templates/analytics/changelog/2026-07-20-dashboard-requests-no-longer-stall-without-progress.md +6 -0
  18. package/corpus/templates/analytics/server/plugins/agent-chat.ts +6 -0
  19. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  20. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  21. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  22. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  23. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  24. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  25. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  26. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  27. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  28. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  29. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  30. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  31. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  32. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  33. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  34. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  35. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  36. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  37. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  38. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  50. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  52. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  53. package/corpus/templates/clips/desktop/package.json +1 -0
  54. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  55. package/corpus/templates/clips/desktop/src/app.tsx +2498 -239
  56. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  57. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  58. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  59. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  60. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  61. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  62. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  63. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  64. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  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/toolkit/CHANGELOG.md +6 -0
  99. package/corpus/toolkit/package.json +1 -1
  100. package/dist/cli/mcp-config-writers.d.ts +2 -2
  101. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  102. package/dist/cli/mcp-config-writers.js +6 -6
  103. package/dist/cli/mcp-config-writers.js.map +1 -1
  104. package/dist/cli/mcp.d.ts +14 -0
  105. package/dist/cli/mcp.d.ts.map +1 -1
  106. package/dist/cli/mcp.js +79 -0
  107. package/dist/cli/mcp.js.map +1 -1
  108. package/dist/cli/skills-content/help.d.ts +1 -1
  109. package/dist/cli/skills-content/help.d.ts.map +1 -1
  110. package/dist/cli/skills-content/help.js +4 -3
  111. package/dist/cli/skills-content/help.js.map +1 -1
  112. package/dist/cli/skills-content/index.d.ts +1 -0
  113. package/dist/cli/skills-content/index.d.ts.map +1 -1
  114. package/dist/cli/skills-content/index.js +1 -0
  115. package/dist/cli/skills-content/index.js.map +1 -1
  116. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  117. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  118. package/dist/cli/skills-content/rewind-skill.js +53 -0
  119. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  120. package/dist/cli/skills.d.ts +6 -0
  121. package/dist/cli/skills.d.ts.map +1 -1
  122. package/dist/cli/skills.js +90 -14
  123. package/dist/cli/skills.js.map +1 -1
  124. package/dist/collab/struct-routes.d.ts +1 -1
  125. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  126. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  127. package/dist/mcp/screen-memory-stdio.js +1014 -91
  128. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  129. package/dist/mcp-client/index.d.ts +1 -1
  130. package/dist/mcp-client/index.d.ts.map +1 -1
  131. package/dist/mcp-client/index.js +1 -1
  132. package/dist/mcp-client/index.js.map +1 -1
  133. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  134. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  135. package/dist/mcp-client/screen-memory-local.js +590 -12
  136. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  137. package/dist/observability/routes.d.ts +1 -1
  138. package/dist/progress/routes.d.ts +1 -1
  139. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  140. package/dist/resources/handlers.d.ts +1 -1
  141. package/dist/server/agent-chat-plugin.d.ts +5 -0
  142. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  143. package/dist/server/agent-chat-plugin.js +10 -9
  144. package/dist/server/agent-chat-plugin.js.map +1 -1
  145. package/package.json +2 -2
  146. package/src/cli/mcp-config-writers.ts +6 -4
  147. package/src/cli/mcp.ts +124 -0
  148. package/src/cli/skills-content/help.ts +4 -3
  149. package/src/cli/skills-content/index.ts +1 -0
  150. package/src/cli/skills-content/rewind-skill.ts +52 -0
  151. package/src/cli/skills.ts +115 -13
  152. package/src/mcp/screen-memory-stdio.ts +1360 -104
  153. package/src/mcp-client/index.ts +9 -0
  154. package/src/mcp-client/screen-memory-local.ts +847 -13
  155. package/src/server/agent-chat-plugin.ts +16 -9
@@ -0,0 +1,360 @@
1
+ //! Local, sparse OCR for completed Screen Memory video segments.
2
+ //!
3
+ //! This module deliberately returns bounded rows for the caller to persist;
4
+ //! it never writes frame images and never logs recognized text.
5
+ // The capture worker consumes this foundation in the next integration slice.
6
+ #![allow(dead_code)]
7
+
8
+ use chrono::{DateTime, Duration, Utc};
9
+ use serde::{Deserialize, Serialize};
10
+ use std::ffi::{CStr, CString};
11
+ use std::path::Path;
12
+
13
+ pub const OCR_SCHEMA_VERSION: u32 = 1;
14
+ pub const MAX_OCR_FRAMES: usize = 30;
15
+ pub const MAX_OCR_TEXT_BYTES: usize = 8 * 1024;
16
+
17
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18
+ #[serde(rename_all = "kebab-case")]
19
+ pub enum ScreenMemoryOcrIndexState {
20
+ Pending,
21
+ Indexing,
22
+ Ready,
23
+ Failed,
24
+ Skipped,
25
+ }
26
+
27
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28
+ #[serde(rename_all = "camelCase")]
29
+ pub struct ScreenMemoryOcrIndexStatus {
30
+ pub state: ScreenMemoryOcrIndexState,
31
+ pub attempted_frames: usize,
32
+ pub completed_frames: usize,
33
+ pub started_at: Option<String>,
34
+ pub finished_at: Option<String>,
35
+ pub error: Option<String>,
36
+ }
37
+
38
+ impl ScreenMemoryOcrIndexStatus {
39
+ pub fn pending() -> Self {
40
+ Self {
41
+ state: ScreenMemoryOcrIndexState::Pending,
42
+ attempted_frames: 0,
43
+ completed_frames: 0,
44
+ started_at: None,
45
+ finished_at: None,
46
+ error: None,
47
+ }
48
+ }
49
+
50
+ pub fn indexing(started_at: impl Into<String>) -> Self {
51
+ Self {
52
+ state: ScreenMemoryOcrIndexState::Indexing,
53
+ attempted_frames: 0,
54
+ completed_frames: 0,
55
+ started_at: Some(started_at.into()),
56
+ finished_at: None,
57
+ error: None,
58
+ }
59
+ }
60
+
61
+ pub fn ready(
62
+ attempted_frames: usize,
63
+ completed_frames: usize,
64
+ started_at: impl Into<String>,
65
+ finished_at: impl Into<String>,
66
+ ) -> Self {
67
+ Self {
68
+ state: ScreenMemoryOcrIndexState::Ready,
69
+ attempted_frames,
70
+ completed_frames,
71
+ started_at: Some(started_at.into()),
72
+ finished_at: Some(finished_at.into()),
73
+ error: None,
74
+ }
75
+ }
76
+
77
+ pub fn failed(
78
+ attempted_frames: usize,
79
+ completed_frames: usize,
80
+ started_at: impl Into<String>,
81
+ finished_at: impl Into<String>,
82
+ error: impl Into<String>,
83
+ ) -> Self {
84
+ Self {
85
+ state: ScreenMemoryOcrIndexState::Failed,
86
+ attempted_frames,
87
+ completed_frames,
88
+ started_at: Some(started_at.into()),
89
+ finished_at: Some(finished_at.into()),
90
+ error: Some(error.into()),
91
+ }
92
+ }
93
+
94
+ pub fn skipped(finished_at: impl Into<String>) -> Self {
95
+ Self {
96
+ state: ScreenMemoryOcrIndexState::Skipped,
97
+ attempted_frames: 0,
98
+ completed_frames: 0,
99
+ started_at: None,
100
+ finished_at: Some(finished_at.into()),
101
+ error: None,
102
+ }
103
+ }
104
+ }
105
+
106
+ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107
+ #[serde(rename_all = "camelCase")]
108
+ pub struct ScreenMemoryOcrRow {
109
+ pub schema_version: u32,
110
+ pub segment_id: String,
111
+ pub captured_at: String,
112
+ pub offset_ms: i64,
113
+ pub source: String,
114
+ pub ocr_text: String,
115
+ pub confidence: f32,
116
+ pub frame_width: u32,
117
+ pub frame_height: u32,
118
+ }
119
+
120
+ #[derive(Debug, Clone, Deserialize)]
121
+ #[serde(rename_all = "camelCase")]
122
+ struct NativeOcrFrame {
123
+ offset_ms: i64,
124
+ text: String,
125
+ confidence: f32,
126
+ width: u32,
127
+ height: u32,
128
+ }
129
+
130
+ #[derive(Debug, Deserialize)]
131
+ struct NativeOcrResponse {
132
+ ok: bool,
133
+ frames: Option<Vec<NativeOcrFrame>>,
134
+ error: Option<String>,
135
+ }
136
+
137
+ /// Maps Vision's actual extracted frame time onto the segment timeline.
138
+ pub fn captured_at_for_offset(segment_started_at: DateTime<Utc>, offset_ms: i64) -> String {
139
+ (segment_started_at + Duration::milliseconds(offset_ms.max(0))).to_rfc3339()
140
+ }
141
+
142
+ /// The sparse sampling contract shared with the native helper: start at the
143
+ /// segment boundary, use the configured interval, and never request more than
144
+ /// thirty video frames.
145
+ pub fn requested_frame_offsets(duration_ms: u64, sample_interval_seconds: u64) -> Vec<u64> {
146
+ let interval_ms = sample_interval_seconds.max(1).saturating_mul(1_000);
147
+ (0..MAX_OCR_FRAMES)
148
+ .map(|index| (index as u64).saturating_mul(interval_ms))
149
+ .take_while(|offset_ms| *offset_ms <= duration_ms)
150
+ .collect()
151
+ }
152
+
153
+ /// Retains a bounded, UTF-8-safe prefix without carrying frame images forward.
154
+ pub fn bounded_ocr_text(text: &str) -> String {
155
+ if text.len() <= MAX_OCR_TEXT_BYTES {
156
+ return text.to_owned();
157
+ }
158
+ let mut end = MAX_OCR_TEXT_BYTES;
159
+ while !text.is_char_boundary(end) {
160
+ end -= 1;
161
+ }
162
+ text[..end].to_owned()
163
+ }
164
+
165
+ fn rows_from_frames(
166
+ segment_id: &str,
167
+ segment_started_at: DateTime<Utc>,
168
+ frames: impl IntoIterator<Item = NativeOcrFrame>,
169
+ ) -> Vec<ScreenMemoryOcrRow> {
170
+ frames
171
+ .into_iter()
172
+ .filter(|frame| !frame.text.trim().is_empty())
173
+ .take(MAX_OCR_FRAMES)
174
+ .map(|frame| ScreenMemoryOcrRow {
175
+ schema_version: OCR_SCHEMA_VERSION,
176
+ segment_id: segment_id.to_owned(),
177
+ captured_at: captured_at_for_offset(segment_started_at, frame.offset_ms),
178
+ offset_ms: frame.offset_ms.max(0),
179
+ source: "ocr".to_owned(),
180
+ ocr_text: bounded_ocr_text(&frame.text),
181
+ confidence: frame.confidence.clamp(0.0, 1.0),
182
+ frame_width: frame.width,
183
+ frame_height: frame.height,
184
+ })
185
+ .collect()
186
+ }
187
+
188
+ /// Runs sparse, on-device OCR on macOS. Callers own persistence and should
189
+ /// transition the associated index status from `indexing` to `ready`/`failed`.
190
+ #[cfg(target_os = "macos")]
191
+ pub fn recognize_segment(
192
+ segment_path: &Path,
193
+ segment_id: &str,
194
+ segment_started_at: DateTime<Utc>,
195
+ sample_interval_seconds: u64,
196
+ ) -> Result<Vec<ScreenMemoryOcrRow>, String> {
197
+ let path = CString::new(segment_path.as_os_str().as_encoded_bytes())
198
+ .map_err(|_| "segment path contains an interior NUL byte".to_owned())?;
199
+ let response =
200
+ unsafe { clips_screen_memory_ocr_json(path.as_ptr(), sample_interval_seconds.max(1)) };
201
+ if response.is_null() {
202
+ return Err("macOS OCR helper returned no response".to_owned());
203
+ }
204
+ let response_text = unsafe {
205
+ let text = CStr::from_ptr(response).to_string_lossy().into_owned();
206
+ clips_screen_memory_ocr_free(response);
207
+ text
208
+ };
209
+ let response: NativeOcrResponse = serde_json::from_str(&response_text)
210
+ .map_err(|_| "macOS OCR helper returned an invalid response".to_owned())?;
211
+ if !response.ok {
212
+ return Err(response
213
+ .error
214
+ .unwrap_or_else(|| "macOS OCR failed".to_owned()));
215
+ }
216
+ Ok(rows_from_frames(
217
+ segment_id,
218
+ segment_started_at,
219
+ response.frames.unwrap_or_default(),
220
+ ))
221
+ }
222
+
223
+ /// Platforms without AVFoundation/Vision do not silently provide a different
224
+ /// OCR path. Capture remains usable, while callers can mark OCR as skipped.
225
+ #[cfg(not(target_os = "macos"))]
226
+ pub fn recognize_segment(
227
+ _segment_path: &Path,
228
+ _segment_id: &str,
229
+ _segment_started_at: DateTime<Utc>,
230
+ _sample_interval_seconds: u64,
231
+ ) -> Result<Vec<ScreenMemoryOcrRow>, String> {
232
+ Err("local Screen Memory OCR is supported on macOS only".to_owned())
233
+ }
234
+
235
+ #[cfg(target_os = "macos")]
236
+ unsafe extern "C" {
237
+ fn clips_screen_memory_ocr_json(
238
+ video_path: *const std::ffi::c_char,
239
+ sample_interval_seconds: u64,
240
+ ) -> *mut std::ffi::c_char;
241
+ fn clips_screen_memory_ocr_free(response: *mut std::ffi::c_char);
242
+ }
243
+
244
+ #[cfg(test)]
245
+ mod tests {
246
+ use super::*;
247
+
248
+ fn frame(offset_ms: i64, text: impl Into<String>) -> NativeOcrFrame {
249
+ NativeOcrFrame {
250
+ offset_ms,
251
+ text: text.into(),
252
+ confidence: 1.2,
253
+ width: 1920,
254
+ height: 1080,
255
+ }
256
+ }
257
+
258
+ #[test]
259
+ fn maps_actual_frame_time_to_segment_timestamp() {
260
+ let started_at = "2026-07-14T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
261
+ assert_eq!(
262
+ captured_at_for_offset(started_at, 1_250),
263
+ "2026-07-14T12:00:01.250+00:00"
264
+ );
265
+ assert_eq!(
266
+ captured_at_for_offset(started_at, -1),
267
+ "2026-07-14T12:00:00+00:00"
268
+ );
269
+ }
270
+
271
+ #[test]
272
+ fn caps_rows_and_bounds_text_without_splitting_unicode() {
273
+ let started_at = "2026-07-14T12:00:00Z".parse::<DateTime<Utc>>().unwrap();
274
+ let oversized = "é".repeat(MAX_OCR_TEXT_BYTES);
275
+ let rows = rows_from_frames(
276
+ "segment-1",
277
+ started_at,
278
+ (0..35).map(|index| {
279
+ frame(
280
+ index * 1_000,
281
+ if index == 0 {
282
+ oversized.clone()
283
+ } else {
284
+ "text".to_owned()
285
+ },
286
+ )
287
+ }),
288
+ );
289
+ assert_eq!(rows.len(), MAX_OCR_FRAMES);
290
+ assert!(rows[0].ocr_text.len() <= MAX_OCR_TEXT_BYTES);
291
+ assert!(rows[0].ocr_text.is_char_boundary(rows[0].ocr_text.len()));
292
+ assert_eq!(rows[0].source, "ocr");
293
+ assert_eq!(rows[0].confidence, 1.0);
294
+ }
295
+
296
+ #[test]
297
+ fn samples_the_requested_interval_with_a_thirty_frame_cap() {
298
+ assert_eq!(
299
+ requested_frame_offsets(5 * 60 * 1_000, 10),
300
+ (0..MAX_OCR_FRAMES)
301
+ .map(|index| index as u64 * 10_000)
302
+ .collect::<Vec<_>>()
303
+ );
304
+ assert_eq!(requested_frame_offsets(2_500, 0), vec![0, 1_000, 2_000]);
305
+ }
306
+
307
+ #[test]
308
+ fn status_shape_serializes_stably() {
309
+ assert_eq!(
310
+ serde_json::to_value(ScreenMemoryOcrIndexStatus::pending()).unwrap(),
311
+ serde_json::json!({
312
+ "state": "pending", "attemptedFrames": 0, "completedFrames": 0,
313
+ "startedAt": null, "finishedAt": null, "error": null
314
+ })
315
+ );
316
+ assert_eq!(
317
+ serde_json::to_value(ScreenMemoryOcrIndexStatus::ready(
318
+ 4,
319
+ 3,
320
+ "2026-07-14T12:00:00Z",
321
+ "2026-07-14T12:00:01Z",
322
+ ))
323
+ .unwrap(),
324
+ serde_json::json!({
325
+ "state": "ready", "attemptedFrames": 4, "completedFrames": 3,
326
+ "startedAt": "2026-07-14T12:00:00Z", "finishedAt": "2026-07-14T12:00:01Z", "error": null
327
+ })
328
+ );
329
+ assert_eq!(
330
+ serde_json::to_value(ScreenMemoryOcrIndexStatus::indexing("2026-07-14T12:00:00Z"))
331
+ .unwrap(),
332
+ serde_json::json!({
333
+ "state": "indexing", "attemptedFrames": 0, "completedFrames": 0,
334
+ "startedAt": "2026-07-14T12:00:00Z", "finishedAt": null, "error": null
335
+ })
336
+ );
337
+ assert_eq!(
338
+ serde_json::to_value(ScreenMemoryOcrIndexStatus::failed(
339
+ 4,
340
+ 1,
341
+ "2026-07-14T12:00:00Z",
342
+ "2026-07-14T12:00:01Z",
343
+ "unavailable",
344
+ ))
345
+ .unwrap(),
346
+ serde_json::json!({
347
+ "state": "failed", "attemptedFrames": 4, "completedFrames": 1,
348
+ "startedAt": "2026-07-14T12:00:00Z", "finishedAt": "2026-07-14T12:00:01Z", "error": "unavailable"
349
+ })
350
+ );
351
+ assert_eq!(
352
+ serde_json::to_value(ScreenMemoryOcrIndexStatus::skipped("2026-07-14T12:00:01Z"))
353
+ .unwrap(),
354
+ serde_json::json!({
355
+ "state": "skipped", "attemptedFrames": 0, "completedFrames": 0,
356
+ "startedAt": null, "finishedAt": "2026-07-14T12:00:01Z", "error": null
357
+ })
358
+ );
359
+ }
360
+ }