@agent-native/core 0.114.5 → 0.114.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  11. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  12. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  13. package/corpus/core/src/mcp-client/index.ts +9 -0
  14. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  15. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  16. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  17. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  18. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  19. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  20. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  21. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  22. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  23. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  24. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  25. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  26. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  27. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  28. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  29. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
  30. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  31. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  32. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  33. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  34. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  35. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  47. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  51. package/corpus/templates/clips/desktop/package.json +1 -0
  52. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  53. package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
  54. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  57. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  58. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  60. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  61. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  62. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  63. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  64. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
  99. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  100. package/corpus/templates/content/AGENTS.md +4 -1
  101. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  102. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  103. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  104. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  105. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  106. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  107. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  108. package/corpus/templates/content/actions/delete-document.ts +233 -6
  109. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  110. package/corpus/templates/content/actions/get-document.ts +4 -1
  111. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  112. package/corpus/templates/content/actions/list-documents.ts +1 -0
  113. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  114. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  115. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  116. package/corpus/templates/content/actions/pull-document.ts +15 -2
  117. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  118. package/corpus/templates/content/actions/restore-document.ts +28 -0
  119. package/corpus/templates/content/actions/search-documents.ts +2 -1
  120. package/corpus/templates/content/actions/view-screen.ts +2 -1
  121. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  122. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  123. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  124. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  125. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  126. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  127. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  128. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  129. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  130. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  131. package/corpus/templates/content/app/i18n-data.ts +83 -2
  132. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  133. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  134. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  135. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  136. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  137. package/corpus/templates/content/parity/matrix.md +1 -1
  138. package/corpus/templates/content/parity/matrix.ts +3 -0
  139. package/corpus/templates/content/server/db/schema.ts +2 -0
  140. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  141. package/corpus/templates/content/server/plugins/db.ts +52 -0
  142. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  143. package/corpus/templates/content/shared/api.ts +10 -0
  144. package/corpus/toolkit/CHANGELOG.md +6 -0
  145. package/corpus/toolkit/package.json +1 -1
  146. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  147. package/dist/cli/mcp-config-writers.d.ts +2 -2
  148. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  149. package/dist/cli/mcp-config-writers.js +6 -6
  150. package/dist/cli/mcp-config-writers.js.map +1 -1
  151. package/dist/cli/mcp.d.ts +14 -0
  152. package/dist/cli/mcp.d.ts.map +1 -1
  153. package/dist/cli/mcp.js +79 -0
  154. package/dist/cli/mcp.js.map +1 -1
  155. package/dist/cli/skills-content/help.d.ts +1 -1
  156. package/dist/cli/skills-content/help.d.ts.map +1 -1
  157. package/dist/cli/skills-content/help.js +4 -3
  158. package/dist/cli/skills-content/help.js.map +1 -1
  159. package/dist/cli/skills-content/index.d.ts +1 -0
  160. package/dist/cli/skills-content/index.d.ts.map +1 -1
  161. package/dist/cli/skills-content/index.js +1 -0
  162. package/dist/cli/skills-content/index.js.map +1 -1
  163. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  164. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  165. package/dist/cli/skills-content/rewind-skill.js +53 -0
  166. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  167. package/dist/cli/skills.d.ts +6 -0
  168. package/dist/cli/skills.d.ts.map +1 -1
  169. package/dist/cli/skills.js +90 -14
  170. package/dist/cli/skills.js.map +1 -1
  171. package/dist/client/resources/ResourceTree.d.ts +3 -1
  172. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  173. package/dist/client/resources/ResourceTree.js +4 -4
  174. package/dist/client/resources/ResourceTree.js.map +1 -1
  175. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  176. package/dist/client/resources/ResourcesPanel.js +55 -6
  177. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  178. package/dist/collab/routes.d.ts +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  181. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.js +1014 -91
  183. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  184. package/dist/mcp-client/index.d.ts +1 -1
  185. package/dist/mcp-client/index.d.ts.map +1 -1
  186. package/dist/mcp-client/index.js +1 -1
  187. package/dist/mcp-client/index.js.map +1 -1
  188. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  189. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  190. package/dist/mcp-client/screen-memory-local.js +590 -12
  191. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  192. package/dist/notifications/routes.d.ts +2 -2
  193. package/dist/observability/routes.d.ts +5 -5
  194. package/dist/progress/routes.d.ts +1 -1
  195. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  196. package/dist/resources/handlers.d.ts +1 -1
  197. package/dist/secrets/routes.d.ts +9 -9
  198. package/dist/server/transcribe-voice.d.ts +1 -1
  199. package/package.json +2 -2
  200. package/src/cli/mcp-config-writers.ts +6 -4
  201. package/src/cli/mcp.ts +124 -0
  202. package/src/cli/skills-content/help.ts +4 -3
  203. package/src/cli/skills-content/index.ts +1 -0
  204. package/src/cli/skills-content/rewind-skill.ts +52 -0
  205. package/src/cli/skills.ts +115 -13
  206. package/src/client/resources/ResourceTree.tsx +11 -5
  207. package/src/client/resources/ResourcesPanel.tsx +141 -18
  208. package/src/mcp/screen-memory-stdio.ts +1360 -104
  209. package/src/mcp-client/index.ts +9 -0
  210. package/src/mcp-client/screen-memory-local.ts +847 -13
@@ -0,0 +1,2434 @@
1
+ //! Retention-bound, local-only work chapters for the Rewind buffer.
2
+ //!
3
+ //! This deliberately stores references and bounded context only. Segments remain
4
+ //! the retention authority; a missing or invalid chapter manifest is disposable.
5
+
6
+ #[cfg(test)]
7
+ use crate::config::RewindCaptureMode;
8
+ use crate::screen_memory::{ScreenMemoryEvent, ScreenMemorySegmentMetadata};
9
+ use chrono::{DateTime, Duration, Utc};
10
+ use serde::{Deserialize, Serialize};
11
+ use std::collections::{BTreeMap, BTreeSet};
12
+ use std::fs::{self, File};
13
+ use std::io::{BufRead, BufReader};
14
+ use std::path::Path;
15
+
16
+ const CHAPTERS_FILE: &str = "chapters.json";
17
+ const HARD_GAP_MS: i64 = 1_500;
18
+ const SCENE_SLICE_MS: i64 = 30_000;
19
+ // A safety bound, not a product boundary. Coherent work commonly lasts longer
20
+ // than the 15–20 minute acceptance samples and must not split merely by age.
21
+ const MAX_CHAPTER_SPAN_MS: i64 = 60 * 60 * 1_000;
22
+ const TRANSIENT_SCENE_MS: i64 = 75_000;
23
+ const MIN_SEMANTIC_TOPIC_MS: i64 = 120_000;
24
+ const SAME_SURFACE_CONTINUITY_OVERLAP: f32 = 0.40;
25
+ const MAX_KEYWORDS: usize = 8;
26
+ const MAX_EVIDENCE_REFS_PER_SOURCE: usize = 8;
27
+ const MAX_EVIDENCE_KEYWORDS: usize = 64;
28
+ const MAX_REPRESENTATIVE_MOMENTS: usize = 6;
29
+ const MAX_SIDECAR_ROWS: usize = 128;
30
+ const MAX_SIDECAR_LINE_BYTES: usize = 16 * 1024;
31
+
32
+ #[derive(Debug, Clone, Serialize, Deserialize)]
33
+ #[serde(rename_all = "camelCase")]
34
+ pub(crate) struct RewindChaptersManifest {
35
+ pub schema_version: u32,
36
+ pub generated_at: String,
37
+ pub state: ChapterIndexState,
38
+ pub coverage: ChapterCoverage,
39
+ #[serde(default)]
40
+ pub scenes: Vec<RewindScene>,
41
+ pub chapters: Vec<RewindChapter>,
42
+ }
43
+
44
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45
+ #[serde(rename_all = "kebab-case")]
46
+ pub(crate) enum ChapterIndexState {
47
+ Pending,
48
+ Partial,
49
+ Ready,
50
+ }
51
+
52
+ #[derive(Debug, Clone, Serialize, Deserialize)]
53
+ #[serde(rename_all = "camelCase")]
54
+ pub(crate) struct ChapterCoverage {
55
+ pub retained_segments: usize,
56
+ pub omitted_segments: usize,
57
+ pub gap_count: usize,
58
+ #[serde(default)]
59
+ pub gaps: Vec<CoverageGap>,
60
+ }
61
+
62
+ #[derive(Debug, Clone, Serialize, Deserialize)]
63
+ #[serde(rename_all = "camelCase")]
64
+ pub(crate) struct CoverageGap {
65
+ pub started_at: String,
66
+ pub ended_at: String,
67
+ pub duration_ms: i64,
68
+ pub reason: String,
69
+ }
70
+
71
+ #[derive(Debug, Clone, Serialize, Deserialize)]
72
+ #[serde(rename_all = "camelCase")]
73
+ pub(crate) struct RewindChapter {
74
+ pub id: String,
75
+ #[serde(default = "default_revision")]
76
+ pub revision: u32,
77
+ #[serde(default)]
78
+ pub aliases: Vec<String>,
79
+ pub started_at: String,
80
+ pub ended_at: String,
81
+ pub duration_ms: i64,
82
+ pub label: String,
83
+ pub summary: String,
84
+ pub keywords: Vec<String>,
85
+ pub confidence: f32,
86
+ #[serde(default)]
87
+ pub scene_refs: Vec<String>,
88
+ pub segment_refs: Vec<SegmentRef>,
89
+ pub evidence_refs: Vec<EvidenceRef>,
90
+ pub contexts: Vec<ChapterContext>,
91
+ pub representative_moments: Vec<RepresentativeMoment>,
92
+ #[serde(default)]
93
+ pub representative_coverage: RepresentativeCoverage,
94
+ pub ambiguity_reasons: Vec<String>,
95
+ pub index_state: ChapterIndexState,
96
+ }
97
+
98
+ fn default_revision() -> u32 {
99
+ 1
100
+ }
101
+
102
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
103
+ #[serde(rename_all = "camelCase")]
104
+ pub(crate) struct RepresentativeCoverage {
105
+ pub covered_scenes: usize,
106
+ pub total_scenes: usize,
107
+ pub truncated: bool,
108
+ }
109
+
110
+ #[derive(Debug, Clone, Serialize, Deserialize)]
111
+ #[serde(rename_all = "camelCase")]
112
+ pub(crate) struct RewindScene {
113
+ pub id: String,
114
+ pub started_at: String,
115
+ pub ended_at: String,
116
+ pub duration_ms: i64,
117
+ pub hard_boundary_before: bool,
118
+ pub segment_refs: Vec<SegmentRef>,
119
+ #[serde(skip_serializing_if = "Option::is_none")]
120
+ pub context: Option<ChapterContext>,
121
+ pub keywords: Vec<String>,
122
+ pub evidence_refs: Vec<EvidenceRef>,
123
+ pub confidence: f32,
124
+ }
125
+
126
+ #[derive(Debug, Clone, Serialize, Deserialize)]
127
+ #[serde(rename_all = "camelCase")]
128
+ pub(crate) struct SegmentRef {
129
+ pub id: String,
130
+ pub started_at: String,
131
+ pub ended_at: String,
132
+ }
133
+ #[derive(Debug, Clone, Serialize, Deserialize)]
134
+ #[serde(rename_all = "camelCase")]
135
+ pub(crate) struct EvidenceRef {
136
+ pub source_type: String,
137
+ #[serde(default, skip_serializing_if = "Option::is_none")]
138
+ pub source_kind: Option<String>,
139
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
140
+ pub keywords: Vec<String>,
141
+ #[serde(default, skip_serializing_if = "Option::is_none")]
142
+ pub confidence: Option<f32>,
143
+ #[serde(skip_serializing_if = "Option::is_none")]
144
+ pub segment_id: Option<String>,
145
+ #[serde(skip_serializing_if = "Option::is_none")]
146
+ pub offset_ms: Option<i64>,
147
+ #[serde(skip_serializing_if = "Option::is_none")]
148
+ pub captured_at: Option<String>,
149
+ }
150
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151
+ #[serde(rename_all = "camelCase")]
152
+ pub(crate) struct ChapterContext {
153
+ #[serde(skip_serializing_if = "Option::is_none")]
154
+ pub app_name: Option<String>,
155
+ #[serde(skip_serializing_if = "Option::is_none")]
156
+ pub window_title: Option<String>,
157
+ #[serde(skip_serializing_if = "Option::is_none")]
158
+ pub bundle_id: Option<String>,
159
+ #[serde(skip_serializing_if = "Option::is_none")]
160
+ pub document_title: Option<String>,
161
+ #[serde(skip_serializing_if = "Option::is_none")]
162
+ pub document_url: Option<String>,
163
+ }
164
+ #[derive(Debug, Clone, Serialize, Deserialize)]
165
+ #[serde(rename_all = "camelCase")]
166
+ pub(crate) struct RepresentativeMoment {
167
+ pub moment_id: String,
168
+ pub captured_at: String,
169
+ pub segment_id: String,
170
+ pub offset_ms: i64,
171
+ pub reason: String,
172
+ }
173
+
174
+ #[derive(Clone)]
175
+ struct Item {
176
+ segment: ScreenMemorySegmentMetadata,
177
+ start: DateTime<Utc>,
178
+ end: DateTime<Utc>,
179
+ partial: bool,
180
+ content: ContentSignal,
181
+ }
182
+
183
+ #[derive(Clone, Default)]
184
+ struct ContentSignal {
185
+ visual_keywords: BTreeMap<String, usize>,
186
+ system_audio_keywords: BTreeMap<String, usize>,
187
+ microphone_keywords: BTreeMap<String, usize>,
188
+ evidence_refs: Vec<EvidenceRef>,
189
+ }
190
+
191
+ #[derive(Clone)]
192
+ struct SceneItem {
193
+ scene: RewindScene,
194
+ start: DateTime<Utc>,
195
+ end: DateTime<Utc>,
196
+ visual_keywords: BTreeMap<String, usize>,
197
+ system_audio_keywords: BTreeMap<String, usize>,
198
+ microphone_keywords: BTreeMap<String, usize>,
199
+ partial: bool,
200
+ }
201
+
202
+ pub(crate) fn rebuild(
203
+ dir: &Path,
204
+ segments: Vec<ScreenMemorySegmentMetadata>,
205
+ events: Vec<ScreenMemoryEvent>,
206
+ ) -> Result<(), String> {
207
+ let content = content_signals(dir, &segments);
208
+ let previous = fs::read(dir.join(CHAPTERS_FILE))
209
+ .ok()
210
+ .and_then(|bytes| serde_json::from_slice::<RewindChaptersManifest>(&bytes).ok());
211
+ let manifest = build_with_previous(
212
+ segments,
213
+ events,
214
+ pending_segment_ids(dir),
215
+ content,
216
+ previous.as_ref(),
217
+ Utc::now(),
218
+ );
219
+ let bytes = serde_json::to_vec_pretty(&manifest)
220
+ .map_err(|e| format!("chapter manifest encode failed: {e}"))?;
221
+ write_atomic(&dir.join(CHAPTERS_FILE), &bytes)
222
+ }
223
+
224
+ pub(crate) fn clear(dir: &Path) -> Result<(), String> {
225
+ let path = dir.join(CHAPTERS_FILE);
226
+ match fs::remove_file(path) {
227
+ Ok(()) => Ok(()),
228
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
229
+ Err(e) => Err(format!("chapter manifest remove failed: {e}")),
230
+ }
231
+ }
232
+
233
+ fn build(
234
+ segments: Vec<ScreenMemorySegmentMetadata>,
235
+ events: Vec<ScreenMemoryEvent>,
236
+ generated_at: DateTime<Utc>,
237
+ ) -> RewindChaptersManifest {
238
+ build_with_pending(
239
+ segments,
240
+ events,
241
+ BTreeSet::new(),
242
+ BTreeMap::new(),
243
+ generated_at,
244
+ )
245
+ }
246
+ fn build_with_pending(
247
+ segments: Vec<ScreenMemorySegmentMetadata>,
248
+ events: Vec<ScreenMemoryEvent>,
249
+ pending: BTreeSet<String>,
250
+ content: BTreeMap<String, ContentSignal>,
251
+ generated_at: DateTime<Utc>,
252
+ ) -> RewindChaptersManifest {
253
+ build_with_previous(segments, events, pending, content, None, generated_at)
254
+ }
255
+
256
+ fn build_with_previous(
257
+ segments: Vec<ScreenMemorySegmentMetadata>,
258
+ mut events: Vec<ScreenMemoryEvent>,
259
+ pending: BTreeSet<String>,
260
+ content: BTreeMap<String, ContentSignal>,
261
+ previous: Option<&RewindChaptersManifest>,
262
+ generated_at: DateTime<Utc>,
263
+ ) -> RewindChaptersManifest {
264
+ events.sort_by(|a, b| a.captured_at.cmp(&b.captured_at));
265
+ let mut omitted = 0usize;
266
+ let mut explicit_gaps = Vec::new();
267
+ let mut items = segments
268
+ .into_iter()
269
+ .filter_map(|segment| {
270
+ if segment.exclusion_tainted || segment.corrupt || segment.error.is_some() {
271
+ omitted += 1;
272
+ if let (Ok(start), Ok(end)) = (
273
+ DateTime::parse_from_rfc3339(&segment.started_at),
274
+ DateTime::parse_from_rfc3339(&segment.ended_at),
275
+ ) {
276
+ let start = start.with_timezone(&Utc);
277
+ let end = end.with_timezone(&Utc);
278
+ if end > start {
279
+ explicit_gaps.push(CoverageGap {
280
+ started_at: start.to_rfc3339(),
281
+ ended_at: end.to_rfc3339(),
282
+ duration_ms: (end - start).num_milliseconds(),
283
+ reason: if segment.exclusion_tainted {
284
+ "excluded capture interval"
285
+ } else if segment.corrupt {
286
+ "corrupt capture interval"
287
+ } else {
288
+ "failed capture interval"
289
+ }
290
+ .into(),
291
+ });
292
+ }
293
+ }
294
+ return None;
295
+ }
296
+ let start = DateTime::parse_from_rfc3339(&segment.started_at)
297
+ .ok()?
298
+ .with_timezone(&Utc);
299
+ let end = DateTime::parse_from_rfc3339(&segment.ended_at)
300
+ .ok()?
301
+ .with_timezone(&Utc);
302
+ if end <= start {
303
+ omitted += 1;
304
+ return None;
305
+ }
306
+ let partial = pending.contains(&segment.id);
307
+ let content = content.get(&segment.id).cloned().unwrap_or_default();
308
+ Some(Item {
309
+ segment,
310
+ start,
311
+ end,
312
+ partial,
313
+ content,
314
+ })
315
+ })
316
+ .collect::<Vec<_>>();
317
+ items.sort_by(|a, b| (a.start, &a.segment.id).cmp(&(b.start, &b.segment.id)));
318
+ let retained = items.len();
319
+ let mut gaps = timeline_gaps(&items, &events);
320
+ gaps.append(&mut explicit_gaps);
321
+ gaps.sort_by(|a, b| a.started_at.cmp(&b.started_at));
322
+ gaps.dedup_by(|a, b| a.started_at == b.started_at && a.ended_at == b.ended_at);
323
+
324
+ let scene_items = build_scene_items(&items, &events);
325
+ let scenes = scene_items
326
+ .iter()
327
+ .map(|item| item.scene.clone())
328
+ .collect::<Vec<_>>();
329
+ let groups = group_semantic_scenes(&scene_items);
330
+ let mut chapters = groups.into_iter().map(chapter).collect::<Vec<_>>();
331
+ apply_previous_identity(&mut chapters, previous);
332
+ let state = if retained == 0 {
333
+ ChapterIndexState::Pending
334
+ } else if chapters
335
+ .iter()
336
+ .any(|c| c.index_state == ChapterIndexState::Partial)
337
+ {
338
+ ChapterIndexState::Partial
339
+ } else {
340
+ ChapterIndexState::Ready
341
+ };
342
+ RewindChaptersManifest {
343
+ schema_version: 2,
344
+ generated_at: generated_at.to_rfc3339(),
345
+ state,
346
+ coverage: ChapterCoverage {
347
+ retained_segments: retained,
348
+ omitted_segments: omitted,
349
+ gap_count: gaps.len(),
350
+ gaps,
351
+ },
352
+ scenes,
353
+ chapters,
354
+ }
355
+ }
356
+
357
+ fn timeline_gaps(items: &[Item], events: &[ScreenMemoryEvent]) -> Vec<CoverageGap> {
358
+ items
359
+ .windows(2)
360
+ .filter_map(|pair| {
361
+ let previous = &pair[0];
362
+ let next = &pair[1];
363
+ let duration_ms = (next.start - previous.end).num_milliseconds();
364
+ (duration_ms > HARD_GAP_MS).then(|| {
365
+ let explicit_reason = events.iter().find_map(|event| {
366
+ (event.source == "coverage-gap"
367
+ && DateTime::parse_from_rfc3339(&event.captured_at)
368
+ .ok()
369
+ .map(|at| at.with_timezone(&Utc))
370
+ .is_some_and(|at| at >= previous.end && at <= next.start))
371
+ .then(|| event.coverage_gap_reason.clone())
372
+ .flatten()
373
+ });
374
+ CoverageGap {
375
+ started_at: previous.end.to_rfc3339(),
376
+ ended_at: next.start.to_rfc3339(),
377
+ duration_ms,
378
+ reason: explicit_reason.unwrap_or_else(|| {
379
+ if previous.segment.graph_epoch_id != next.segment.graph_epoch_id {
380
+ "capture restart interval"
381
+ } else {
382
+ "unretained capture interval"
383
+ }
384
+ .into()
385
+ }),
386
+ }
387
+ })
388
+ })
389
+ .collect()
390
+ }
391
+
392
+ fn build_scene_items(items: &[Item], events: &[ScreenMemoryEvent]) -> Vec<SceneItem> {
393
+ let mut scenes = Vec::new();
394
+ for (item_index, item) in items.iter().enumerate() {
395
+ let mut boundaries = vec![item.start, item.end];
396
+ let mut cursor = item.start + Duration::milliseconds(SCENE_SLICE_MS);
397
+ while cursor < item.end {
398
+ boundaries.push(cursor);
399
+ cursor += Duration::milliseconds(SCENE_SLICE_MS);
400
+ }
401
+ let mut last_semantic_key: Option<String> = None;
402
+ for event in events {
403
+ let Some(at) = parse_utc(&event.captured_at) else {
404
+ continue;
405
+ };
406
+ if at < item.start || at > item.end || event.source == "coverage-gap" {
407
+ continue;
408
+ }
409
+ let key = event_semantic_key(event);
410
+ if last_semantic_key.as_ref() != Some(&key) {
411
+ if at > item.start && at < item.end {
412
+ boundaries.push(at);
413
+ }
414
+ last_semantic_key = Some(key);
415
+ }
416
+ }
417
+ boundaries.sort();
418
+ boundaries.dedup();
419
+ for pair in boundaries.windows(2) {
420
+ let start = pair[0];
421
+ let end = pair[1];
422
+ if end <= start {
423
+ continue;
424
+ }
425
+ let context_event = context_event_at(events, start, end);
426
+ let context = context_event.map(chapter_context_from_event);
427
+ let mut visual_keywords = BTreeMap::new();
428
+ let mut system_audio_keywords = BTreeMap::new();
429
+ let mut microphone_keywords = BTreeMap::new();
430
+ let mut evidence_refs = Vec::new();
431
+
432
+ if let Some(event) = context_event {
433
+ if let Some(title) = event.window_title.as_deref() {
434
+ let repeats_app_name = event
435
+ .app_name
436
+ .as_deref()
437
+ .is_some_and(|app| app.trim().eq_ignore_ascii_case(title.trim()));
438
+ if !generic_surface_title(title) && !repeats_app_name {
439
+ add_keywords(title, &mut visual_keywords);
440
+ }
441
+ }
442
+ let accessibility = accessibility_keywords(event);
443
+ merge_counts(&mut visual_keywords, &accessibility);
444
+ evidence_refs.push(EvidenceRef {
445
+ source_type: "app-context".into(),
446
+ source_kind: Some(
447
+ if event.accessibility.is_some() {
448
+ "accessibility"
449
+ } else {
450
+ "window"
451
+ }
452
+ .into(),
453
+ ),
454
+ keywords: top_keywords(&accessibility, 12),
455
+ confidence: Some(if event.accessibility.is_some() {
456
+ 0.9
457
+ } else {
458
+ 0.55
459
+ }),
460
+ segment_id: Some(item.segment.id.clone()),
461
+ offset_ms: Some((start - item.start).num_milliseconds()),
462
+ captured_at: Some(start.to_rfc3339()),
463
+ });
464
+ }
465
+
466
+ for reference in &item.content.evidence_refs {
467
+ let Some(at) = evidence_time(reference, item.start) else {
468
+ continue;
469
+ };
470
+ if at < start || at >= end {
471
+ continue;
472
+ }
473
+ match reference.source_kind.as_deref() {
474
+ Some("visual" | "accessibility") => {
475
+ add_keyword_list(&reference.keywords, &mut visual_keywords)
476
+ }
477
+ Some("microphone") => {
478
+ add_keyword_list(&reference.keywords, &mut microphone_keywords)
479
+ }
480
+ _ => add_keyword_list(&reference.keywords, &mut system_audio_keywords),
481
+ }
482
+ evidence_refs.push(reference.clone());
483
+ }
484
+
485
+ let mut searchable = visual_keywords.clone();
486
+ merge_counts(&mut searchable, &system_audio_keywords);
487
+ if searchable.is_empty() {
488
+ merge_counts(&mut searchable, &microphone_keywords);
489
+ }
490
+ let hard_boundary_before = scenes.is_empty()
491
+ || (start == item.start
492
+ && item_index > 0
493
+ && hard_boundary_between(&items[item_index - 1], item));
494
+ let scene_id = format!("scene-{}-{}", item.segment.id, start.timestamp_millis());
495
+ let confidence = if !visual_keywords.is_empty() {
496
+ 0.85
497
+ } else if !system_audio_keywords.is_empty() {
498
+ 0.65
499
+ } else if context.is_some() {
500
+ 0.5
501
+ } else {
502
+ 0.3
503
+ };
504
+ let scene = RewindScene {
505
+ id: scene_id,
506
+ started_at: start.to_rfc3339(),
507
+ ended_at: end.to_rfc3339(),
508
+ duration_ms: (end - start).num_milliseconds(),
509
+ hard_boundary_before,
510
+ segment_refs: vec![SegmentRef {
511
+ id: item.segment.id.clone(),
512
+ started_at: item.segment.started_at.clone(),
513
+ ended_at: item.segment.ended_at.clone(),
514
+ }],
515
+ context,
516
+ keywords: top_keywords(&searchable, MAX_KEYWORDS),
517
+ evidence_refs: bounded_evidence_refs(evidence_refs),
518
+ confidence,
519
+ };
520
+ scenes.push(SceneItem {
521
+ scene,
522
+ start,
523
+ end,
524
+ visual_keywords,
525
+ system_audio_keywords,
526
+ microphone_keywords,
527
+ partial: item.partial,
528
+ });
529
+ }
530
+ }
531
+ scenes
532
+ }
533
+
534
+ fn hard_boundary_between(previous: &Item, next: &Item) -> bool {
535
+ previous.segment.graph_epoch_id != next.segment.graph_epoch_id
536
+ || previous.segment.capture_mode != next.segment.capture_mode
537
+ || (next.start - previous.end).num_milliseconds() > HARD_GAP_MS
538
+ }
539
+
540
+ fn context_event_at(
541
+ events: &[ScreenMemoryEvent],
542
+ start: DateTime<Utc>,
543
+ end: DateTime<Utc>,
544
+ ) -> Option<&ScreenMemoryEvent> {
545
+ events
546
+ .iter()
547
+ .filter_map(|event| parse_utc(&event.captured_at).map(|at| (at, event)))
548
+ .filter(|(at, event)| *at <= end && event.source != "coverage-gap")
549
+ .filter(|(at, _)| *at <= start || (*at >= start && *at < end))
550
+ .max_by_key(|(at, _)| *at)
551
+ .map(|(_, event)| event)
552
+ }
553
+
554
+ fn chapter_context_from_event(event: &ScreenMemoryEvent) -> ChapterContext {
555
+ let document = event
556
+ .accessibility
557
+ .as_ref()
558
+ .and_then(|fingerprint| fingerprint.document.as_ref());
559
+ ChapterContext {
560
+ app_name: event.app_name.clone(),
561
+ window_title: event.window_title.clone(),
562
+ bundle_id: event.bundle_id.clone(),
563
+ document_title: document.and_then(|node| {
564
+ node.title
565
+ .clone()
566
+ .or_else(|| node.document.clone())
567
+ .filter(|value| !value.trim().is_empty())
568
+ }),
569
+ document_url: document.and_then(|node| node.url.clone()),
570
+ }
571
+ }
572
+
573
+ fn event_semantic_key(event: &ScreenMemoryEvent) -> String {
574
+ format!(
575
+ "{}|{}|{}|{}",
576
+ event.app_name.as_deref().unwrap_or_default(),
577
+ event.window_title.as_deref().unwrap_or_default(),
578
+ event.bundle_id.as_deref().unwrap_or_default(),
579
+ top_keywords(&accessibility_keywords(event), 16).join(",")
580
+ )
581
+ }
582
+
583
+ fn accessibility_keywords(event: &ScreenMemoryEvent) -> BTreeMap<String, usize> {
584
+ let mut keywords = BTreeMap::new();
585
+ let Some(accessibility) = event.accessibility.as_ref() else {
586
+ return keywords;
587
+ };
588
+ for node in [
589
+ accessibility.document.as_ref(),
590
+ accessibility.focused.as_ref(),
591
+ ]
592
+ .into_iter()
593
+ .flatten()
594
+ {
595
+ // Roles describe the accessibility API, not the user's work. Ingesting
596
+ // them recursively produced labels such as "Working on AXTextArea".
597
+ for text in [
598
+ node.title.as_deref(),
599
+ node.description.as_deref(),
600
+ node.document.as_deref(),
601
+ node.selected_text.as_deref(),
602
+ ]
603
+ .into_iter()
604
+ .flatten()
605
+ {
606
+ add_keywords(text, &mut keywords);
607
+ add_semantic_phrases(text, &mut keywords);
608
+ }
609
+ }
610
+ for label in &accessibility.visible_labels {
611
+ add_keywords(label, &mut keywords);
612
+ add_semantic_phrases(label, &mut keywords);
613
+ }
614
+ keywords
615
+ }
616
+
617
+ fn add_keyword_list(keywords: &[String], counts: &mut BTreeMap<String, usize>) {
618
+ for keyword in keywords {
619
+ *counts.entry(keyword.clone()).or_default() += 1;
620
+ }
621
+ }
622
+
623
+ fn evidence_time(reference: &EvidenceRef, segment_start: DateTime<Utc>) -> Option<DateTime<Utc>> {
624
+ reference
625
+ .captured_at
626
+ .as_deref()
627
+ .and_then(parse_utc)
628
+ .or_else(|| {
629
+ reference
630
+ .offset_ms
631
+ .map(|offset| segment_start + Duration::milliseconds(offset.max(0)))
632
+ })
633
+ }
634
+
635
+ fn parse_utc(value: &str) -> Option<DateTime<Utc>> {
636
+ DateTime::parse_from_rfc3339(value)
637
+ .ok()
638
+ .map(|at| at.with_timezone(&Utc))
639
+ }
640
+
641
+ fn bounded_evidence_refs(mut references: Vec<EvidenceRef>) -> Vec<EvidenceRef> {
642
+ references.sort_by(|a, b| {
643
+ (
644
+ &a.source_type,
645
+ &a.source_kind,
646
+ &a.segment_id,
647
+ &a.offset_ms,
648
+ &a.captured_at,
649
+ )
650
+ .cmp(&(
651
+ &b.source_type,
652
+ &b.source_kind,
653
+ &b.segment_id,
654
+ &b.offset_ms,
655
+ &b.captured_at,
656
+ ))
657
+ });
658
+ references.dedup_by(|a, b| {
659
+ a.source_type == b.source_type
660
+ && a.source_kind == b.source_kind
661
+ && a.segment_id == b.segment_id
662
+ && a.offset_ms == b.offset_ms
663
+ && a.captured_at == b.captured_at
664
+ });
665
+ let mut by_source: BTreeMap<String, Vec<EvidenceRef>> = BTreeMap::new();
666
+ for reference in references {
667
+ by_source
668
+ .entry(format!(
669
+ "{}:{}",
670
+ reference.source_type,
671
+ reference.source_kind.as_deref().unwrap_or_default()
672
+ ))
673
+ .or_default()
674
+ .push(reference);
675
+ }
676
+ by_source
677
+ .into_values()
678
+ .flat_map(|references| evenly_bounded(references, MAX_EVIDENCE_REFS_PER_SOURCE))
679
+ .collect()
680
+ }
681
+
682
+ fn evenly_bounded<T: Clone>(values: Vec<T>, limit: usize) -> Vec<T> {
683
+ if values.len() <= limit {
684
+ return values;
685
+ }
686
+ (0..limit)
687
+ .map(|index| {
688
+ let source_index = index * (values.len() - 1) / (limit - 1);
689
+ values[source_index].clone()
690
+ })
691
+ .collect()
692
+ }
693
+ fn pending_segment_ids(dir: &Path) -> BTreeSet<String> {
694
+ fs::read_dir(dir)
695
+ .ok()
696
+ .into_iter()
697
+ .flatten()
698
+ .filter_map(|entry| {
699
+ let entry = entry.ok()?;
700
+ let path = entry.path();
701
+ let name = entry.file_name().to_string_lossy().to_string();
702
+ let segment_id = name
703
+ .strip_suffix(".ocr-status.json")
704
+ .or_else(|| name.strip_suffix(".transcript-status.json"))?;
705
+ let bytes = fs::read(path).ok()?;
706
+ let state = serde_json::from_slice::<serde_json::Value>(&bytes)
707
+ .ok()?
708
+ .get("state")?
709
+ .as_str()?
710
+ .to_owned();
711
+ matches!(
712
+ state.as_str(),
713
+ "pending" | "indexing" | "transcribing" | "failed"
714
+ )
715
+ .then_some(segment_id.to_owned())
716
+ })
717
+ .collect()
718
+ }
719
+
720
+ fn content_signals(
721
+ dir: &Path,
722
+ segments: &[ScreenMemorySegmentMetadata],
723
+ ) -> BTreeMap<String, ContentSignal> {
724
+ segments
725
+ .iter()
726
+ .filter(|segment| !segment.exclusion_tainted && !segment.corrupt && segment.error.is_none())
727
+ .map(|segment| {
728
+ let mut signal = ContentSignal::default();
729
+ read_sidecar_signal(
730
+ &dir.join(format!("{}.transcript.jsonl", segment.id)),
731
+ "transcript",
732
+ &segment.id,
733
+ &mut signal,
734
+ );
735
+ read_sidecar_signal(
736
+ &dir.join(format!("{}.ocr.jsonl", segment.id)),
737
+ "ocr",
738
+ &segment.id,
739
+ &mut signal,
740
+ );
741
+ (segment.id.clone(), signal)
742
+ })
743
+ .collect()
744
+ }
745
+
746
+ fn read_sidecar_signal(
747
+ path: &Path,
748
+ source_type: &str,
749
+ segment_id: &str,
750
+ signal: &mut ContentSignal,
751
+ ) {
752
+ let Ok(file) = File::open(path) else {
753
+ return;
754
+ };
755
+ for line in BufReader::new(file)
756
+ .lines()
757
+ .map_while(Result::ok)
758
+ .take(MAX_SIDECAR_ROWS)
759
+ {
760
+ if line.len() > MAX_SIDECAR_LINE_BYTES {
761
+ continue;
762
+ }
763
+ let Ok(row) = serde_json::from_str::<serde_json::Value>(&line) else {
764
+ continue;
765
+ };
766
+ let text = match source_type {
767
+ "transcript" => row.get("text"),
768
+ "ocr" => row.get("ocrText"),
769
+ _ => None,
770
+ }
771
+ .and_then(|value| value.as_str());
772
+ let source_kind = if source_type == "transcript" {
773
+ row.get("source")
774
+ .and_then(|value| value.as_str())
775
+ .unwrap_or("unknown-audio")
776
+ } else {
777
+ "visual"
778
+ };
779
+ let mut row_keywords = BTreeMap::new();
780
+ if let Some(text) = text {
781
+ add_keywords(text, &mut row_keywords);
782
+ if source_type == "ocr" {
783
+ add_semantic_phrases(text, &mut row_keywords);
784
+ }
785
+ let target = if source_type == "ocr" {
786
+ &mut signal.visual_keywords
787
+ } else if source_kind == "microphone" {
788
+ &mut signal.microphone_keywords
789
+ } else {
790
+ &mut signal.system_audio_keywords
791
+ };
792
+ merge_counts(target, &row_keywords);
793
+ }
794
+ signal.evidence_refs.push(EvidenceRef {
795
+ source_type: source_type.into(),
796
+ source_kind: Some(source_kind.into()),
797
+ keywords: top_keywords(&row_keywords, MAX_EVIDENCE_KEYWORDS),
798
+ confidence: row
799
+ .get("confidence")
800
+ .and_then(|value| value.as_f64())
801
+ .map(|value| value as f32),
802
+ segment_id: Some(segment_id.into()),
803
+ offset_ms: row
804
+ .get("startMs")
805
+ .or_else(|| row.get("offsetMs"))
806
+ .and_then(|value| value.as_i64()),
807
+ captured_at: row
808
+ .get("capturedAt")
809
+ .and_then(|value| value.as_str())
810
+ .map(str::to_owned),
811
+ });
812
+ }
813
+ }
814
+
815
+ fn merge_counts(target: &mut BTreeMap<String, usize>, source: &BTreeMap<String, usize>) {
816
+ for (keyword, count) in source {
817
+ *target.entry(keyword.clone()).or_default() += count;
818
+ }
819
+ }
820
+
821
+ fn top_keywords(counts: &BTreeMap<String, usize>, limit: usize) -> Vec<String> {
822
+ let mut keywords = counts.iter().collect::<Vec<_>>();
823
+ keywords.sort_by(|(left_keyword, left_count), (right_keyword, right_count)| {
824
+ right_count
825
+ .cmp(left_count)
826
+ .then_with(|| left_keyword.cmp(right_keyword))
827
+ });
828
+ keywords
829
+ .into_iter()
830
+ .take(limit)
831
+ .map(|(keyword, _)| keyword.clone())
832
+ .collect()
833
+ }
834
+
835
+ fn add_keywords(text: &str, keywords: &mut BTreeMap<String, usize>) {
836
+ let lower = text.to_ascii_lowercase();
837
+ // Skip only the credential-bearing line; a password field elsewhere in a
838
+ // frame must not erase unrelated, safe topic evidence from the whole OCR row.
839
+ for line in lower.lines().take(64) {
840
+ if credential_shaped_text(line) {
841
+ continue;
842
+ }
843
+ for word in line.split(|character: char| !character.is_ascii_alphabetic()) {
844
+ if word.len() < 4 || word.len() > 32 || STOP_WORDS.contains(&word) {
845
+ continue;
846
+ }
847
+ *keywords.entry(word.to_owned()).or_default() += 1;
848
+ }
849
+ }
850
+ }
851
+
852
+ fn add_semantic_phrases(text: &str, keywords: &mut BTreeMap<String, usize>) {
853
+ let lower = text.to_ascii_lowercase();
854
+ for line in lower.lines().take(64) {
855
+ if credential_shaped_text(line) {
856
+ continue;
857
+ }
858
+ let words = line
859
+ .split(|character: char| !character.is_ascii_alphabetic())
860
+ .filter(|word| word.len() >= 4 && word.len() <= 24 && !STOP_WORDS.contains(word))
861
+ .take(12)
862
+ .collect::<Vec<_>>();
863
+ for size in [3usize, 2] {
864
+ for phrase in words.windows(size).take(8) {
865
+ if phrase.iter().all(|word| LABEL_GENERIC_WORDS.contains(word)) {
866
+ continue;
867
+ }
868
+ *keywords.entry(phrase.join(" ")).or_default() += 1;
869
+ }
870
+ }
871
+ }
872
+ }
873
+
874
+ fn credential_shaped_text(lower: &str) -> bool {
875
+ let marked = [
876
+ "password",
877
+ "secret",
878
+ "api key",
879
+ "api_key",
880
+ "access token",
881
+ "bearer",
882
+ ]
883
+ .iter()
884
+ .any(|marker| lower.contains(marker));
885
+ let email_shaped = lower.split_whitespace().any(|word| {
886
+ word.split_once('@')
887
+ .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
888
+ });
889
+ marked || email_shaped
890
+ }
891
+
892
+ const STOP_WORDS: &[&str] = &[
893
+ "about", "after", "again", "also", "been", "could", "from", "have", "into", "just", "like",
894
+ "more", "most", "only", "that", "their", "there", "these", "they", "this", "through", "using",
895
+ "were", "what", "when", "with", "would", "your",
896
+ ];
897
+
898
+ fn group_semantic_scenes(scenes: &[SceneItem]) -> Vec<Vec<SceneItem>> {
899
+ let mut groups: Vec<Vec<SceneItem>> = Vec::new();
900
+ for (index, scene) in scenes.iter().cloned().enumerate() {
901
+ let split = groups
902
+ .last()
903
+ .is_some_and(|group| should_split_semantically(group, &scene, &scenes[index + 1..]));
904
+ if split || groups.is_empty() {
905
+ groups.push(vec![scene]);
906
+ } else if let Some(group) = groups.last_mut() {
907
+ group.push(scene);
908
+ }
909
+ }
910
+
911
+ // A bounded audit or retained archive can begin in the middle of a task.
912
+ // When the first observed foreground context changes almost immediately,
913
+ // there is no earlier evidence with which to prove a genuine chapter
914
+ // boundary. Keep that open-edge sliver with the first durable chapter and
915
+ // let the next rebuild recover the original boundary if more history exists.
916
+ if groups.len() > 1 {
917
+ let first_duration_ms = groups[0]
918
+ .last()
919
+ .map(|last| (last.end - groups[0][0].start).num_milliseconds())
920
+ .unwrap_or_default();
921
+ let next_has_hard_boundary = groups[1]
922
+ .first()
923
+ .is_some_and(|scene| scene.scene.hard_boundary_before);
924
+ if first_duration_ms < SCENE_SLICE_MS && !next_has_hard_boundary {
925
+ let first = groups.remove(0);
926
+ groups[0].splice(0..0, first);
927
+ }
928
+ }
929
+ groups
930
+ }
931
+
932
+ fn should_split_semantically(
933
+ group: &[SceneItem],
934
+ next: &SceneItem,
935
+ following: &[SceneItem],
936
+ ) -> bool {
937
+ let previous = group.last().expect("nonempty semantic group");
938
+ if next.scene.hard_boundary_before {
939
+ return true;
940
+ }
941
+ if (next.end - group[0].start).num_milliseconds() > MAX_CHAPTER_SPAN_MS {
942
+ return true;
943
+ }
944
+
945
+ // Window titles and accessibility summaries can change every few seconds
946
+ // inside one app. Treat the foreground application as the stable surface;
947
+ // semantic evidence below remains responsible for finding topic changes
948
+ // within that surface.
949
+ let context_changed =
950
+ !same_foreground_context(previous.scene.context.as_ref(), next.scene.context.as_ref());
951
+ let current_group_ms = (previous.end - group[0].start).num_milliseconds();
952
+ if current_group_ms < SCENE_SLICE_MS && (!context_changed || previous.scene.context.is_none()) {
953
+ return false;
954
+ }
955
+ let previous_semantics = if context_changed {
956
+ group
957
+ .iter()
958
+ .rev()
959
+ .map(semantic_keywords)
960
+ .find(|keywords| !keywords.is_empty())
961
+ .unwrap_or_default()
962
+ } else {
963
+ let mut combined = BTreeMap::new();
964
+ for scene in group.iter().rev().take(8) {
965
+ merge_counts(&mut combined, &semantic_keywords(scene));
966
+ }
967
+ combined
968
+ };
969
+ let next_semantics = semantic_keywords(next);
970
+ if previous.scene.context.is_none() && previous_semantics.is_empty() {
971
+ return false;
972
+ }
973
+ let overlap = keyword_overlap(&previous_semantics, &next_semantics);
974
+
975
+ if context_changed && returns_after_transient_surface(group, next) {
976
+ return false;
977
+ }
978
+
979
+ if context_changed
980
+ && following.iter().any(|later| {
981
+ (later.start - next.start).num_milliseconds() <= TRANSIENT_SCENE_MS
982
+ && group.iter().rev().any(|earlier| {
983
+ same_foreground_context(
984
+ later.scene.context.as_ref(),
985
+ earlier.scene.context.as_ref(),
986
+ )
987
+ })
988
+ })
989
+ {
990
+ return false;
991
+ }
992
+
993
+ if (context_changed && strong_cross_surface_continuity(&previous_semantics, &next_semantics))
994
+ || (!context_changed && overlap >= SAME_SURFACE_CONTINUITY_OVERLAP)
995
+ {
996
+ return false;
997
+ }
998
+
999
+ if !context_changed
1000
+ && document_context_changed(previous.scene.context.as_ref(), next.scene.context.as_ref())
1001
+ && document_context_persists_for(next, following, MIN_SEMANTIC_TOPIC_MS)
1002
+ {
1003
+ return true;
1004
+ }
1005
+
1006
+ let persistent_new_context =
1007
+ context_changed && context_persists_for(next, following, TRANSIENT_SCENE_MS);
1008
+ if persistent_new_context {
1009
+ return true;
1010
+ }
1011
+
1012
+ if !context_changed && !previous_semantics.is_empty() && !next_semantics.is_empty() {
1013
+ return semantics_persist_for(next, following, MIN_SEMANTIC_TOPIC_MS);
1014
+ }
1015
+
1016
+ // A context seen only at the open edge of the retained/audited range is not
1017
+ // enough evidence for a new chapter. The next rebuild can split at the
1018
+ // original boundary once the foreground change has actually persisted.
1019
+ false
1020
+ }
1021
+
1022
+ fn same_foreground_context(left: Option<&ChapterContext>, right: Option<&ChapterContext>) -> bool {
1023
+ match (left, right) {
1024
+ (None, None) => true,
1025
+ (Some(left), Some(right)) => {
1026
+ if let (Some(left_bundle), Some(right_bundle)) =
1027
+ (left.bundle_id.as_deref(), right.bundle_id.as_deref())
1028
+ {
1029
+ return left_bundle.eq_ignore_ascii_case(right_bundle);
1030
+ }
1031
+ if let (Some(left_app), Some(right_app)) =
1032
+ (left.app_name.as_deref(), right.app_name.as_deref())
1033
+ {
1034
+ return left_app.eq_ignore_ascii_case(right_app);
1035
+ }
1036
+ left.window_title == right.window_title
1037
+ }
1038
+ _ => false,
1039
+ }
1040
+ }
1041
+
1042
+ fn document_context_key(context: Option<&ChapterContext>) -> Option<String> {
1043
+ let context = context?;
1044
+ context
1045
+ .document_url
1046
+ .as_deref()
1047
+ .or(context.document_title.as_deref())
1048
+ .or_else(|| {
1049
+ context
1050
+ .window_title
1051
+ .as_deref()
1052
+ .filter(|title| !generic_surface_title(title) && !transient_surface_title(title))
1053
+ })
1054
+ .map(|value| value.trim().to_ascii_lowercase())
1055
+ .filter(|value| !value.is_empty())
1056
+ }
1057
+
1058
+ fn document_context_changed(
1059
+ previous: Option<&ChapterContext>,
1060
+ next: Option<&ChapterContext>,
1061
+ ) -> bool {
1062
+ match (document_context_key(previous), document_context_key(next)) {
1063
+ (Some(previous), Some(next)) => previous != next,
1064
+ _ => false,
1065
+ }
1066
+ }
1067
+
1068
+ fn document_context_persists_for(
1069
+ next: &SceneItem,
1070
+ following: &[SceneItem],
1071
+ minimum_ms: i64,
1072
+ ) -> bool {
1073
+ let Some(target) = document_context_key(next.scene.context.as_ref()) else {
1074
+ return false;
1075
+ };
1076
+ let mut covered_until = next.end;
1077
+ if (covered_until - next.start).num_milliseconds() >= minimum_ms {
1078
+ return true;
1079
+ }
1080
+ for later in following {
1081
+ if later.scene.hard_boundary_before
1082
+ || document_context_key(later.scene.context.as_ref()).as_deref()
1083
+ != Some(target.as_str())
1084
+ {
1085
+ break;
1086
+ }
1087
+ covered_until = later.end;
1088
+ if (covered_until - next.start).num_milliseconds() >= minimum_ms {
1089
+ return true;
1090
+ }
1091
+ }
1092
+ false
1093
+ }
1094
+
1095
+ fn returns_after_transient_surface(group: &[SceneItem], next: &SceneItem) -> bool {
1096
+ let Some(trailing) = group.last() else {
1097
+ return false;
1098
+ };
1099
+ let trailing_start = group
1100
+ .iter()
1101
+ .rposition(|scene| {
1102
+ !same_foreground_context(
1103
+ scene.scene.context.as_ref(),
1104
+ trailing.scene.context.as_ref(),
1105
+ )
1106
+ })
1107
+ .map_or(0, |index| index + 1);
1108
+ if trailing_start == 0
1109
+ || (trailing.end - group[trailing_start].start).num_milliseconds() > TRANSIENT_SCENE_MS
1110
+ {
1111
+ return false;
1112
+ }
1113
+ group[..trailing_start].iter().rev().any(|scene| {
1114
+ same_foreground_context(scene.scene.context.as_ref(), next.scene.context.as_ref())
1115
+ })
1116
+ }
1117
+
1118
+ fn context_persists_for(next: &SceneItem, following: &[SceneItem], minimum_ms: i64) -> bool {
1119
+ let mut covered_until = next.end;
1120
+ if (covered_until - next.start).num_milliseconds() >= minimum_ms {
1121
+ return true;
1122
+ }
1123
+ for later in following {
1124
+ if later.scene.hard_boundary_before
1125
+ || !same_foreground_context(later.scene.context.as_ref(), next.scene.context.as_ref())
1126
+ {
1127
+ break;
1128
+ }
1129
+ covered_until = later.end;
1130
+ if (covered_until - next.start).num_milliseconds() >= minimum_ms {
1131
+ return true;
1132
+ }
1133
+ }
1134
+ false
1135
+ }
1136
+
1137
+ fn semantics_persist_for(next: &SceneItem, following: &[SceneItem], minimum_ms: i64) -> bool {
1138
+ let target = semantic_keywords(next);
1139
+ let mut covered_until = next.end;
1140
+ if (covered_until - next.start).num_milliseconds() >= minimum_ms {
1141
+ return true;
1142
+ }
1143
+ for later in following {
1144
+ if later.scene.hard_boundary_before
1145
+ || !same_foreground_context(later.scene.context.as_ref(), next.scene.context.as_ref())
1146
+ || keyword_overlap(&target, &semantic_keywords(later)) < SAME_SURFACE_CONTINUITY_OVERLAP
1147
+ {
1148
+ break;
1149
+ }
1150
+ covered_until = later.end;
1151
+ if (covered_until - next.start).num_milliseconds() >= minimum_ms {
1152
+ return true;
1153
+ }
1154
+ }
1155
+ false
1156
+ }
1157
+
1158
+ fn semantic_keywords(scene: &SceneItem) -> BTreeMap<String, usize> {
1159
+ if !scene.visual_keywords.is_empty() {
1160
+ let mut keywords = scene.visual_keywords.clone();
1161
+ for keyword in scene.system_audio_keywords.keys() {
1162
+ if keywords.contains_key(keyword) {
1163
+ *keywords.entry(keyword.clone()).or_default() += 1;
1164
+ }
1165
+ }
1166
+ return keywords;
1167
+ }
1168
+ if !scene.system_audio_keywords.is_empty() {
1169
+ return scene.system_audio_keywords.clone();
1170
+ }
1171
+ scene.microphone_keywords.clone()
1172
+ }
1173
+
1174
+ fn keyword_overlap(left: &BTreeMap<String, usize>, right: &BTreeMap<String, usize>) -> f32 {
1175
+ if left.is_empty() || right.is_empty() {
1176
+ return 0.0;
1177
+ }
1178
+ let left = semantic_word_set(left);
1179
+ let right = semantic_word_set(right);
1180
+ if left.is_empty() || right.is_empty() {
1181
+ return 0.0;
1182
+ }
1183
+ let intersection = left.intersection(&right).count();
1184
+ intersection as f32 / left.len().min(right.len()) as f32
1185
+ }
1186
+
1187
+ fn strong_cross_surface_continuity(
1188
+ left: &BTreeMap<String, usize>,
1189
+ right: &BTreeMap<String, usize>,
1190
+ ) -> bool {
1191
+ let left = semantic_word_set(left);
1192
+ let right = semantic_word_set(right);
1193
+ if left.is_empty() || right.is_empty() {
1194
+ return false;
1195
+ }
1196
+ let shared = left.intersection(&right).count();
1197
+ let union = left.union(&right).count();
1198
+ shared >= 2 && shared as f32 / union.max(1) as f32 >= 0.60
1199
+ }
1200
+
1201
+ fn semantic_word_set(keywords: &BTreeMap<String, usize>) -> BTreeSet<String> {
1202
+ keywords
1203
+ .keys()
1204
+ .flat_map(|keyword| keyword.split_whitespace())
1205
+ .filter(|word| !generic_label_word(word) && plausible_label_word(word))
1206
+ .map(str::to_owned)
1207
+ .collect()
1208
+ }
1209
+
1210
+ fn chapter(group: Vec<SceneItem>) -> RewindChapter {
1211
+ let first = &group[0];
1212
+ let last = group.last().unwrap();
1213
+ let mut contexts = group
1214
+ .iter()
1215
+ .filter_map(|item| item.scene.context.clone())
1216
+ .collect::<Vec<_>>();
1217
+ contexts.sort_by(|a, b| format!("{a:?}").cmp(&format!("{b:?}")));
1218
+ contexts.dedup();
1219
+ let partial = group.iter().any(|item| item.partial);
1220
+ let mut evidence_refs = Vec::new();
1221
+ let mut visual_keywords = BTreeMap::new();
1222
+ let mut system_audio_keywords = BTreeMap::new();
1223
+ let mut microphone_keywords = BTreeMap::new();
1224
+ for item in &group {
1225
+ merge_counts(&mut visual_keywords, &item.visual_keywords);
1226
+ merge_counts(&mut system_audio_keywords, &item.system_audio_keywords);
1227
+ merge_counts(&mut microphone_keywords, &item.microphone_keywords);
1228
+ evidence_refs.extend(item.scene.evidence_refs.clone());
1229
+ }
1230
+ let mut label_keywords = visual_keywords.clone();
1231
+ merge_counts(&mut label_keywords, &system_audio_keywords);
1232
+ if label_keywords.is_empty() {
1233
+ label_keywords = microphone_keywords.clone();
1234
+ }
1235
+ let keywords = top_keywords(&label_keywords, MAX_KEYWORDS);
1236
+ let label_terms = top_label_keywords(&label_keywords, 3);
1237
+ let label = choose_label(&group, &contexts, &label_terms);
1238
+ let scene_refs = group
1239
+ .iter()
1240
+ .map(|item| item.scene.id.clone())
1241
+ .collect::<Vec<_>>();
1242
+ let mut segment_refs = group
1243
+ .iter()
1244
+ .flat_map(|item| item.scene.segment_refs.clone())
1245
+ .collect::<Vec<_>>();
1246
+ segment_refs.sort_by(|a, b| {
1247
+ a.started_at
1248
+ .cmp(&b.started_at)
1249
+ .then_with(|| a.id.cmp(&b.id))
1250
+ });
1251
+ segment_refs.dedup_by(|a, b| a.id == b.id);
1252
+ let representative_moments = representative_moments(&group);
1253
+ let representative_coverage = RepresentativeCoverage {
1254
+ covered_scenes: representative_moments.len(),
1255
+ total_scenes: group.len(),
1256
+ truncated: representative_moments.len() < group.len(),
1257
+ };
1258
+ let confidence = if !visual_keywords.is_empty() {
1259
+ 0.85
1260
+ } else if !system_audio_keywords.is_empty() {
1261
+ 0.65
1262
+ } else if !contexts.is_empty() {
1263
+ 0.45
1264
+ } else {
1265
+ 0.3
1266
+ };
1267
+ RewindChapter {
1268
+ id: format!("chapter-{}", first.scene.id),
1269
+ revision: 1,
1270
+ aliases: Vec::new(),
1271
+ started_at: first.scene.started_at.clone(),
1272
+ ended_at: last.scene.ended_at.clone(),
1273
+ duration_ms: (last.end - first.start).num_milliseconds(),
1274
+ summary: if keywords.is_empty() {
1275
+ format!("{label}; local semantic evidence is sparse.")
1276
+ } else {
1277
+ format!(
1278
+ "{label}; corroborated local evidence mentions {}.",
1279
+ keywords
1280
+ .iter()
1281
+ .take(5)
1282
+ .cloned()
1283
+ .collect::<Vec<_>>()
1284
+ .join(", ")
1285
+ )
1286
+ },
1287
+ keywords,
1288
+ label,
1289
+ confidence,
1290
+ scene_refs,
1291
+ segment_refs,
1292
+ evidence_refs: bounded_evidence_refs(evidence_refs),
1293
+ contexts,
1294
+ representative_moments,
1295
+ representative_coverage,
1296
+ ambiguity_reasons: if partial {
1297
+ vec!["local indexing is still catching up".into()]
1298
+ } else if visual_keywords.is_empty() && system_audio_keywords.is_empty() {
1299
+ vec!["no corroborated visual, accessibility, or system-audio topic was retained".into()]
1300
+ } else {
1301
+ vec![]
1302
+ },
1303
+ index_state: if partial {
1304
+ ChapterIndexState::Partial
1305
+ } else {
1306
+ ChapterIndexState::Ready
1307
+ },
1308
+ }
1309
+ }
1310
+
1311
+ fn choose_label(group: &[SceneItem], contexts: &[ChapterContext], keywords: &[String]) -> String {
1312
+ let mut title_duration = BTreeMap::<String, i64>::new();
1313
+ for scene in group {
1314
+ let Some(title) = scene
1315
+ .scene
1316
+ .context
1317
+ .as_ref()
1318
+ .and_then(|context| context.window_title.as_deref())
1319
+ else {
1320
+ continue;
1321
+ };
1322
+ let repeats_app_name = scene
1323
+ .scene
1324
+ .context
1325
+ .as_ref()
1326
+ .and_then(|context| context.app_name.as_deref())
1327
+ .is_some_and(|app| app.trim().eq_ignore_ascii_case(title.trim()));
1328
+ if !generic_surface_title(title) && !transient_surface_title(title) && !repeats_app_name {
1329
+ *title_duration.entry(title.trim().to_owned()).or_default() += scene.scene.duration_ms;
1330
+ }
1331
+ }
1332
+ if let Some((title, _)) = title_duration
1333
+ .into_iter()
1334
+ .filter(|(title, duration)| *duration >= SCENE_SLICE_MS && title.len() <= 96)
1335
+ .max_by_key(|(_, duration)| *duration)
1336
+ {
1337
+ return title;
1338
+ }
1339
+ if !keywords.is_empty() {
1340
+ let displayed = if keywords[0].contains(' ') {
1341
+ &keywords[..1]
1342
+ } else {
1343
+ &keywords[..keywords.len().min(3)]
1344
+ };
1345
+ return format!("Working on {}", natural_keyword_list(displayed));
1346
+ }
1347
+ contexts
1348
+ .iter()
1349
+ .find_map(|context| context.app_name.clone())
1350
+ .map(|app| format!("Work in {app} (low confidence)"))
1351
+ .unwrap_or_else(|| "Recent work (low confidence)".into())
1352
+ }
1353
+
1354
+ fn top_label_keywords(counts: &BTreeMap<String, usize>, limit: usize) -> Vec<String> {
1355
+ let weighted = counts
1356
+ .iter()
1357
+ .filter(|(keyword, count)| {
1358
+ let words = keyword.split_whitespace().collect::<Vec<_>>();
1359
+ !words.iter().any(|word| generic_label_word(word))
1360
+ && words.iter().all(|word| plausible_label_word(word))
1361
+ && (words.len() == 1 || **count >= 2)
1362
+ })
1363
+ .map(|(keyword, count)| {
1364
+ // Repeated clean phrases are more human than isolated words, but a
1365
+ // fixed bonus prevents long OCR fragments from winning by length.
1366
+ let words = keyword.split_whitespace().count();
1367
+ let phrase_weight = if words > 1 { words + 2 } else { 1 };
1368
+ (keyword.clone(), count.saturating_mul(phrase_weight))
1369
+ })
1370
+ .collect::<BTreeMap<_, _>>();
1371
+ let ranked = top_keywords(&weighted, weighted.len());
1372
+ let mut selected = Vec::<String>::new();
1373
+ for candidate in ranked {
1374
+ let candidate_words = candidate.split_whitespace().collect::<BTreeSet<_>>();
1375
+ let duplicates_selected_meaning = selected.iter().any(|selected| {
1376
+ let selected_words = selected.split_whitespace().collect::<BTreeSet<_>>();
1377
+ let shared = candidate_words.intersection(&selected_words).count();
1378
+ shared > 0 && shared * 4 >= candidate_words.len().min(selected_words.len()) * 3
1379
+ });
1380
+ if !duplicates_selected_meaning {
1381
+ selected.push(candidate);
1382
+ if selected.len() == limit {
1383
+ break;
1384
+ }
1385
+ }
1386
+ }
1387
+ selected
1388
+ }
1389
+
1390
+ fn generic_label_word(word: &str) -> bool {
1391
+ if word.starts_with("ax") {
1392
+ return true;
1393
+ }
1394
+ LABEL_GENERIC_WORDS.iter().any(|generic| {
1395
+ word == *generic
1396
+ || (word.len() == generic.len()
1397
+ && word
1398
+ .chars()
1399
+ .zip(generic.chars())
1400
+ .filter(|(left, right)| left != right)
1401
+ .count()
1402
+ <= 1)
1403
+ })
1404
+ }
1405
+
1406
+ fn plausible_label_word(word: &str) -> bool {
1407
+ if word.len() <= 5 {
1408
+ return true;
1409
+ }
1410
+ let mut vowels = 0usize;
1411
+ let mut consonant_run = 0usize;
1412
+ let mut longest_consonant_run = 0usize;
1413
+ for character in word.chars() {
1414
+ if matches!(character, 'a' | 'e' | 'i' | 'o' | 'u') {
1415
+ vowels += 1;
1416
+ consonant_run = 0;
1417
+ } else {
1418
+ consonant_run += 1;
1419
+ longest_consonant_run = longest_consonant_run.max(consonant_run);
1420
+ }
1421
+ }
1422
+ vowels > 0 && longest_consonant_run <= 4
1423
+ }
1424
+
1425
+ const LABEL_GENERIC_WORDS: &[&str] = &[
1426
+ "agent",
1427
+ "native",
1428
+ "content",
1429
+ "work",
1430
+ "working",
1431
+ "created",
1432
+ "private",
1433
+ "document",
1434
+ "start",
1435
+ "open",
1436
+ "review",
1437
+ "changes",
1438
+ "should",
1439
+ "current",
1440
+ "system",
1441
+ "local",
1442
+ "chatgpt",
1443
+ "codex",
1444
+ "claude",
1445
+ "cursor",
1446
+ "browser",
1447
+ "terminal",
1448
+ "safari",
1449
+ "finder",
1450
+ "axwindow",
1451
+ "axwebarea",
1452
+ "axapplication",
1453
+ ];
1454
+
1455
+ fn generic_surface_title(title: &str) -> bool {
1456
+ let normalized = title.trim().to_ascii_lowercase();
1457
+ normalized.is_empty()
1458
+ || [
1459
+ "chatgpt",
1460
+ "codex",
1461
+ "claude",
1462
+ "claude code",
1463
+ "cursor",
1464
+ "browser",
1465
+ "terminal",
1466
+ "google chrome",
1467
+ "safari",
1468
+ "mail",
1469
+ "finder",
1470
+ ]
1471
+ .contains(&normalized.as_str())
1472
+ }
1473
+
1474
+ fn transient_surface_title(title: &str) -> bool {
1475
+ let normalized = title.trim().to_ascii_lowercase();
1476
+ normalized.contains("save password")
1477
+ || normalized.contains("password manager")
1478
+ || normalized == "new tab"
1479
+ || normalized == "downloads"
1480
+ || normalized.ends_with(" notification")
1481
+ }
1482
+
1483
+ fn natural_keyword_list(keywords: &[String]) -> String {
1484
+ match keywords {
1485
+ [] => "recent work".into(),
1486
+ [one] => one.clone(),
1487
+ [one, two] => format!("{one} and {two}"),
1488
+ [one, two, rest @ ..] => format!("{one}, {two}, and {}", rest[0]),
1489
+ }
1490
+ }
1491
+
1492
+ fn representative_moments(group: &[SceneItem]) -> Vec<RepresentativeMoment> {
1493
+ let limit = group.len().min(MAX_REPRESENTATIVE_MOMENTS);
1494
+ if limit == 0 {
1495
+ return Vec::new();
1496
+ }
1497
+ let selected = if group.len() <= limit {
1498
+ (0..group.len()).collect::<Vec<_>>()
1499
+ } else {
1500
+ (0..limit)
1501
+ .map(|index| index * (group.len() - 1) / (limit - 1))
1502
+ .collect::<Vec<_>>()
1503
+ };
1504
+ selected
1505
+ .into_iter()
1506
+ .filter_map(|index| {
1507
+ let item = &group[index];
1508
+ let segment = item.scene.segment_refs.first()?;
1509
+ let segment_start = parse_utc(&segment.started_at)?;
1510
+ Some(RepresentativeMoment {
1511
+ moment_id: format!("moment-{}", item.scene.id),
1512
+ captured_at: item.scene.started_at.clone(),
1513
+ segment_id: segment.id.clone(),
1514
+ offset_ms: (item.start - segment_start).num_milliseconds().max(0),
1515
+ reason: if index == 0 {
1516
+ "chapter start"
1517
+ } else if index + 1 == group.len() {
1518
+ "chapter end"
1519
+ } else {
1520
+ "semantic scene change"
1521
+ }
1522
+ .into(),
1523
+ })
1524
+ })
1525
+ .collect()
1526
+ }
1527
+
1528
+ fn apply_previous_identity(
1529
+ chapters: &mut [RewindChapter],
1530
+ previous: Option<&RewindChaptersManifest>,
1531
+ ) {
1532
+ let Some(previous) = previous else {
1533
+ return;
1534
+ };
1535
+ for chapter in chapters {
1536
+ if let Some(prior) = previous
1537
+ .chapters
1538
+ .iter()
1539
+ .find(|prior| prior.id == chapter.id)
1540
+ {
1541
+ chapter.aliases = prior.aliases.clone();
1542
+ chapter.revision = if chapter_semantics_equal(prior, chapter) {
1543
+ prior.revision
1544
+ } else {
1545
+ prior.revision.saturating_add(1)
1546
+ };
1547
+ }
1548
+ for prior in &previous.chapters {
1549
+ let Some(first_scene) = prior.scene_refs.first() else {
1550
+ continue;
1551
+ };
1552
+ if prior.id != chapter.id
1553
+ && chapter.scene_refs.contains(first_scene)
1554
+ && !chapter.aliases.contains(&prior.id)
1555
+ {
1556
+ chapter.aliases.push(prior.id.clone());
1557
+ }
1558
+ }
1559
+ chapter.aliases.sort();
1560
+ chapter.aliases.dedup();
1561
+ }
1562
+ }
1563
+
1564
+ fn chapter_semantics_equal(previous: &RewindChapter, current: &RewindChapter) -> bool {
1565
+ previous.started_at == current.started_at
1566
+ && previous.ended_at == current.ended_at
1567
+ && previous.scene_refs == current.scene_refs
1568
+ && previous.label == current.label
1569
+ && previous.summary == current.summary
1570
+ && previous.keywords == current.keywords
1571
+ && previous.ambiguity_reasons == current.ambiguity_reasons
1572
+ && previous.representative_moments.len() == current.representative_moments.len()
1573
+ }
1574
+ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
1575
+ let temporary = path.with_extension("json.tmp");
1576
+ fs::write(&temporary, bytes).map_err(|e| format!("chapter manifest write failed: {e}"))?;
1577
+ fs::rename(temporary, path).map_err(|e| format!("chapter manifest replace failed: {e}"))
1578
+ }
1579
+
1580
+ #[cfg(test)]
1581
+ mod tests {
1582
+ use super::*;
1583
+ use std::path::PathBuf;
1584
+ fn segment(id: &str, start: &str, end: &str) -> ScreenMemorySegmentMetadata {
1585
+ ScreenMemorySegmentMetadata {
1586
+ id: id.into(),
1587
+ path: PathBuf::from("/private/media.mp4"),
1588
+ file_name: "media.mp4".into(),
1589
+ mime_type: "video/mp4".into(),
1590
+ started_at: start.into(),
1591
+ ended_at: end.into(),
1592
+ duration_ms: 60_000,
1593
+ width: None,
1594
+ height: None,
1595
+ bytes: 1,
1596
+ system_audio_path: None,
1597
+ microphone_path: None,
1598
+ corrupt: false,
1599
+ error: None,
1600
+ capture_mode: RewindCaptureMode::Visuals,
1601
+ exclusion_tainted: false,
1602
+ graph_epoch_id: Some("a".into()),
1603
+ graph_started_elapsed_ms: 0,
1604
+ graph_ended_elapsed_ms: 60_000,
1605
+ }
1606
+ }
1607
+ fn event(at: &str, app: &str) -> ScreenMemoryEvent {
1608
+ ScreenMemoryEvent {
1609
+ captured_at: at.into(),
1610
+ app_name: Some(app.into()),
1611
+ window_title: Some(app.into()),
1612
+ bundle_id: None,
1613
+ source: "foreground".into(),
1614
+ coverage_gap_reason: None,
1615
+ accessibility: None,
1616
+ }
1617
+ }
1618
+ fn evidence(
1619
+ segment_id: &str,
1620
+ source_type: &str,
1621
+ source_kind: &str,
1622
+ captured_at: &str,
1623
+ words: &[&str],
1624
+ ) -> EvidenceRef {
1625
+ EvidenceRef {
1626
+ source_type: source_type.into(),
1627
+ source_kind: Some(source_kind.into()),
1628
+ keywords: words.iter().map(|word| (*word).into()).collect(),
1629
+ confidence: Some(0.9),
1630
+ segment_id: Some(segment_id.into()),
1631
+ offset_ms: None,
1632
+ captured_at: Some(captured_at.into()),
1633
+ }
1634
+ }
1635
+ fn signal_with(references: Vec<EvidenceRef>) -> ContentSignal {
1636
+ let mut signal = ContentSignal::default();
1637
+ for reference in references {
1638
+ let target = match reference.source_kind.as_deref() {
1639
+ Some("visual" | "accessibility") => &mut signal.visual_keywords,
1640
+ Some("microphone") => &mut signal.microphone_keywords,
1641
+ _ => &mut signal.system_audio_keywords,
1642
+ };
1643
+ add_keyword_list(&reference.keywords, target);
1644
+ signal.evidence_refs.push(reference);
1645
+ }
1646
+ signal
1647
+ }
1648
+ #[test]
1649
+ fn same_context_merges_and_serialization_has_no_paths() {
1650
+ let value = build(
1651
+ vec![
1652
+ segment("b", "2026-01-01T00:01:00Z", "2026-01-01T00:02:00Z"),
1653
+ segment("a", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z"),
1654
+ ],
1655
+ vec![event("2026-01-01T00:00:30Z", "Editor")],
1656
+ Utc::now(),
1657
+ );
1658
+ assert_eq!(value.chapters.len(), 1, "{:#?}", value.chapters);
1659
+ assert!(!String::from_utf8(serde_json::to_vec(&value).unwrap())
1660
+ .unwrap()
1661
+ .contains("/private/"));
1662
+ }
1663
+ #[test]
1664
+ fn persistent_context_splits_but_short_switch_does_not() {
1665
+ let a = segment("a", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
1666
+ let b = segment("b", "2026-01-01T00:01:00Z", "2026-01-01T00:02:00Z");
1667
+ let c = segment("c", "2026-01-01T00:02:00Z", "2026-01-01T00:03:00Z");
1668
+ assert_eq!(
1669
+ build(
1670
+ vec![a.clone(), b.clone(), c.clone()],
1671
+ vec![
1672
+ event("2026-01-01T00:00:30Z", "A"),
1673
+ event("2026-01-01T00:01:30Z", "B"),
1674
+ event("2026-01-01T00:02:30Z", "B")
1675
+ ],
1676
+ Utc::now()
1677
+ )
1678
+ .chapters
1679
+ .len(),
1680
+ 2
1681
+ );
1682
+ assert_eq!(
1683
+ build(
1684
+ vec![a, b, c],
1685
+ vec![
1686
+ event("2026-01-01T00:00:30Z", "A"),
1687
+ event("2026-01-01T00:01:30Z", "B"),
1688
+ event("2026-01-01T00:02:30Z", "A")
1689
+ ],
1690
+ Utc::now()
1691
+ )
1692
+ .chapters
1693
+ .len(),
1694
+ 1
1695
+ );
1696
+ }
1697
+
1698
+ #[test]
1699
+ fn multiple_short_foreground_interruptions_do_not_split_the_underlying_task() {
1700
+ let segments = vec![
1701
+ segment("before", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z"),
1702
+ segment("maps", "2026-01-01T00:01:00Z", "2026-01-01T00:01:30Z"),
1703
+ segment("message", "2026-01-01T00:01:30Z", "2026-01-01T00:02:00Z"),
1704
+ segment("after", "2026-01-01T00:02:00Z", "2026-01-01T00:03:00Z"),
1705
+ ];
1706
+ let content = BTreeMap::from([
1707
+ (
1708
+ "before".into(),
1709
+ signal_with(vec![evidence(
1710
+ "before",
1711
+ "ocr",
1712
+ "visual",
1713
+ "2026-01-01T00:00:10Z",
1714
+ &["capability", "catalog"],
1715
+ )]),
1716
+ ),
1717
+ (
1718
+ "maps".into(),
1719
+ signal_with(vec![evidence(
1720
+ "maps",
1721
+ "ocr",
1722
+ "visual",
1723
+ "2026-01-01T00:01:05Z",
1724
+ &["franklin", "route"],
1725
+ )]),
1726
+ ),
1727
+ (
1728
+ "message".into(),
1729
+ signal_with(vec![evidence(
1730
+ "message",
1731
+ "ocr",
1732
+ "visual",
1733
+ "2026-01-01T00:01:35Z",
1734
+ &["compose", "message"],
1735
+ )]),
1736
+ ),
1737
+ (
1738
+ "after".into(),
1739
+ signal_with(vec![evidence(
1740
+ "after",
1741
+ "ocr",
1742
+ "visual",
1743
+ "2026-01-01T00:02:05Z",
1744
+ &["capability", "catalog"],
1745
+ )]),
1746
+ ),
1747
+ ]);
1748
+ let manifest = build_with_pending(
1749
+ segments,
1750
+ vec![
1751
+ event("2026-01-01T00:00:01Z", "Browser"),
1752
+ event("2026-01-01T00:01:01Z", "Maps"),
1753
+ event("2026-01-01T00:01:31Z", "Messages"),
1754
+ event("2026-01-01T00:02:01Z", "Browser"),
1755
+ ],
1756
+ BTreeSet::new(),
1757
+ content,
1758
+ Utc::now(),
1759
+ );
1760
+
1761
+ assert_eq!(manifest.chapters.len(), 1, "{:#?}", manifest.chapters);
1762
+ }
1763
+
1764
+ #[test]
1765
+ fn window_title_churn_inside_one_foreground_app_does_not_make_micro_chapters() {
1766
+ let mut long = segment("maps", "2026-01-01T00:00:00Z", "2026-01-01T00:03:00Z");
1767
+ long.duration_ms = 180_000;
1768
+ let mut first = event("2026-01-01T00:00:05Z", "Safari");
1769
+ first.bundle_id = Some("com.apple.Safari".into());
1770
+ first.window_title = Some("Franklin - Google Maps".into());
1771
+ let mut second = event("2026-01-01T00:00:15Z", "Safari");
1772
+ second.bundle_id = Some("com.apple.Safari".into());
1773
+ second.window_title = Some("Directions to Franklin - Google Maps".into());
1774
+ let mut third = event("2026-01-01T00:00:25Z", "Safari");
1775
+ third.bundle_id = Some("com.apple.Safari".into());
1776
+ third.window_title = Some("Fishers to Franklin - Google Maps".into());
1777
+
1778
+ let manifest = build(vec![long], vec![first, second, third], Utc::now());
1779
+
1780
+ assert_eq!(manifest.chapters.len(), 1, "{:#?}", manifest.chapters);
1781
+ }
1782
+
1783
+ #[test]
1784
+ fn first_observed_context_does_not_leave_a_tiny_open_edge_chapter() {
1785
+ let mut long = segment("edge", "2026-01-01T00:00:00Z", "2026-01-01T00:03:00Z");
1786
+ long.duration_ms = 180_000;
1787
+
1788
+ let manifest = build(
1789
+ vec![long],
1790
+ vec![event("2026-01-01T00:00:05Z", "Editor")],
1791
+ Utc::now(),
1792
+ );
1793
+
1794
+ assert_eq!(manifest.chapters.len(), 1, "{:#?}", manifest.chapters);
1795
+ }
1796
+
1797
+ #[test]
1798
+ fn bounded_audit_does_not_leave_a_tiny_first_context_chapter() {
1799
+ let mut long = segment("edge", "2026-01-01T00:00:00Z", "2026-01-01T00:03:00Z");
1800
+ long.duration_ms = 180_000;
1801
+
1802
+ let manifest = build(
1803
+ vec![long],
1804
+ vec![
1805
+ event("2026-01-01T00:00:01Z", "Browser"),
1806
+ event("2026-01-01T00:00:08Z", "Messages"),
1807
+ event("2026-01-01T00:00:24Z", "Browser"),
1808
+ ],
1809
+ Utc::now(),
1810
+ );
1811
+
1812
+ assert_eq!(manifest.chapters.len(), 1, "{:#?}", manifest.chapters);
1813
+ }
1814
+ #[test]
1815
+ fn gaps_epochs_modes_and_tainted_segments_do_not_bridge() {
1816
+ let a = segment("a", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
1817
+ let mut b = segment("b", "2026-01-01T00:03:00Z", "2026-01-01T00:04:00Z");
1818
+ b.graph_epoch_id = Some("b".into());
1819
+ let mut bad = segment("bad", "2026-01-01T00:04:00Z", "2026-01-01T00:05:00Z");
1820
+ bad.exclusion_tainted = true;
1821
+ let v = build(vec![a, b, bad], vec![], Utc::now());
1822
+ assert_eq!(v.chapters.len(), 2);
1823
+ assert_eq!(v.coverage.omitted_segments, 1);
1824
+ }
1825
+
1826
+ #[test]
1827
+ fn pending_indexes_are_partial_and_retention_rebuild_removes_references() {
1828
+ let a = segment("a", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
1829
+ let b = segment("b", "2026-01-01T00:01:00Z", "2026-01-01T00:02:00Z");
1830
+ let mut pending = BTreeSet::new();
1831
+ pending.insert("a".into());
1832
+ let partial = build_with_pending(
1833
+ vec![a.clone(), b.clone()],
1834
+ vec![],
1835
+ pending,
1836
+ BTreeMap::new(),
1837
+ Utc::now(),
1838
+ );
1839
+ assert_eq!(partial.state, ChapterIndexState::Partial);
1840
+ let retained = build(vec![a], vec![], Utc::now());
1841
+ assert!(retained.chapters.iter().all(|chapter| chapter
1842
+ .segment_refs
1843
+ .iter()
1844
+ .all(|reference| reference.id != "b")));
1845
+ }
1846
+
1847
+ #[test]
1848
+ fn bounded_content_keywords_are_searchable_without_raw_sidecar_bodies() {
1849
+ let segment = segment("a", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
1850
+ let mut signal = ContentSignal::default();
1851
+ add_keywords(
1852
+ "Roadmap launch planning for the customer workshop.",
1853
+ &mut signal.system_audio_keywords,
1854
+ );
1855
+ add_keywords(
1856
+ "password=ultra-secret-squirrel should never be retained",
1857
+ &mut signal.system_audio_keywords,
1858
+ );
1859
+ signal.evidence_refs.push(EvidenceRef {
1860
+ source_type: "transcript".into(),
1861
+ source_kind: Some("system-audio".into()),
1862
+ keywords: vec!["roadmap".into(), "workshop".into()],
1863
+ confidence: None,
1864
+ segment_id: Some("a".into()),
1865
+ offset_ms: Some(500),
1866
+ captured_at: Some("2026-01-01T00:00:00.500Z".into()),
1867
+ });
1868
+ let mut content = BTreeMap::new();
1869
+ content.insert("a".into(), signal);
1870
+ let manifest =
1871
+ build_with_pending(vec![segment], vec![], BTreeSet::new(), content, Utc::now());
1872
+ let serialized = String::from_utf8(serde_json::to_vec(&manifest).unwrap()).unwrap();
1873
+ assert!(serialized.contains("roadmap"));
1874
+ assert!(serialized.contains("workshop"));
1875
+ assert!(serialized.contains("\"sourceType\":\"transcript\""));
1876
+ assert!(!serialized.contains("ultra-secret-squirrel"));
1877
+ assert!(!serialized.contains("password="));
1878
+ assert!(!serialized.contains("Roadmap launch planning for the customer workshop."));
1879
+
1880
+ let mut mixed = BTreeMap::new();
1881
+ add_keywords(
1882
+ "Semantic chapter evaluation\nalice@example.test\nPassword: example-value",
1883
+ &mut mixed,
1884
+ );
1885
+ assert!(mixed.contains_key("semantic") && mixed.contains_key("chapter"));
1886
+ assert!(!mixed.contains_key("alice") && !mixed.contains_key("password"));
1887
+ }
1888
+
1889
+ #[test]
1890
+ fn chapter_labels_do_not_repeat_the_same_word_as_three_phrases() {
1891
+ let counts = BTreeMap::from([
1892
+ ("account".into(), 10),
1893
+ ("account passed".into(), 9),
1894
+ ("account passed provisioning".into(), 8),
1895
+ ("deploy".into(), 7),
1896
+ ("preview".into(), 6),
1897
+ ]);
1898
+
1899
+ assert_eq!(
1900
+ top_label_keywords(&counts, 3),
1901
+ vec!["account passed provisioning", "deploy", "preview"]
1902
+ );
1903
+ }
1904
+
1905
+ #[test]
1906
+ fn accessibility_roles_and_ocr_garble_do_not_become_labels() {
1907
+ let mut context = event("2026-01-01T00:00:00Z", "WhatsApp");
1908
+ context.accessibility = Some(crate::accessibility::AccessibilityFingerprint {
1909
+ document: Some(crate::accessibility::AccessibilitySemanticNode {
1910
+ role: "AXWindow".into(),
1911
+ title: Some("WhatsApp".into()),
1912
+ description: None,
1913
+ document: None,
1914
+ url: None,
1915
+ selected_text: None,
1916
+ }),
1917
+ focused: Some(crate::accessibility::AccessibilitySemanticNode {
1918
+ role: "AXTextArea".into(),
1919
+ title: None,
1920
+ description: Some("Compose message".into()),
1921
+ document: None,
1922
+ url: None,
1923
+ selected_text: None,
1924
+ }),
1925
+ visible_labels: vec![],
1926
+ });
1927
+ let keywords = accessibility_keywords(&context);
1928
+ assert!(!keywords.contains_key("axwindow"));
1929
+ assert!(!keywords.contains_key("axtextarea"));
1930
+ assert!(keywords.contains_key("compose"));
1931
+ assert_eq!(
1932
+ top_label_keywords(
1933
+ &BTreeMap::from([
1934
+ ("axtextarea".into(), 20),
1935
+ ("netltfy".into(), 18),
1936
+ ("capability catalog".into(), 3),
1937
+ ("catalog".into(), 8),
1938
+ ]),
1939
+ 2,
1940
+ ),
1941
+ vec!["capability catalog"]
1942
+ );
1943
+ }
1944
+
1945
+ #[test]
1946
+ fn sustained_same_app_semantic_change_splits_fixed_scenes() {
1947
+ let origin = parse_utc("2026-01-01T00:00:00Z").unwrap();
1948
+ let mut long = segment("same-app", "2026-01-01T00:00:00Z", "2026-01-01T00:05:00Z");
1949
+ long.duration_ms = 300_000;
1950
+ let mut references =
1951
+ repeating_evidence("same-app", "visual", origin, 5, &["custom", "blocks"]);
1952
+ references.extend(repeating_evidence(
1953
+ "same-app",
1954
+ "visual",
1955
+ origin + Duration::seconds(150),
1956
+ 5,
1957
+ &["daily", "brief"],
1958
+ ));
1959
+ let signal = signal_with(references);
1960
+ let manifest = build_with_pending(
1961
+ vec![long],
1962
+ vec![event("2026-01-01T00:00:01Z", "ChatGPT")],
1963
+ BTreeSet::new(),
1964
+ BTreeMap::from([("same-app".into(), signal)]),
1965
+ Utc::now(),
1966
+ );
1967
+ assert_eq!(manifest.chapters.len(), 2, "{:#?}", manifest.chapters);
1968
+ assert!(manifest.chapters[0].keywords.contains(&"blocks".into()));
1969
+ assert!(manifest.chapters[1].keywords.contains(&"brief".into()));
1970
+ }
1971
+
1972
+ #[test]
1973
+ fn cross_app_scenes_merge_when_visual_meaning_continues() {
1974
+ let browser = segment("browser", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
1975
+ let mail = segment("mail", "2026-01-01T00:01:00Z", "2026-01-01T00:02:00Z");
1976
+ let content = BTreeMap::from([
1977
+ (
1978
+ "browser".into(),
1979
+ signal_with(vec![evidence(
1980
+ "browser",
1981
+ "ocr",
1982
+ "visual",
1983
+ "2026-01-01T00:00:35Z",
1984
+ &["preview", "verification"],
1985
+ )]),
1986
+ ),
1987
+ (
1988
+ "mail".into(),
1989
+ signal_with(vec![evidence(
1990
+ "mail",
1991
+ "ocr",
1992
+ "visual",
1993
+ "2026-01-01T00:01:05Z",
1994
+ &["preview", "verification"],
1995
+ )]),
1996
+ ),
1997
+ ]);
1998
+ let manifest = build_with_pending(
1999
+ vec![browser, mail],
2000
+ vec![
2001
+ event("2026-01-01T00:00:01Z", "Browser"),
2002
+ event("2026-01-01T00:01:01Z", "Mail"),
2003
+ ],
2004
+ BTreeSet::new(),
2005
+ content,
2006
+ Utc::now(),
2007
+ );
2008
+ assert_eq!(manifest.chapters.len(), 1, "{:#?}", manifest.chapters);
2009
+ }
2010
+
2011
+ #[test]
2012
+ fn unrelated_microphone_words_do_not_name_visible_work() {
2013
+ let item = segment("visual", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
2014
+ let signal = signal_with(vec![
2015
+ evidence(
2016
+ "visual",
2017
+ "ocr",
2018
+ "visual",
2019
+ "2026-01-01T00:00:05Z",
2020
+ &["content", "blocks"],
2021
+ ),
2022
+ evidence(
2023
+ "visual",
2024
+ "transcript",
2025
+ "microphone",
2026
+ "2026-01-01T00:00:05Z",
2027
+ &["dragons", "kingdom"],
2028
+ ),
2029
+ ]);
2030
+ let manifest = build_with_pending(
2031
+ vec![item],
2032
+ vec![event("2026-01-01T00:00:01Z", "ChatGPT")],
2033
+ BTreeSet::new(),
2034
+ BTreeMap::from([("visual".into(), signal)]),
2035
+ Utc::now(),
2036
+ );
2037
+ assert!(
2038
+ manifest.chapters[0].label.contains("blocks"),
2039
+ "{}",
2040
+ manifest.chapters[0].label
2041
+ );
2042
+ assert!(!manifest.chapters[0].label.contains("dragons"));
2043
+ assert!(!manifest.chapters[0].keywords.contains(&"kingdom".into()));
2044
+ }
2045
+
2046
+ #[test]
2047
+ fn exact_gaps_and_stable_ids_survive_semantic_refinement() {
2048
+ let first = segment("first", "2026-01-01T00:00:00Z", "2026-01-01T00:01:00Z");
2049
+ let second = segment("second", "2026-01-01T00:03:00Z", "2026-01-01T00:04:00Z");
2050
+ let explicit_pause = ScreenMemoryEvent {
2051
+ captured_at: "2026-01-01T00:01:00Z".into(),
2052
+ app_name: None,
2053
+ window_title: None,
2054
+ bundle_id: None,
2055
+ source: "coverage-gap".into(),
2056
+ coverage_gap_reason: Some("user-paused".into()),
2057
+ accessibility: None,
2058
+ };
2059
+ let initial = build_with_pending(
2060
+ vec![first.clone(), second.clone()],
2061
+ vec![explicit_pause.clone()],
2062
+ BTreeSet::new(),
2063
+ BTreeMap::new(),
2064
+ Utc::now(),
2065
+ );
2066
+ assert_eq!(initial.coverage.gap_count, 1);
2067
+ assert_eq!(initial.coverage.gaps[0].duration_ms, 120_000);
2068
+ assert_eq!(initial.coverage.gaps[0].reason, "user-paused");
2069
+ let refined = build_with_previous(
2070
+ vec![first, second],
2071
+ vec![explicit_pause],
2072
+ BTreeSet::new(),
2073
+ BTreeMap::from([(
2074
+ "first".into(),
2075
+ signal_with(vec![evidence(
2076
+ "first",
2077
+ "ocr",
2078
+ "visual",
2079
+ "2026-01-01T00:00:05Z",
2080
+ &["roadmap"],
2081
+ )]),
2082
+ )]),
2083
+ Some(&initial),
2084
+ Utc::now(),
2085
+ );
2086
+ assert_eq!(refined.chapters[0].id, initial.chapters[0].id);
2087
+ assert!(refined.chapters[0].revision > initial.chapters[0].revision);
2088
+ assert_eq!(refined.coverage.gaps[0].duration_ms, 120_000);
2089
+ }
2090
+
2091
+ #[test]
2092
+ fn representative_moments_cover_diverse_scenes_with_budget_warning() {
2093
+ let mut long = segment("long", "2026-01-01T00:00:00Z", "2026-01-01T00:04:00Z");
2094
+ long.duration_ms = 240_000;
2095
+ let manifest = build(vec![long], vec![], Utc::now());
2096
+ let chapter = &manifest.chapters[0];
2097
+ assert_eq!(
2098
+ chapter.representative_moments.len(),
2099
+ MAX_REPRESENTATIVE_MOMENTS
2100
+ );
2101
+ assert_eq!(chapter.representative_coverage.total_scenes, 8);
2102
+ assert!(chapter.representative_coverage.truncated);
2103
+ assert_eq!(chapter.representative_moments[0].reason, "chapter start");
2104
+ assert_eq!(
2105
+ chapter.representative_moments.last().unwrap().reason,
2106
+ "chapter end"
2107
+ );
2108
+ }
2109
+
2110
+ #[test]
2111
+ fn ocr_near_miss_of_generic_product_word_does_not_name_a_chapter() {
2112
+ let counts = BTreeMap::from([
2113
+ ("agent native conlent".into(), 8),
2114
+ ("conlent".into(), 8),
2115
+ ("semantic".into(), 5),
2116
+ ("chapters".into(), 5),
2117
+ ]);
2118
+ let labels = top_label_keywords(&counts, 3);
2119
+ assert_eq!(labels, vec!["chapters", "semantic"]);
2120
+ assert!(top_label_keywords(
2121
+ &BTreeMap::from([("chatgpt".into(), 10), ("axwindow".into(), 10)]),
2122
+ 3
2123
+ )
2124
+ .is_empty());
2125
+ }
2126
+
2127
+ fn long_segment(id: &str, start: &str, end: &str) -> ScreenMemorySegmentMetadata {
2128
+ let mut value = segment(id, start, end);
2129
+ value.duration_ms = (parse_utc(end).unwrap() - parse_utc(start).unwrap())
2130
+ .num_milliseconds()
2131
+ .try_into()
2132
+ .unwrap();
2133
+ value
2134
+ }
2135
+
2136
+ fn repeating_evidence(
2137
+ segment_id: &str,
2138
+ source_kind: &str,
2139
+ start: DateTime<Utc>,
2140
+ count: usize,
2141
+ words: &[&str],
2142
+ ) -> Vec<EvidenceRef> {
2143
+ (0..count)
2144
+ .map(|index| {
2145
+ evidence(
2146
+ segment_id,
2147
+ if source_kind == "visual" {
2148
+ "ocr"
2149
+ } else {
2150
+ "transcript"
2151
+ },
2152
+ source_kind,
2153
+ &(start + Duration::seconds(index as i64 * 30 + 5)).to_rfc3339(),
2154
+ words,
2155
+ )
2156
+ })
2157
+ .collect()
2158
+ }
2159
+
2160
+ fn boundary_offsets(manifest: &RewindChaptersManifest, origin: DateTime<Utc>) -> Vec<i64> {
2161
+ manifest
2162
+ .chapters
2163
+ .iter()
2164
+ .skip(1)
2165
+ .filter_map(|chapter| parse_utc(&chapter.started_at))
2166
+ .map(|started_at| (started_at - origin).num_milliseconds())
2167
+ .collect()
2168
+ }
2169
+
2170
+ fn boundary_score(
2171
+ predicted: &[i64],
2172
+ expected: &[i64],
2173
+ tolerance_ms: i64,
2174
+ ) -> (usize, usize, usize) {
2175
+ let mut used = vec![false; expected.len()];
2176
+ let matched = predicted
2177
+ .iter()
2178
+ .filter(|prediction| {
2179
+ expected
2180
+ .iter()
2181
+ .enumerate()
2182
+ .filter(|(index, _)| !used[*index])
2183
+ .min_by_key(|(_, boundary)| (*prediction - **boundary).abs())
2184
+ .filter(|(_, boundary)| (*prediction - **boundary).abs() <= tolerance_ms)
2185
+ .map(|(index, _)| {
2186
+ used[index] = true;
2187
+ })
2188
+ .is_some()
2189
+ })
2190
+ .count();
2191
+ (matched, predicted.len(), expected.len())
2192
+ }
2193
+
2194
+ #[test]
2195
+ fn three_repository_safe_gold_samples_meet_boundary_precision_and_recall_gate() {
2196
+ // These hand-authored annotations preserve only the timing and topic
2197
+ // shape of the private dogfood cases: cross-app continuation, a
2198
+ // same-app topic change, and a short interruption plus a hard gap.
2199
+ let origin = parse_utc("2026-01-01T00:00:00Z").unwrap();
2200
+
2201
+ let cross_app_segments = vec![
2202
+ long_segment("browser-a", "2026-01-01T00:00:00Z", "2026-01-01T00:05:00Z"),
2203
+ long_segment("mail", "2026-01-01T00:05:00Z", "2026-01-01T00:10:00Z"),
2204
+ long_segment("browser-b", "2026-01-01T00:10:00Z", "2026-01-01T00:15:00Z"),
2205
+ ];
2206
+ let cross_app_content = BTreeMap::from([
2207
+ (
2208
+ "browser-a".into(),
2209
+ signal_with(repeating_evidence(
2210
+ "browser-a",
2211
+ "visual",
2212
+ origin,
2213
+ 10,
2214
+ &["account", "verification"],
2215
+ )),
2216
+ ),
2217
+ (
2218
+ "mail".into(),
2219
+ signal_with(repeating_evidence(
2220
+ "mail",
2221
+ "visual",
2222
+ origin + Duration::minutes(5),
2223
+ 10,
2224
+ &["account", "verification"],
2225
+ )),
2226
+ ),
2227
+ (
2228
+ "browser-b".into(),
2229
+ signal_with(repeating_evidence(
2230
+ "browser-b",
2231
+ "visual",
2232
+ origin + Duration::minutes(10),
2233
+ 10,
2234
+ &["account", "verification"],
2235
+ )),
2236
+ ),
2237
+ ]);
2238
+ let cross_app = build_with_pending(
2239
+ cross_app_segments,
2240
+ vec![
2241
+ event("2026-01-01T00:00:00Z", "Browser"),
2242
+ event("2026-01-01T00:05:00Z", "Mail"),
2243
+ event("2026-01-01T00:10:00Z", "Browser"),
2244
+ ],
2245
+ BTreeSet::new(),
2246
+ cross_app_content,
2247
+ Utc::now(),
2248
+ );
2249
+
2250
+ let same_app_segment =
2251
+ long_segment("agent", "2026-01-01T00:00:00Z", "2026-01-01T00:15:00Z");
2252
+ let mut same_app_refs =
2253
+ repeating_evidence("agent", "visual", origin, 15, &["release", "roadmap"]);
2254
+ same_app_refs.extend(repeating_evidence(
2255
+ "agent",
2256
+ "visual",
2257
+ origin + Duration::seconds(450),
2258
+ 15,
2259
+ &["customer", "interview"],
2260
+ ));
2261
+ let same_app = build_with_pending(
2262
+ vec![same_app_segment],
2263
+ vec![event("2026-01-01T00:00:00Z", "Agent")],
2264
+ BTreeSet::new(),
2265
+ BTreeMap::from([("agent".into(), signal_with(same_app_refs))]),
2266
+ Utc::now(),
2267
+ );
2268
+
2269
+ let interrupted_segments = vec![
2270
+ long_segment("before-gap", "2026-01-01T00:00:00Z", "2026-01-01T00:10:00Z"),
2271
+ long_segment("after-gap", "2026-01-01T00:11:00Z", "2026-01-01T00:20:00Z"),
2272
+ ];
2273
+ let mut before_gap =
2274
+ repeating_evidence("before-gap", "visual", origin, 20, &["privacy", "review"]);
2275
+ before_gap.extend(repeating_evidence(
2276
+ "before-gap",
2277
+ "microphone",
2278
+ origin,
2279
+ 20,
2280
+ &["dragon", "kingdom"],
2281
+ ));
2282
+ let interrupted = build_with_pending(
2283
+ interrupted_segments,
2284
+ vec![
2285
+ event("2026-01-01T00:00:00Z", "Editor"),
2286
+ event("2026-01-01T00:05:00Z", "Messages"),
2287
+ event("2026-01-01T00:05:30Z", "Editor"),
2288
+ event("2026-01-01T00:11:00Z", "Terminal"),
2289
+ ],
2290
+ BTreeSet::new(),
2291
+ BTreeMap::from([
2292
+ ("before-gap".into(), signal_with(before_gap)),
2293
+ (
2294
+ "after-gap".into(),
2295
+ signal_with(repeating_evidence(
2296
+ "after-gap",
2297
+ "visual",
2298
+ origin + Duration::minutes(11),
2299
+ 18,
2300
+ &["chapter", "tests"],
2301
+ )),
2302
+ ),
2303
+ ]),
2304
+ Utc::now(),
2305
+ );
2306
+
2307
+ let scored = [
2308
+ (boundary_offsets(&cross_app, origin), vec![]),
2309
+ (boundary_offsets(&same_app, origin), vec![450_000]),
2310
+ (boundary_offsets(&interrupted, origin), vec![660_000]),
2311
+ ];
2312
+ let (mut matched, mut predicted, mut expected) = (0, 0, 0);
2313
+ for (actual, annotated) in scored {
2314
+ let score = boundary_score(&actual, &annotated, 30_000);
2315
+ matched += score.0;
2316
+ predicted += score.1;
2317
+ expected += score.2;
2318
+ }
2319
+ let precision = matched as f32 / predicted.max(1) as f32;
2320
+ let recall = matched as f32 / expected.max(1) as f32;
2321
+ assert!(
2322
+ precision >= 0.8,
2323
+ "precision={precision}, predicted={predicted}"
2324
+ );
2325
+ assert!(recall >= 0.8, "recall={recall}, expected={expected}");
2326
+ assert_eq!(cross_app.chapters.len(), 1, "{:#?}", cross_app.chapters);
2327
+ let recognizable_titles = [
2328
+ cross_app.chapters[0].label.contains("verification"),
2329
+ same_app.chapters[0].label.contains("roadmap"),
2330
+ same_app.chapters[1].label.contains("interview"),
2331
+ interrupted.chapters[0].label.contains("privacy"),
2332
+ interrupted.chapters[1].label.contains("chapter"),
2333
+ ];
2334
+ assert!(
2335
+ recognizable_titles.iter().filter(|clear| **clear).count() as f32
2336
+ / recognizable_titles.len() as f32
2337
+ >= 0.8,
2338
+ "gold titles: cross_app={:?}, same_app={:?}, interrupted={:?}",
2339
+ cross_app
2340
+ .chapters
2341
+ .iter()
2342
+ .map(|chapter| &chapter.label)
2343
+ .collect::<Vec<_>>(),
2344
+ same_app
2345
+ .chapters
2346
+ .iter()
2347
+ .map(|chapter| &chapter.label)
2348
+ .collect::<Vec<_>>(),
2349
+ interrupted
2350
+ .chapters
2351
+ .iter()
2352
+ .map(|chapter| &chapter.label)
2353
+ .collect::<Vec<_>>(),
2354
+ );
2355
+ assert!(!interrupted.chapters[0].label.contains("dragon"));
2356
+ }
2357
+
2358
+ /// Read-only dogfood harness. It is ignored unless a developer explicitly
2359
+ /// supplies a local Screen Memory directory; no archive path is committed.
2360
+ #[test]
2361
+ #[ignore = "requires CLIPS_REWIND_AUDIT_DIR and prints local derived evidence"]
2362
+ fn audits_an_explicit_local_store_without_rewriting_it() {
2363
+ let directory = std::env::var_os("CLIPS_REWIND_AUDIT_DIR")
2364
+ .map(PathBuf::from)
2365
+ .expect("set CLIPS_REWIND_AUDIT_DIR");
2366
+ let minutes = std::env::var("CLIPS_REWIND_AUDIT_MINUTES")
2367
+ .ok()
2368
+ .and_then(|value| value.parse::<i64>().ok())
2369
+ .unwrap_or(20)
2370
+ .clamp(1, 120);
2371
+ let mut all_segments = fs::read_dir(&directory)
2372
+ .expect("read audit directory")
2373
+ .flatten()
2374
+ .filter_map(|entry| {
2375
+ let path = entry.path();
2376
+ (path.extension().and_then(|value| value.to_str()) == Some("json"))
2377
+ .then(|| fs::read(path).ok())
2378
+ .flatten()
2379
+ .and_then(|bytes| {
2380
+ serde_json::from_slice::<ScreenMemorySegmentMetadata>(&bytes).ok()
2381
+ })
2382
+ })
2383
+ .collect::<Vec<_>>();
2384
+ let newest_end = all_segments
2385
+ .iter()
2386
+ .filter_map(|segment| parse_utc(&segment.ended_at))
2387
+ .max()
2388
+ .expect("at least one segment");
2389
+ let audit_end = std::env::var("CLIPS_REWIND_AUDIT_END_AT")
2390
+ .ok()
2391
+ .and_then(|value| parse_utc(&value))
2392
+ .unwrap_or(newest_end);
2393
+ let cutoff = audit_end - Duration::minutes(minutes);
2394
+ all_segments.retain(|segment| {
2395
+ parse_utc(&segment.started_at).is_some_and(|started_at| started_at <= audit_end)
2396
+ && parse_utc(&segment.ended_at).is_some_and(|ended_at| ended_at >= cutoff)
2397
+ });
2398
+ for segment in &mut all_segments {
2399
+ let started_at = parse_utc(&segment.started_at).unwrap_or(cutoff).max(cutoff);
2400
+ let ended_at = parse_utc(&segment.ended_at)
2401
+ .unwrap_or(audit_end)
2402
+ .min(audit_end);
2403
+ segment.started_at = started_at.to_rfc3339();
2404
+ segment.ended_at = ended_at.to_rfc3339();
2405
+ segment.duration_ms = (ended_at - started_at).num_milliseconds().max(0) as u128;
2406
+ }
2407
+ let events = File::open(directory.join("events.jsonl"))
2408
+ .ok()
2409
+ .into_iter()
2410
+ .flat_map(|file| BufReader::new(file).lines().map_while(Result::ok))
2411
+ .filter_map(|line| serde_json::from_str::<ScreenMemoryEvent>(&line).ok())
2412
+ .filter(|event| {
2413
+ parse_utc(&event.captured_at).is_some_and(|at| at >= cutoff && at <= audit_end)
2414
+ })
2415
+ .collect::<Vec<_>>();
2416
+ let content = content_signals(&directory, &all_segments);
2417
+ let manifest =
2418
+ build_with_pending(all_segments, events, BTreeSet::new(), content, Utc::now());
2419
+ let report = serde_json::json!({
2420
+ "coverage": manifest.coverage,
2421
+ "chapters": manifest.chapters.iter().map(|chapter| serde_json::json!({
2422
+ "id": chapter.id,
2423
+ "startedAt": chapter.started_at,
2424
+ "endedAt": chapter.ended_at,
2425
+ "label": chapter.label,
2426
+ "keywords": chapter.keywords,
2427
+ "sceneCount": chapter.scene_refs.len(),
2428
+ "representativeCoverage": chapter.representative_coverage,
2429
+ "ambiguityReasons": chapter.ambiguity_reasons,
2430
+ })).collect::<Vec<_>>(),
2431
+ });
2432
+ println!("{}", serde_json::to_string_pretty(&report).unwrap());
2433
+ }
2434
+ }