@agent-native/core 0.114.5 → 0.114.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/mcp-config-writers.ts +6 -4
  5. package/corpus/core/src/cli/mcp.ts +124 -0
  6. package/corpus/core/src/cli/skills-content/help.ts +4 -3
  7. package/corpus/core/src/cli/skills-content/index.ts +1 -0
  8. package/corpus/core/src/cli/skills-content/rewind-skill.ts +52 -0
  9. package/corpus/core/src/cli/skills.ts +115 -13
  10. package/corpus/core/src/client/resources/ResourceTree.tsx +11 -5
  11. package/corpus/core/src/client/resources/ResourcesPanel.tsx +141 -18
  12. package/corpus/core/src/mcp/screen-memory-stdio.ts +1360 -104
  13. package/corpus/core/src/mcp-client/index.ts +9 -0
  14. package/corpus/core/src/mcp-client/screen-memory-local.ts +847 -13
  15. package/corpus/templates/clips/actions/apply-rewind-extension.ts +195 -0
  16. package/corpus/templates/clips/actions/delete-agent-recording-if-unpromoted.ts +64 -0
  17. package/corpus/templates/clips/actions/get-rewind-extension-request.ts +22 -0
  18. package/corpus/templates/clips/actions/get-screen-memory-status.ts +18 -2
  19. package/corpus/templates/clips/actions/list-rewind-extension-requests.ts +52 -0
  20. package/corpus/templates/clips/actions/make-recording-private-for-rewind.ts +55 -0
  21. package/corpus/templates/clips/actions/query-screen-memory-context.ts +4 -4
  22. package/corpus/templates/clips/actions/request-rewind-extension.ts +81 -0
  23. package/corpus/templates/clips/actions/update-rewind-extension-request.ts +47 -0
  24. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +25 -0
  25. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +36 -0
  26. package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +338 -0
  27. package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +1 -16
  28. package/corpus/templates/clips/app/components/editor/timeline.tsx +26 -0
  29. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +1 -2
  30. package/corpus/templates/clips/app/i18n/en-US.ts +18 -0
  31. package/corpus/templates/clips/app/lib/ffmpeg-export.ts +19 -12
  32. package/corpus/templates/clips/app/lib/rewind-visibility.ts +31 -0
  33. package/corpus/templates/clips/app/lib/timestamp-mapping.ts +7 -0
  34. package/corpus/templates/clips/app/lib/upload-file-client.ts +17 -0
  35. package/corpus/templates/clips/changelog/2026-07-14-rewind-keeps-recent-screen-context-local-and-turns-it-into-clips.md +6 -0
  36. package/corpus/templates/clips/changelog/2026-07-17-active-clips-keep-rewind-stable-and-settings-clear.md +12 -0
  37. package/corpus/templates/clips/changelog/2026-07-17-clicking-the-clips-icon-during-a-recording-now-opens-the-app.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-17-default-mic-now-uses-the-mac-default-and-transcription-shares-one-capture.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-17-full-screen-clips-finalize-even-when-screencapturekit-stop-hangs.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-17-normal-full-screen-clips-no-longer-stop-immediately-after-th.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-17-rewind-can-hand-one-bounded-private-clip-to-your-agent.md +12 -0
  42. package/corpus/templates/clips/changelog/2026-07-17-rewind-home-can-copy-a-local-agent-prompt.md +6 -0
  43. package/corpus/templates/clips/changelog/2026-07-17-rewind-now-has-a-calm-home-status-focused-first-run-consent-.md +6 -0
  44. package/corpus/templates/clips/changelog/2026-07-17-rewind-settings-are-smaller-clearer-and-agent-first.md +6 -0
  45. package/corpus/templates/clips/changelog/2026-07-17-the-full-clips-interface-stays-available-while-recording.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-18-rewind-organizes-recent-work-into-local-searchable-chapters.md +8 -0
  47. package/corpus/templates/clips/changelog/2026-07-18-searchable-rewind-chapters.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-19-rewind-backed-recordings-now-start-immediately-after-the-cou.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-20-opening-zoom-meeting-launches-native-app.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-07-20-rewind-can-set-up-your-local-agent-once-so-future-requests-w.md +6 -0
  51. package/corpus/templates/clips/desktop/package.json +1 -0
  52. package/corpus/templates/clips/desktop/scripts/sign-macos-local.ts +12 -1
  53. package/corpus/templates/clips/desktop/src/app.tsx +2478 -223
  54. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +21 -1
  55. package/corpus/templates/clips/desktop/src/components/Switch.tsx +3 -0
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +156 -24
  57. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +6 -7
  58. package/corpus/templates/clips/desktop/src/lib/open-meeting-join-url.ts +22 -0
  59. package/corpus/templates/clips/desktop/src/lib/recorder.ts +1763 -1160
  60. package/corpus/templates/clips/desktop/src/lib/rewind-agent-prompt.ts +9 -0
  61. package/corpus/templates/clips/desktop/src/lib/rewind-recording-start.ts +20 -0
  62. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  63. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +14 -14
  64. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +2 -3
  65. package/corpus/templates/clips/desktop/src/overlays/onboarding.tsx +4 -4
  66. package/corpus/templates/clips/desktop/src/overlays/preparing.tsx +13 -0
  67. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +27 -2
  68. package/corpus/templates/clips/desktop/src/shared/config.ts +16 -0
  69. package/corpus/templates/clips/desktop/src/styles.css +956 -134
  70. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +5 -0
  71. package/corpus/templates/clips/desktop/src-tauri/build.rs +69 -1
  72. package/corpus/templates/clips/desktop/src-tauri/src/accessibility.rs +386 -12
  73. package/corpus/templates/clips/desktop/src-tauri/src/capture_audio_bus.rs +382 -0
  74. package/corpus/templates/clips/desktop/src-tauri/src/capture_graph.rs +777 -0
  75. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +249 -22
  76. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +148 -1
  77. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +58 -4
  78. package/corpus/templates/clips/desktop/src-tauri/src/logfile.rs +13 -1
  79. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/custom_capture.rs +1505 -51
  80. package/corpus/templates/clips/desktop/src-tauri/src/native_screen/live_upload.rs +124 -0
  81. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +1006 -95
  82. package/corpus/templates/clips/desktop/src-tauri/src/rewind_capture_suspension.rs +165 -0
  83. package/corpus/templates/clips/desktop/src-tauri/src/rewind_chapters.rs +2434 -0
  84. package/corpus/templates/clips/desktop/src-tauri/src/rewind_clip.rs +1650 -0
  85. package/corpus/templates/clips/desktop/src-tauri/src/rewind_egress.rs +643 -0
  86. package/corpus/templates/clips/desktop/src-tauri/src/rewind_local_ask.rs +803 -0
  87. package/corpus/templates/clips/desktop/src-tauri/src/rewind_meeting_history.rs +595 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory.rs +2900 -91
  89. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_ocr.rs +360 -0
  90. package/corpus/templates/clips/desktop/src-tauri/src/screen_memory_transcript.rs +408 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +114 -33
  92. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +2 -2
  93. package/corpus/templates/clips/desktop/src-tauri/src/system_audio.rs +13 -3
  94. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +4 -1
  95. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +7 -13
  96. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +198 -11
  97. package/corpus/templates/clips/desktop/src-tauri/tauri.alpha.conf.json +17 -0
  98. package/corpus/templates/clips/shared/meeting-join-url.ts +0 -5
  99. package/corpus/templates/content/.agents/skills/document-editing/SKILL.md +9 -1
  100. package/corpus/templates/content/AGENTS.md +4 -1
  101. package/corpus/templates/content/actions/_content-spaces.ts +6 -1
  102. package/corpus/templates/content/actions/_database-row-batch.ts +15 -2
  103. package/corpus/templates/content/actions/_database-utils.ts +83 -15
  104. package/corpus/templates/content/actions/add-database-item.ts +3 -0
  105. package/corpus/templates/content/actions/connect-local-folder-source.ts +2 -1
  106. package/corpus/templates/content/actions/delete-content-database.ts +12 -9
  107. package/corpus/templates/content/actions/delete-database-items.ts +3 -2
  108. package/corpus/templates/content/actions/delete-document.ts +233 -6
  109. package/corpus/templates/content/actions/duplicate-database-item.ts +1 -0
  110. package/corpus/templates/content/actions/get-document.ts +4 -1
  111. package/corpus/templates/content/actions/list-content-databases.ts +1 -0
  112. package/corpus/templates/content/actions/list-documents.ts +1 -0
  113. package/corpus/templates/content/actions/list-trashed-content-databases.ts +8 -2
  114. package/corpus/templates/content/actions/list-trashed-documents.ts +50 -0
  115. package/corpus/templates/content/actions/permanently-delete-document.ts +28 -0
  116. package/corpus/templates/content/actions/pull-document.ts +15 -2
  117. package/corpus/templates/content/actions/restore-content-database.ts +48 -11
  118. package/corpus/templates/content/actions/restore-document.ts +28 -0
  119. package/corpus/templates/content/actions/search-documents.ts +2 -1
  120. package/corpus/templates/content/actions/view-screen.ts +2 -1
  121. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +149 -55
  122. package/corpus/templates/content/app/components/editor/database/GalleryView.tsx +7 -2
  123. package/corpus/templates/content/app/components/editor/database/ListView.tsx +7 -2
  124. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +3 -0
  125. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +26 -5
  126. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +243 -163
  127. package/corpus/templates/content/app/components/sidebar/WorkspaceSourceMenu.tsx +154 -0
  128. package/corpus/templates/content/app/components/sidebar/select-content-space.ts +11 -0
  129. package/corpus/templates/content/app/hooks/use-content-database.ts +6 -0
  130. package/corpus/templates/content/app/hooks/use-documents.ts +57 -0
  131. package/corpus/templates/content/app/i18n-data.ts +83 -2
  132. package/corpus/templates/content/app/routes/_app.local-files.tsx +8 -1
  133. package/corpus/templates/content/changelog/2026-07-19-pages-now-move-to-a-reversible-trash-and-the-organization-pi.md +6 -0
  134. package/corpus/templates/content/changelog/2026-07-20-trash-actions-now-preserve-independent-archived-pages-enforc.md +6 -0
  135. package/corpus/templates/content/changelog/2026-07-20-workspace-toggles-now-stay-independently-open-or-closed-and-.md +6 -0
  136. package/corpus/templates/content/changelog/2026-07-20-workspaces-can-be-added-from-a-blank-workspace-or-a-connecte.md +6 -0
  137. package/corpus/templates/content/parity/matrix.md +1 -1
  138. package/corpus/templates/content/parity/matrix.ts +3 -0
  139. package/corpus/templates/content/server/db/schema.ts +2 -0
  140. package/corpus/templates/content/server/lib/public-documents.ts +8 -2
  141. package/corpus/templates/content/server/plugins/db.ts +52 -0
  142. package/corpus/templates/content/server/routes/api/document-agent-context.json.get.ts +2 -2
  143. package/corpus/templates/content/shared/api.ts +10 -0
  144. package/corpus/toolkit/CHANGELOG.md +6 -0
  145. package/corpus/toolkit/package.json +1 -1
  146. package/corpus/toolkit/src/ui/progress.tsx +3 -2
  147. package/dist/cli/mcp-config-writers.d.ts +2 -2
  148. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  149. package/dist/cli/mcp-config-writers.js +6 -6
  150. package/dist/cli/mcp-config-writers.js.map +1 -1
  151. package/dist/cli/mcp.d.ts +14 -0
  152. package/dist/cli/mcp.d.ts.map +1 -1
  153. package/dist/cli/mcp.js +79 -0
  154. package/dist/cli/mcp.js.map +1 -1
  155. package/dist/cli/skills-content/help.d.ts +1 -1
  156. package/dist/cli/skills-content/help.d.ts.map +1 -1
  157. package/dist/cli/skills-content/help.js +4 -3
  158. package/dist/cli/skills-content/help.js.map +1 -1
  159. package/dist/cli/skills-content/index.d.ts +1 -0
  160. package/dist/cli/skills-content/index.d.ts.map +1 -1
  161. package/dist/cli/skills-content/index.js +1 -0
  162. package/dist/cli/skills-content/index.js.map +1 -1
  163. package/dist/cli/skills-content/rewind-skill.d.ts +2 -0
  164. package/dist/cli/skills-content/rewind-skill.d.ts.map +1 -0
  165. package/dist/cli/skills-content/rewind-skill.js +53 -0
  166. package/dist/cli/skills-content/rewind-skill.js.map +1 -0
  167. package/dist/cli/skills.d.ts +6 -0
  168. package/dist/cli/skills.d.ts.map +1 -1
  169. package/dist/cli/skills.js +90 -14
  170. package/dist/cli/skills.js.map +1 -1
  171. package/dist/client/resources/ResourceTree.d.ts +3 -1
  172. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  173. package/dist/client/resources/ResourceTree.js +4 -4
  174. package/dist/client/resources/ResourceTree.js.map +1 -1
  175. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  176. package/dist/client/resources/ResourcesPanel.js +55 -6
  177. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  178. package/dist/collab/routes.d.ts +1 -1
  179. package/dist/collab/struct-routes.d.ts +1 -1
  180. package/dist/mcp/screen-memory-stdio.d.ts +365 -0
  181. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  182. package/dist/mcp/screen-memory-stdio.js +1014 -91
  183. package/dist/mcp/screen-memory-stdio.js.map +1 -1
  184. package/dist/mcp-client/index.d.ts +1 -1
  185. package/dist/mcp-client/index.d.ts.map +1 -1
  186. package/dist/mcp-client/index.js +1 -1
  187. package/dist/mcp-client/index.js.map +1 -1
  188. package/dist/mcp-client/screen-memory-local.d.ts +90 -0
  189. package/dist/mcp-client/screen-memory-local.d.ts.map +1 -1
  190. package/dist/mcp-client/screen-memory-local.js +590 -12
  191. package/dist/mcp-client/screen-memory-local.js.map +1 -1
  192. package/dist/notifications/routes.d.ts +2 -2
  193. package/dist/observability/routes.d.ts +5 -5
  194. package/dist/progress/routes.d.ts +1 -1
  195. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  196. package/dist/resources/handlers.d.ts +1 -1
  197. package/dist/secrets/routes.d.ts +9 -9
  198. package/dist/server/transcribe-voice.d.ts +1 -1
  199. package/package.json +2 -2
  200. package/src/cli/mcp-config-writers.ts +6 -4
  201. package/src/cli/mcp.ts +124 -0
  202. package/src/cli/skills-content/help.ts +4 -3
  203. package/src/cli/skills-content/index.ts +1 -0
  204. package/src/cli/skills-content/rewind-skill.ts +52 -0
  205. package/src/cli/skills.ts +115 -13
  206. package/src/client/resources/ResourceTree.tsx +11 -5
  207. package/src/client/resources/ResourcesPanel.tsx +141 -18
  208. package/src/mcp/screen-memory-stdio.ts +1360 -104
  209. package/src/mcp-client/index.ts +9 -0
  210. package/src/mcp-client/screen-memory-local.ts +847 -13
@@ -1,14 +1,10 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
1
3
  import fs from "node:fs";
2
4
  import os from "node:os";
3
5
  import path from "node:path";
4
6
 
5
- interface ScreenMemoryEvent {
6
- capturedAt: string;
7
- appName?: string | null;
8
- windowTitle?: string | null;
9
- bundleId?: string | null;
10
- source: string;
11
- }
7
+ import { queryScreenMemoryContext } from "../mcp-client/screen-memory-local.js";
12
8
 
13
9
  interface ScreenMemorySegment {
14
10
  id: string;
@@ -20,6 +16,65 @@ interface ScreenMemorySegment {
20
16
  bytes: number;
21
17
  durationMs: number;
22
18
  corrupt?: boolean;
19
+ excluded?: boolean;
20
+ exclusionTainted?: boolean;
21
+ tainted?: boolean;
22
+ }
23
+
24
+ export interface ScreenMemoryChapter {
25
+ id: string;
26
+ /** In v2, chapter identity survives later semantic refinements. */
27
+ revision: number;
28
+ aliases: string[];
29
+ startedAt: string;
30
+ endedAt: string;
31
+ durationMs: number;
32
+ label: string;
33
+ summary: string;
34
+ keywords: string[];
35
+ accessibilitySummary: string;
36
+ accessibilityKeywords: string[];
37
+ confidence: number;
38
+ segmentRefs: Array<{ id: string; startedAt: string; endedAt: string }>;
39
+ sceneRefs: Array<{ id: string; startedAt?: string; endedAt?: string }>;
40
+ evidenceRefs: Array<{
41
+ sourceType: string;
42
+ sourceKind?: string;
43
+ segmentId?: string;
44
+ offsetMs?: number;
45
+ capturedAt?: string;
46
+ keywords?: string[];
47
+ confidence?: number;
48
+ }>;
49
+ contexts: Array<{
50
+ appName?: string;
51
+ windowTitle?: string;
52
+ bundleId?: string;
53
+ }>;
54
+ representativeMoments: Array<{
55
+ momentId: string;
56
+ capturedAt: string;
57
+ segmentId: string;
58
+ offsetMs: number;
59
+ reason: string;
60
+ }>;
61
+ representativeCoverage?:
62
+ | string
63
+ | { coveredScenes: number; totalScenes: number; truncated: boolean };
64
+ ambiguityReasons: string[];
65
+ indexState: "pending" | "partial" | "ready";
66
+ }
67
+
68
+ export interface ScreenMemoryChaptersDocument {
69
+ schemaVersion: 1 | 2;
70
+ generatedAt: string;
71
+ state: string;
72
+ coverage: unknown;
73
+ chapters: ScreenMemoryChapter[];
74
+ }
75
+
76
+ export interface ScreenMemoryFrameDecoder {
77
+ (segmentPath: string, offsetMs: number): Buffer;
23
78
  }
24
79
 
25
80
  interface ScreenMemoryConfig {
@@ -27,14 +82,35 @@ interface ScreenMemoryConfig {
27
82
  paused?: boolean;
28
83
  retentionHours?: number;
29
84
  maxBytes?: number;
85
+ reviewBeforeSending?: boolean;
86
+ agentClipRetention?: "forever" | "24-hours" | "7-days" | "30-days";
30
87
  }
31
88
 
32
89
  export interface RunScreenMemoryMCPStdioOptions {
33
90
  storeDir?: string;
34
91
  env?: NodeJS.ProcessEnv;
92
+ /** Test seam; production uses the local ffmpeg executable. */
93
+ decodeFrame?: ScreenMemoryFrameDecoder;
35
94
  }
36
95
 
37
- const EVENTS_JSONL = "events.jsonl";
96
+ const MAX_CHAPTERS = 12;
97
+ const MAX_FRAME_BYTES = 900_000;
98
+ const MAX_FRAME_EDGE = 1280;
99
+ const SEARCH_STOP_WORDS = new Set([
100
+ "and",
101
+ "for",
102
+ "from",
103
+ "that",
104
+ "the",
105
+ "this",
106
+ "was",
107
+ "were",
108
+ "what",
109
+ "when",
110
+ "with",
111
+ "work",
112
+ "working",
113
+ ]);
38
114
 
39
115
  function log(msg: string): void {
40
116
  process.stderr.write(`[screen-memory-mcp] ${msg}\n`);
@@ -64,26 +140,6 @@ function defaultStoreDir(env: NodeJS.ProcessEnv): string {
64
140
  return path.join(defaultAppDataDir(env), "screen-memory");
65
141
  }
66
142
 
67
- function readJsonl<T>(file: string): T[] {
68
- let body = "";
69
- try {
70
- body = fs.readFileSync(file, "utf-8");
71
- } catch {
72
- return [];
73
- }
74
- return body
75
- .split(/\r?\n/)
76
- .filter(Boolean)
77
- .map((line) => {
78
- try {
79
- return JSON.parse(line) as T;
80
- } catch {
81
- return null;
82
- }
83
- })
84
- .filter((value): value is T => Boolean(value));
85
- }
86
-
87
143
  function readFeatureConfig(storeDir: string): ScreenMemoryConfig {
88
144
  const configPath = path.join(path.dirname(storeDir), "feature-config.json");
89
145
  try {
@@ -100,30 +156,6 @@ function cutoffFor(minutes: number): number {
100
156
  return Date.now() - Math.max(1, Math.min(minutes, 24 * 60)) * 60_000;
101
157
  }
102
158
 
103
- function eventMatches(event: ScreenMemoryEvent, query: string): boolean {
104
- const needle = query.trim().toLowerCase();
105
- if (!needle) return true;
106
- return [event.appName, event.windowTitle, event.bundleId, event.source].some(
107
- (value) => value?.toLowerCase().includes(needle),
108
- );
109
- }
110
-
111
- function recentEvents(
112
- storeDir: string,
113
- args: { query?: string; minutes?: number; limit?: number },
114
- ): ScreenMemoryEvent[] {
115
- const cutoff = cutoffFor(args.minutes ?? 30);
116
- const limit = Math.max(1, Math.min(args.limit ?? 40, 200));
117
- return readJsonl<ScreenMemoryEvent>(path.join(storeDir, EVENTS_JSONL))
118
- .filter((event) => {
119
- const capturedAt = Date.parse(event.capturedAt);
120
- return Number.isFinite(capturedAt) && capturedAt >= cutoff;
121
- })
122
- .filter((event) => eventMatches(event, args.query ?? ""))
123
- .sort((a, b) => b.capturedAt.localeCompare(a.capturedAt))
124
- .slice(0, limit);
125
- }
126
-
127
159
  function readSegments(storeDir: string): ScreenMemorySegment[] {
128
160
  let entries: string[] = [];
129
161
  try {
@@ -145,6 +177,609 @@ function readSegments(storeDir: string): ScreenMemorySegment[] {
145
177
  .filter((segment): segment is ScreenMemorySegment => Boolean(segment));
146
178
  }
147
179
 
180
+ function isTimestamp(value: unknown): value is string {
181
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
182
+ }
183
+
184
+ function isRecord(value: unknown): value is Record<string, unknown> {
185
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
186
+ }
187
+
188
+ function cleanText(value: unknown, fallback = ""): string {
189
+ return typeof value === "string" ? value.trim().slice(0, 2_000) : fallback;
190
+ }
191
+
192
+ function cleanKeywords(value: unknown, limit = 12): string[] {
193
+ return Array.isArray(value)
194
+ ? value
195
+ .filter((item): item is string => typeof item === "string")
196
+ .map((item) => cleanText(item).slice(0, 80))
197
+ .filter(Boolean)
198
+ .slice(0, limit)
199
+ : [];
200
+ }
201
+
202
+ function parseChapter(value: unknown): ScreenMemoryChapter | null {
203
+ if (
204
+ !isRecord(value) ||
205
+ !isTimestamp(value.startedAt) ||
206
+ !isTimestamp(value.endedAt)
207
+ )
208
+ return null;
209
+ const id = cleanText(value.id);
210
+ if (!id) return null;
211
+ const indexState = value.indexState;
212
+ if (
213
+ indexState !== "pending" &&
214
+ indexState !== "partial" &&
215
+ indexState !== "ready"
216
+ )
217
+ return null;
218
+ const segmentRefs = Array.isArray(value.segmentRefs)
219
+ ? value.segmentRefs.flatMap((ref) =>
220
+ isRecord(ref) &&
221
+ cleanText(ref.id) &&
222
+ isTimestamp(ref.startedAt) &&
223
+ isTimestamp(ref.endedAt)
224
+ ? [
225
+ {
226
+ id: cleanText(ref.id),
227
+ startedAt: ref.startedAt,
228
+ endedAt: ref.endedAt,
229
+ },
230
+ ]
231
+ : [],
232
+ )
233
+ : [];
234
+ const evidenceRefs = Array.isArray(value.evidenceRefs)
235
+ ? value.evidenceRefs.flatMap((ref) =>
236
+ isRecord(ref) && cleanText(ref.sourceType)
237
+ ? [
238
+ {
239
+ sourceType: cleanText(ref.sourceType),
240
+ ...(cleanText(ref.sourceKind)
241
+ ? { sourceKind: cleanText(ref.sourceKind) }
242
+ : {}),
243
+ ...(cleanText(ref.segmentId)
244
+ ? { segmentId: cleanText(ref.segmentId) }
245
+ : {}),
246
+ ...(typeof ref.offsetMs === "number" &&
247
+ Number.isFinite(ref.offsetMs)
248
+ ? { offsetMs: ref.offsetMs }
249
+ : {}),
250
+ ...(isTimestamp(ref.capturedAt)
251
+ ? { capturedAt: ref.capturedAt }
252
+ : {}),
253
+ ...(cleanKeywords(ref.keywords, 64).length
254
+ ? { keywords: cleanKeywords(ref.keywords, 64) }
255
+ : {}),
256
+ ...(typeof ref.confidence === "number" &&
257
+ Number.isFinite(ref.confidence)
258
+ ? { confidence: Math.max(0, Math.min(1, ref.confidence)) }
259
+ : {}),
260
+ },
261
+ ]
262
+ : [],
263
+ )
264
+ : [];
265
+ const sceneRefs = Array.isArray(value.sceneRefs)
266
+ ? value.sceneRefs.flatMap((ref) =>
267
+ typeof ref === "string" && cleanText(ref)
268
+ ? [{ id: cleanText(ref) }]
269
+ : isRecord(ref) && cleanText(ref.id)
270
+ ? [
271
+ {
272
+ id: cleanText(ref.id),
273
+ ...(isTimestamp(ref.startedAt)
274
+ ? { startedAt: ref.startedAt }
275
+ : {}),
276
+ ...(isTimestamp(ref.endedAt) ? { endedAt: ref.endedAt } : {}),
277
+ },
278
+ ]
279
+ : [],
280
+ )
281
+ : [];
282
+ const representativeCoverage = isRecord(value.representativeCoverage)
283
+ ? typeof value.representativeCoverage.coveredScenes === "number" &&
284
+ Number.isFinite(value.representativeCoverage.coveredScenes) &&
285
+ typeof value.representativeCoverage.totalScenes === "number" &&
286
+ Number.isFinite(value.representativeCoverage.totalScenes) &&
287
+ typeof value.representativeCoverage.truncated === "boolean"
288
+ ? {
289
+ coveredScenes: Math.max(
290
+ 0,
291
+ Math.trunc(value.representativeCoverage.coveredScenes),
292
+ ),
293
+ totalScenes: Math.max(
294
+ 0,
295
+ Math.trunc(value.representativeCoverage.totalScenes),
296
+ ),
297
+ truncated: value.representativeCoverage.truncated,
298
+ }
299
+ : undefined
300
+ : cleanText(value.representativeCoverage) || undefined;
301
+ const contexts = Array.isArray(value.contexts)
302
+ ? value.contexts.flatMap((context) =>
303
+ isRecord(context)
304
+ ? [
305
+ {
306
+ ...(cleanText(context.appName)
307
+ ? { appName: cleanText(context.appName) }
308
+ : {}),
309
+ ...(cleanText(context.windowTitle)
310
+ ? { windowTitle: cleanText(context.windowTitle) }
311
+ : {}),
312
+ ...(cleanText(context.bundleId)
313
+ ? { bundleId: cleanText(context.bundleId) }
314
+ : {}),
315
+ },
316
+ ]
317
+ : [],
318
+ )
319
+ : [];
320
+ const representativeMoments = Array.isArray(value.representativeMoments)
321
+ ? value.representativeMoments.flatMap((moment) =>
322
+ isRecord(moment) &&
323
+ cleanText(moment.momentId) &&
324
+ isTimestamp(moment.capturedAt) &&
325
+ cleanText(moment.segmentId) &&
326
+ typeof moment.offsetMs === "number" &&
327
+ Number.isFinite(moment.offsetMs)
328
+ ? [
329
+ {
330
+ momentId: cleanText(moment.momentId),
331
+ capturedAt: moment.capturedAt,
332
+ segmentId: cleanText(moment.segmentId),
333
+ offsetMs: moment.offsetMs,
334
+ reason: cleanText(moment.reason),
335
+ },
336
+ ]
337
+ : [],
338
+ )
339
+ : [];
340
+ return {
341
+ id,
342
+ revision:
343
+ typeof value.revision === "number" &&
344
+ Number.isInteger(value.revision) &&
345
+ value.revision >= 0
346
+ ? value.revision
347
+ : 1,
348
+ aliases: cleanKeywords(value.aliases, 24),
349
+ startedAt: value.startedAt,
350
+ endedAt: value.endedAt,
351
+ durationMs:
352
+ typeof value.durationMs === "number" && Number.isFinite(value.durationMs)
353
+ ? value.durationMs
354
+ : Date.parse(value.endedAt) - Date.parse(value.startedAt),
355
+ label: cleanText(value.label, "Untitled work chapter"),
356
+ summary: cleanText(value.summary),
357
+ keywords: cleanKeywords(value.keywords),
358
+ accessibilitySummary: cleanText(value.accessibilitySummary),
359
+ accessibilityKeywords: cleanKeywords(value.accessibilityKeywords),
360
+ confidence:
361
+ typeof value.confidence === "number" && Number.isFinite(value.confidence)
362
+ ? Math.max(0, Math.min(1, value.confidence))
363
+ : 0,
364
+ segmentRefs,
365
+ sceneRefs,
366
+ evidenceRefs,
367
+ contexts,
368
+ representativeMoments,
369
+ ...(representativeCoverage ? { representativeCoverage } : {}),
370
+ ambiguityReasons: Array.isArray(value.ambiguityReasons)
371
+ ? value.ambiguityReasons
372
+ .filter((item): item is string => typeof item === "string")
373
+ .map((item) => item.slice(0, 500))
374
+ .slice(0, 8)
375
+ : [],
376
+ indexState,
377
+ };
378
+ }
379
+
380
+ /** Reads only the native, retention-bound chapter manifest; malformed rows never escape. */
381
+ export function readScreenMemoryChapters(
382
+ storeDir: string,
383
+ ): ScreenMemoryChaptersDocument | null {
384
+ try {
385
+ const raw: unknown = JSON.parse(
386
+ fs.readFileSync(path.join(storeDir, "chapters.json"), "utf8"),
387
+ );
388
+ if (
389
+ !isRecord(raw) ||
390
+ (raw.schemaVersion !== 1 && raw.schemaVersion !== 2) ||
391
+ !isTimestamp(raw.generatedAt) ||
392
+ typeof raw.state !== "string" ||
393
+ !Array.isArray(raw.chapters)
394
+ )
395
+ return null;
396
+ const chapters = raw.chapters
397
+ .map(parseChapter)
398
+ .filter((chapter): chapter is ScreenMemoryChapter => Boolean(chapter));
399
+ if (chapters.length !== raw.chapters.length) return null;
400
+ return {
401
+ schemaVersion: raw.schemaVersion,
402
+ generatedAt: raw.generatedAt,
403
+ state: raw.state,
404
+ coverage: raw.coverage ?? null,
405
+ chapters,
406
+ };
407
+ } catch {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ type MatchProvenance = {
413
+ sourceType: string;
414
+ sourceKind?: string;
415
+ confidence?: number;
416
+ matchedTerms: string[];
417
+ };
418
+
419
+ function textIncludes(value: string, term: string): boolean {
420
+ return value.toLowerCase().includes(term);
421
+ }
422
+
423
+ function isMicrophoneEvidence(
424
+ ref: ScreenMemoryChapter["evidenceRefs"][number],
425
+ ) {
426
+ return /microphone|mic/.test(
427
+ `${ref.sourceType} ${ref.sourceKind ?? ""}`.toLowerCase(),
428
+ );
429
+ }
430
+
431
+ function isStrongScreenEvidence(
432
+ ref: ScreenMemoryChapter["evidenceRefs"][number],
433
+ ) {
434
+ return /accessibility|ocr|visible|screen|visual/.test(
435
+ `${ref.sourceType} ${ref.sourceKind ?? ""}`.toLowerCase(),
436
+ );
437
+ }
438
+
439
+ export function searchScreenMemoryChapters(
440
+ document: ScreenMemoryChaptersDocument,
441
+ query: string,
442
+ limit = 5,
443
+ clientHint?: string,
444
+ ): Array<
445
+ ScreenMemoryChapter & {
446
+ score: number;
447
+ matchReasons: string[];
448
+ matchProvenance: MatchProvenance[];
449
+ }
450
+ > {
451
+ const terms = (
452
+ query.toLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []
453
+ ).filter((term) => !SEARCH_STOP_WORDS.has(term));
454
+ const exactPhrase = query.trim().toLowerCase();
455
+ const hint = clientHint?.toLowerCase().slice(0, 500) ?? "";
456
+ return document.chapters
457
+ .map((chapter) => {
458
+ const strongText = [
459
+ chapter.label,
460
+ chapter.summary,
461
+ ...chapter.keywords,
462
+ chapter.accessibilitySummary,
463
+ ...chapter.accessibilityKeywords,
464
+ ]
465
+ .filter(Boolean)
466
+ .join(" ");
467
+ const contextText = chapter.contexts
468
+ .flatMap((context) => [
469
+ context.appName,
470
+ context.windowTitle,
471
+ context.bundleId,
472
+ ])
473
+ .filter(Boolean)
474
+ .join(" ");
475
+ const strongMatched = terms.filter((term) =>
476
+ textIncludes(strongText, term),
477
+ );
478
+ const contextMatched = terms.filter((term) =>
479
+ textIncludes(contextText, term),
480
+ );
481
+ const evidenceMatches = chapter.evidenceRefs.flatMap((ref) => {
482
+ const matchedTerms = terms.filter((term) =>
483
+ (ref.keywords ?? []).some((keyword) => textIncludes(keyword, term)),
484
+ );
485
+ return matchedTerms.length ? [{ ref, matchedTerms }] : [];
486
+ });
487
+ const corroboratedTerms = new Set([
488
+ ...strongMatched,
489
+ ...evidenceMatches
490
+ .filter(({ ref }) => isStrongScreenEvidence(ref))
491
+ .flatMap(({ matchedTerms }) => matchedTerms),
492
+ ]);
493
+ const evidenceScore = evidenceMatches.reduce(
494
+ (sum, { ref, matchedTerms }) => {
495
+ const sourceWeight = isStrongScreenEvidence(ref)
496
+ ? 1.5
497
+ : isMicrophoneEvidence(ref)
498
+ ? matchedTerms.filter((term) => corroboratedTerms.has(term))
499
+ .length
500
+ ? 0.12
501
+ : 0
502
+ : /transcript/.test(
503
+ `${ref.sourceType} ${ref.sourceKind ?? ""}`.toLowerCase(),
504
+ )
505
+ ? 0.3
506
+ : 0.5;
507
+ return (
508
+ sum + sourceWeight * matchedTerms.length * (ref.confidence ?? 1)
509
+ );
510
+ },
511
+ 0,
512
+ );
513
+ const exactStrong =
514
+ exactPhrase.length >= 2 && textIncludes(strongText, exactPhrase);
515
+ const exactEvidence = chapter.evidenceRefs.some(
516
+ (ref) =>
517
+ isStrongScreenEvidence(ref) &&
518
+ (ref.keywords ?? []).some((keyword) =>
519
+ textIncludes(keyword, exactPhrase),
520
+ ),
521
+ );
522
+ const semanticScore = terms.length
523
+ ? (strongMatched.length * 1.2 +
524
+ contextMatched.length * 0.2 +
525
+ evidenceScore) /
526
+ terms.length +
527
+ (exactStrong || exactEvidence ? 2 : 0)
528
+ : 0;
529
+ // A hint may only settle an otherwise close semantic result; it cannot make a non-match win.
530
+ const hintScore =
531
+ semanticScore > 0 &&
532
+ hint &&
533
+ (textIncludes(strongText, hint) || textIncludes(contextText, hint))
534
+ ? 0.03
535
+ : 0;
536
+ const score = semanticScore + hintScore + chapter.confidence * 0.001;
537
+ return {
538
+ ...chapter,
539
+ score,
540
+ semanticScore,
541
+ matchReasons: [
542
+ ...(exactStrong || exactEvidence
543
+ ? [
544
+ `exact phrase \"${exactPhrase}\" matched visible or accessibility evidence`,
545
+ ]
546
+ : []),
547
+ ...strongMatched.map(
548
+ (term) =>
549
+ `matched \"${term}\" in chapter or accessibility evidence`,
550
+ ),
551
+ ...contextMatched.map(
552
+ (term) => `matched \"${term}\" in app or document context`,
553
+ ),
554
+ ...evidenceMatches.flatMap(({ ref, matchedTerms }) =>
555
+ matchedTerms.map((term) =>
556
+ isMicrophoneEvidence(ref) && !corroboratedTerms.has(term)
557
+ ? `ignored uncorroborated microphone match \"${term}\"`
558
+ : `matched \"${term}\" in ${ref.sourceType}${ref.sourceKind ? `/${ref.sourceKind}` : ""} evidence`,
559
+ ),
560
+ ),
561
+ ...(hintScore ? ["client hint weakly broke a close tie"] : []),
562
+ ],
563
+ matchProvenance: evidenceMatches.map(({ ref, matchedTerms }) => ({
564
+ sourceType: ref.sourceType,
565
+ ...(ref.sourceKind ? { sourceKind: ref.sourceKind } : {}),
566
+ ...(typeof ref.confidence === "number"
567
+ ? { confidence: ref.confidence }
568
+ : {}),
569
+ matchedTerms,
570
+ })),
571
+ };
572
+ })
573
+ .filter((chapter) => chapter.semanticScore > 0)
574
+ .sort((a, b) => b.score - a.score || b.endedAt.localeCompare(a.endedAt))
575
+ .slice(0, Math.min(Math.max(Math.trunc(limit), 1), MAX_CHAPTERS))
576
+ .map(({ semanticScore: _semanticScore, ...chapter }) => chapter);
577
+ }
578
+
579
+ export function projectScreenMemoryChapterCandidate(
580
+ chapter: ReturnType<typeof searchScreenMemoryChapters>[number],
581
+ ) {
582
+ const evidenceSources = [
583
+ ...new Set(
584
+ chapter.evidenceRefs.map(
585
+ (reference) =>
586
+ `${reference.sourceType}${reference.sourceKind ? `/${reference.sourceKind}` : ""}`,
587
+ ),
588
+ ),
589
+ ].slice(0, 8);
590
+ return {
591
+ id: chapter.id,
592
+ revision: chapter.revision,
593
+ aliases: chapter.aliases.slice(0, 8),
594
+ startedAt: chapter.startedAt,
595
+ endedAt: chapter.endedAt,
596
+ durationMs: chapter.durationMs,
597
+ label: redactCredentialText(chapter.label),
598
+ summary: redactCredentialText(chapter.summary),
599
+ keywords: chapter.keywords.slice(0, 8),
600
+ confidence: chapter.confidence,
601
+ sceneCount: chapter.sceneRefs.length,
602
+ evidenceSources,
603
+ contexts: chapter.contexts.slice(0, 6),
604
+ representativeMoments: chapter.representativeMoments
605
+ .slice(0, 8)
606
+ .map(({ momentId, capturedAt, reason }) => ({
607
+ momentId,
608
+ capturedAt,
609
+ reason,
610
+ })),
611
+ representativeCoverage: chapter.representativeCoverage,
612
+ ambiguityReasons: chapter.ambiguityReasons.slice(0, 8),
613
+ indexState: chapter.indexState,
614
+ score: chapter.score,
615
+ matchReasons: chapter.matchReasons.slice(0, 8),
616
+ matchProvenance: chapter.matchProvenance.slice(0, 6),
617
+ };
618
+ }
619
+
620
+ function decodeFrameWithFfmpeg(segmentPath: string, offsetMs: number): Buffer {
621
+ const result = spawnSync(
622
+ "ffmpeg",
623
+ [
624
+ "-v",
625
+ "error",
626
+ "-ss",
627
+ (Math.max(0, offsetMs) / 1000).toFixed(3),
628
+ "-i",
629
+ segmentPath,
630
+ "-frames:v",
631
+ "1",
632
+ "-vf",
633
+ `scale='min(${MAX_FRAME_EDGE},iw)':-2`,
634
+ "-f",
635
+ "image2pipe",
636
+ "-vcodec",
637
+ "mjpeg",
638
+ "pipe:1",
639
+ ],
640
+ { encoding: null, maxBuffer: MAX_FRAME_BYTES + 1 },
641
+ );
642
+ if (result.error || result.status !== 0 || !result.stdout?.length)
643
+ throw new Error(
644
+ "Local frame decoding is unavailable because ffmpeg could not decode this retained segment.",
645
+ );
646
+ if (result.stdout.length > MAX_FRAME_BYTES)
647
+ throw new Error("The decoded frame exceeded the local byte cap.");
648
+ return result.stdout;
649
+ }
650
+
651
+ function retainedSegmentAt(
652
+ storeDir: string,
653
+ timestamp: string,
654
+ ): ScreenMemorySegment {
655
+ const target = Date.parse(timestamp);
656
+ if (!Number.isFinite(target))
657
+ throw new Error("timestamp must be a valid RFC3339 timestamp.");
658
+ if (target > Date.now() + 5_000)
659
+ throw new Error("A Rewind frame cannot be requested from the future.");
660
+ const segment = readSegments(storeDir).find(
661
+ (candidate) =>
662
+ Date.parse(candidate.startedAt) <= target &&
663
+ target <= Date.parse(candidate.endedAt),
664
+ );
665
+ if (!segment)
666
+ throw new Error(
667
+ "No clean retained Rewind segment covers that timestamp (it may be outside retention or in a coverage gap).",
668
+ );
669
+ if (!segment.path || !fs.existsSync(segment.path))
670
+ throw new Error("That Rewind segment is no longer retained locally.");
671
+ let storeRoot: string;
672
+ let canonicalSegmentPath: string;
673
+ try {
674
+ storeRoot = fs.realpathSync(storeDir);
675
+ canonicalSegmentPath = fs.realpathSync(segment.path);
676
+ } catch {
677
+ throw new Error("That Rewind segment is no longer retained locally.");
678
+ }
679
+ const relative = path.relative(storeRoot, canonicalSegmentPath);
680
+ if (
681
+ path.isAbsolute(relative) ||
682
+ relative === ".." ||
683
+ relative.startsWith(`..${path.sep}`) ||
684
+ !fs.statSync(canonicalSegmentPath).isFile()
685
+ ) {
686
+ throw new Error("That Rewind segment is outside the local memory store.");
687
+ }
688
+ if (segment.corrupt)
689
+ throw new Error(
690
+ "That Rewind segment is corrupt and cannot provide a frame.",
691
+ );
692
+ if (segment.excluded || segment.exclusionTainted || segment.tainted)
693
+ throw new Error(
694
+ "That Rewind interval was excluded from capture and cannot provide a frame.",
695
+ );
696
+ return { ...segment, path: canonicalSegmentPath };
697
+ }
698
+
699
+ function frameContent(
700
+ segment: ScreenMemorySegment,
701
+ timestamp: string,
702
+ decoder: ScreenMemoryFrameDecoder,
703
+ ) {
704
+ const offsetMs = Date.parse(timestamp) - Date.parse(segment.startedAt);
705
+ const bytes = decoder(segment.path, offsetMs);
706
+ if (bytes.length > MAX_FRAME_BYTES)
707
+ throw new Error("The decoded frame exceeded the local byte cap.");
708
+ return {
709
+ timestamp: new Date(Date.parse(segment.startedAt) + offsetMs).toISOString(),
710
+ segmentId: segment.id,
711
+ image: {
712
+ type: "image" as const,
713
+ data: bytes.toString("base64"),
714
+ mimeType: "image/jpeg",
715
+ },
716
+ };
717
+ }
718
+
719
+ export function readScreenMemoryFrame(
720
+ storeDir: string,
721
+ timestamp: string,
722
+ decoder: ScreenMemoryFrameDecoder = decodeFrameWithFfmpeg,
723
+ ) {
724
+ return frameContent(
725
+ retainedSegmentAt(storeDir, timestamp),
726
+ timestamp,
727
+ decoder,
728
+ );
729
+ }
730
+
731
+ export function selectContactSheetTimestamps(
732
+ startAt: string,
733
+ endAt: string,
734
+ count: number,
735
+ representative: string[] = [],
736
+ ): string[] {
737
+ const started = Date.parse(startAt);
738
+ const duration = Date.parse(endAt) - started;
739
+ const representatives = [
740
+ ...new Set(
741
+ representative
742
+ .filter(isTimestamp)
743
+ .filter(
744
+ (timestamp) =>
745
+ Date.parse(timestamp) >= started &&
746
+ Date.parse(timestamp) <= started + duration,
747
+ ),
748
+ ),
749
+ ];
750
+ // Reserve one slot for temporal coverage whenever possible: a contact sheet
751
+ // should not become a cluster of favorite frames with no sense of the span.
752
+ const preferred = representatives.slice(0, Math.max(0, count - 1));
753
+ const even = Array.from({ length: count }, (_, index) =>
754
+ new Date(started + (duration * (index + 0.5)) / count).toISOString(),
755
+ );
756
+ const selected = [...preferred];
757
+ while (selected.length < count) {
758
+ const next = even
759
+ .filter((timestamp) => !selected.includes(timestamp))
760
+ .sort((a, b) => {
761
+ const distance = (timestamp: string) =>
762
+ Math.min(
763
+ ...selected.map((chosen) =>
764
+ Math.abs(Date.parse(timestamp) - Date.parse(chosen)),
765
+ ),
766
+ Number.POSITIVE_INFINITY,
767
+ );
768
+ return distance(b) - distance(a);
769
+ })[0];
770
+ if (!next) break;
771
+ selected.push(next);
772
+ }
773
+ return selected;
774
+ }
775
+
776
+ export function contactSheetRangeIsValid(
777
+ durationMs: number,
778
+ usesChapterMoments: boolean,
779
+ ): boolean {
780
+ return durationMs >= 0 && (usesChapterMoments || durationMs <= 5 * 60_000);
781
+ }
782
+
148
783
  function recentSegments(
149
784
  storeDir: string,
150
785
  minutes: number,
@@ -176,6 +811,341 @@ function textResult(value: unknown) {
176
811
  };
177
812
  }
178
813
 
814
+ export function redactCredentialText(value: string): string {
815
+ return value
816
+ .replace(
817
+ /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{12,}|AKIA[A-Z0-9]{16})\b/g,
818
+ "[REDACTED CREDENTIAL]",
819
+ )
820
+ .replace(/\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/gi, "Bearer [REDACTED]")
821
+ .replace(
822
+ /\b(api[_-]?key|access[_-]?token|password|secret)\s*[:=]\s*([^\s,;]{4,})/gi,
823
+ "$1=[REDACTED]",
824
+ );
825
+ }
826
+
827
+ const sanitizedEgressStores = new Set<string>();
828
+
829
+ function sanitizeLegacyEgressLog(storeDir: string): void {
830
+ if (sanitizedEgressStores.has(storeDir)) return;
831
+ sanitizedEgressStores.add(storeDir);
832
+ const logPath = path.join(storeDir, "egress.jsonl");
833
+ if (!fs.existsSync(logPath)) return;
834
+ const sanitized = fs
835
+ .readFileSync(logPath, "utf-8")
836
+ .split("\n")
837
+ .filter(Boolean)
838
+ .flatMap((line) => {
839
+ try {
840
+ const event = JSON.parse(line) as Record<string, unknown>;
841
+ const packet = event.packet as
842
+ | { evidence?: Array<Record<string, unknown>> }
843
+ | null
844
+ | undefined;
845
+ if (!event.receipt && Array.isArray(packet?.evidence)) {
846
+ event.receipt = {
847
+ evidence: packet.evidence.map((evidence) => ({
848
+ id: evidence.id,
849
+ momentId: evidence.momentId,
850
+ sourceType: evidence.sourceType,
851
+ capturedAt: evidence.capturedAt ?? null,
852
+ })),
853
+ };
854
+ }
855
+ delete event.packet;
856
+ delete event.reason;
857
+ event.packetBytes =
858
+ typeof event.packetBytes === "number" ? event.packetBytes : 0;
859
+ return [JSON.stringify(event)];
860
+ } catch {
861
+ // A malformed audit row cannot provide reliable provenance. Dropping it
862
+ // also prevents arbitrary stale text from surviving this migration.
863
+ return [];
864
+ }
865
+ });
866
+ const temporaryPath = `${logPath}.sanitize-${process.pid}`;
867
+ fs.writeFileSync(
868
+ temporaryPath,
869
+ sanitized.length > 0 ? `${sanitized.join("\n")}\n` : "",
870
+ { encoding: "utf-8", mode: 0o600 },
871
+ );
872
+ fs.renameSync(temporaryPath, logPath);
873
+ fs.chmodSync(logPath, 0o600);
874
+ }
875
+
876
+ function appendEgressEvent(storeDir: string, event: Record<string, unknown>) {
877
+ fs.mkdirSync(storeDir, { recursive: true });
878
+ fs.chmodSync(storeDir, 0o700);
879
+ sanitizeLegacyEgressLog(storeDir);
880
+ fs.appendFileSync(
881
+ path.join(storeDir, "egress.jsonl"),
882
+ `${JSON.stringify(event)}\n`,
883
+ { encoding: "utf-8", mode: 0o600 },
884
+ );
885
+ fs.chmodSync(path.join(storeDir, "egress.jsonl"), 0o600);
886
+ }
887
+
888
+ type EgressEvidenceReference = {
889
+ id: string;
890
+ momentId: string;
891
+ sourceType: string;
892
+ capturedAt: string | null;
893
+ };
894
+
895
+ /**
896
+ * Records that bounded evidence was returned without preserving its text.
897
+ * The originating Screen Memory interval remains the only durable home for
898
+ * transcripts and chapter summaries.
899
+ */
900
+ function appendEgressReceipt(
901
+ storeDir: string,
902
+ requestId: string,
903
+ operation: string,
904
+ evidence: EgressEvidenceReference[],
905
+ ): void {
906
+ const occurredAt = new Date().toISOString();
907
+ const receipt = {
908
+ evidence: evidence.map(({ id, momentId, sourceType, capturedAt }) => ({
909
+ id,
910
+ momentId,
911
+ sourceType,
912
+ capturedAt,
913
+ })),
914
+ };
915
+ appendEgressEvent(storeDir, {
916
+ requestId,
917
+ occurredAt,
918
+ state: "prepared",
919
+ operation,
920
+ receipt,
921
+ evidenceCount: receipt.evidence.length,
922
+ packetBytes: 0,
923
+ error: null,
924
+ });
925
+ appendEgressEvent(storeDir, {
926
+ requestId,
927
+ occurredAt: new Date().toISOString(),
928
+ state: "completed",
929
+ operation,
930
+ receipt: null,
931
+ evidenceCount: receipt.evidence.length,
932
+ packetBytes: 0,
933
+ error: null,
934
+ });
935
+ }
936
+
937
+ export function appendLocalEvidenceReceipt(
938
+ storeDir: string,
939
+ operation: string,
940
+ evidence: Array<{ timestamp: string; segmentId: string }>,
941
+ ): void {
942
+ appendEgressEvent(storeDir, {
943
+ requestId: `local-${operation}-${Date.now()}-${process.pid}`,
944
+ occurredAt: new Date().toISOString(),
945
+ state: "local-evidence-read",
946
+ operation,
947
+ receipt: {
948
+ frames: evidence.map(({ timestamp, segmentId }) => ({
949
+ timestamp,
950
+ segmentId,
951
+ })),
952
+ },
953
+ evidenceCount: evidence.length,
954
+ packetBytes: 0,
955
+ error: null,
956
+ });
957
+ }
958
+
959
+ function handoffDir(storeDir: string): string {
960
+ return path.join(storeDir, "agent-handoffs");
961
+ }
962
+
963
+ function handoffPath(storeDir: string, requestId: string): string {
964
+ const safeId = requestId.replace(/[^a-zA-Z0-9_-]/g, "");
965
+ if (!safeId || safeId !== requestId)
966
+ throw new Error("Invalid handoff request ID.");
967
+ return path.join(handoffDir(storeDir), `${safeId}.json`);
968
+ }
969
+
970
+ function writePrivateJson(file: string, value: unknown): void {
971
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
972
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, {
973
+ encoding: "utf-8",
974
+ mode: 0o600,
975
+ });
976
+ fs.chmodSync(file, 0o600);
977
+ }
978
+
979
+ function parseHandoffRange(args: Record<string, unknown>) {
980
+ if (typeof args.startAt !== "string" || typeof args.endAt !== "string") {
981
+ throw new Error("startAt and endAt must be RFC3339 timestamps.");
982
+ }
983
+ const startAt = new Date(args.startAt);
984
+ const endAt = new Date(args.endAt);
985
+ if (
986
+ !Number.isFinite(startAt.getTime()) ||
987
+ !Number.isFinite(endAt.getTime())
988
+ ) {
989
+ throw new Error("startAt and endAt must be valid RFC3339 timestamps.");
990
+ }
991
+ const durationMs = endAt.getTime() - startAt.getTime();
992
+ if (durationMs < 1_000 || durationMs > 5 * 60_000) {
993
+ throw new Error(
994
+ "A Rewind Clip handoff must be between one second and five minutes.",
995
+ );
996
+ }
997
+ if (endAt.getTime() > Date.now() + 5_000) {
998
+ throw new Error("A Rewind Clip handoff cannot include future time.");
999
+ }
1000
+ return {
1001
+ startAt: startAt.toISOString(),
1002
+ endAt: endAt.toISOString(),
1003
+ durationMs,
1004
+ };
1005
+ }
1006
+
1007
+ export function screenMemoryMcpToolDefinitions() {
1008
+ return [
1009
+ {
1010
+ name: "screen_memory_status",
1011
+ description:
1012
+ "Read local Clips Screen Memory status, retention, disk usage, and connection health without exposing archive paths.",
1013
+ inputSchema: { type: "object", properties: {} },
1014
+ },
1015
+ {
1016
+ name: "screen_memory_recent_context",
1017
+ description:
1018
+ "Search bounded local Screen Memory evidence. Returns coverage, explicit gaps, typed app-context/OCR/transcript evidence when local files contain it, and no media bytes or images.",
1019
+ inputSchema: {
1020
+ type: "object",
1021
+ properties: {
1022
+ query: {
1023
+ type: "string",
1024
+ description: "Optional case-insensitive text filter.",
1025
+ },
1026
+ minutes: {
1027
+ type: "number",
1028
+ description: "Lookback window in minutes. Defaults to 30.",
1029
+ },
1030
+ limit: {
1031
+ type: "number",
1032
+ description: "Maximum evidence items to return. Defaults to 40.",
1033
+ },
1034
+ },
1035
+ },
1036
+ },
1037
+ {
1038
+ name: "screen_memory_recent_segments",
1039
+ description:
1040
+ "List recent local Screen Memory segment references and timestamps. Media paths and bytes stay behind Clips' bounded private Clip handoff boundary.",
1041
+ inputSchema: {
1042
+ type: "object",
1043
+ properties: {
1044
+ minutes: {
1045
+ type: "number",
1046
+ description: "Lookback window in minutes. Defaults to 30.",
1047
+ },
1048
+ },
1049
+ },
1050
+ },
1051
+ {
1052
+ name: "screen_memory_search_chapters",
1053
+ description:
1054
+ "Search locally generated, retention-bound Rewind work chapters. Returns bounded scored candidates, coverage and ambiguity for the connected agent; never archive paths or media bytes.",
1055
+ inputSchema: {
1056
+ type: "object",
1057
+ required: ["query"],
1058
+ properties: {
1059
+ query: {
1060
+ type: "string",
1061
+ description:
1062
+ "Question or topic to match against local chapter labels, summaries, context, and evidence types.",
1063
+ },
1064
+ minutes: {
1065
+ type: "number",
1066
+ description: "Optional recent-window cap, up to 24 hours.",
1067
+ },
1068
+ limit: {
1069
+ type: "number",
1070
+ description: "Maximum candidates, up to 12.",
1071
+ },
1072
+ clientHint: {
1073
+ type: "string",
1074
+ description:
1075
+ "Optional weak tie-breaker such as project or working directory; cannot hide a stronger semantic match.",
1076
+ },
1077
+ },
1078
+ },
1079
+ },
1080
+ {
1081
+ name: "screen_memory_frame_at",
1082
+ description:
1083
+ "Decode one bounded local JPEG at an exact RFC3339 Rewind timestamp. Returns image content and opaque segment reference only; no archive path, upload, motion, or audio.",
1084
+ inputSchema: {
1085
+ type: "object",
1086
+ required: ["timestamp"],
1087
+ properties: {
1088
+ timestamp: {
1089
+ type: "string",
1090
+ description: "Exact RFC3339 timestamp.",
1091
+ },
1092
+ reason: {
1093
+ type: "string",
1094
+ description: "Short reason for this bounded local evidence read.",
1095
+ },
1096
+ },
1097
+ },
1098
+ },
1099
+ {
1100
+ name: "screen_memory_contact_sheet",
1101
+ description:
1102
+ "Return up to eight bounded, timestamped local Rewind frames from one chapter or a range of no more than five minutes. Prefer representative moments; no paths or uploads.",
1103
+ inputSchema: {
1104
+ type: "object",
1105
+ properties: {
1106
+ chapterId: { type: "string" },
1107
+ startAt: { type: "string" },
1108
+ endAt: { type: "string" },
1109
+ count: {
1110
+ type: "number",
1111
+ description: "1 to 8 frames; defaults to 4.",
1112
+ },
1113
+ reason: { type: "string" },
1114
+ },
1115
+ },
1116
+ },
1117
+ {
1118
+ name: "screen_memory_request_clip",
1119
+ description:
1120
+ "Request one bounded private Clip from a Rewind interval when visual or audio inspection is necessary. Clips reviews it first by default, unless the user disabled review in Settings. Raw archive paths are never returned.",
1121
+ inputSchema: {
1122
+ type: "object",
1123
+ required: ["startAt", "endAt"],
1124
+ properties: {
1125
+ startAt: { type: "string", description: "RFC3339 start timestamp." },
1126
+ endAt: { type: "string", description: "RFC3339 end timestamp." },
1127
+ reason: {
1128
+ type: "string",
1129
+ description: "Short user-facing reason this media range is needed.",
1130
+ },
1131
+ includeMicrophone: { type: "boolean" },
1132
+ includeSystemAudio: { type: "boolean" },
1133
+ },
1134
+ },
1135
+ },
1136
+ {
1137
+ name: "screen_memory_handoff_status",
1138
+ description:
1139
+ "Check whether a bounded Rewind Clip request is awaiting review, processing, ready, declined, or failed. Ready responses contain only the private agent URL and timestamps, never local archive paths.",
1140
+ inputSchema: {
1141
+ type: "object",
1142
+ required: ["requestId"],
1143
+ properties: { requestId: { type: "string" } },
1144
+ },
1145
+ },
1146
+ ];
1147
+ }
1148
+
179
1149
  export async function runScreenMemoryMCPStdio(
180
1150
  opts: RunScreenMemoryMCPStdioOptions = {},
181
1151
  ): Promise<void> {
@@ -194,50 +1164,7 @@ export async function runScreenMemoryMCPStdio(
194
1164
  );
195
1165
 
196
1166
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
197
- tools: [
198
- {
199
- name: "screen_memory_status",
200
- description:
201
- "Read local Clips Screen Memory status, retention, disk usage, and store path. Local-only.",
202
- inputSchema: { type: "object", properties: {} },
203
- },
204
- {
205
- name: "screen_memory_recent_context",
206
- description:
207
- "Search recent local Screen Memory app/window context. Returns timestamps, app names, window titles, and bundle ids; does not return images.",
208
- inputSchema: {
209
- type: "object",
210
- properties: {
211
- query: {
212
- type: "string",
213
- description: "Optional case-insensitive text filter.",
214
- },
215
- minutes: {
216
- type: "number",
217
- description: "Lookback window in minutes. Defaults to 30.",
218
- },
219
- limit: {
220
- type: "number",
221
- description: "Maximum events to return. Defaults to 40.",
222
- },
223
- },
224
- },
225
- },
226
- {
227
- name: "screen_memory_recent_segments",
228
- description:
229
- "List recent local Screen Memory video segment file paths and timestamps. Use only when the user asks to inspect or export local context.",
230
- inputSchema: {
231
- type: "object",
232
- properties: {
233
- minutes: {
234
- type: "number",
235
- description: "Lookback window in minutes. Defaults to 30.",
236
- },
237
- },
238
- },
239
- },
240
- ],
1167
+ tools: screenMemoryMcpToolDefinitions(),
241
1168
  }));
242
1169
 
243
1170
  server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
@@ -256,24 +1183,353 @@ export async function runScreenMemoryMCPStdio(
256
1183
  (sum, segment) => sum + (segment.bytes || 0),
257
1184
  0,
258
1185
  ),
259
- storeDir,
1186
+ note: "The Screen Memory store is connected. Local archive paths are intentionally not exposed to agents.",
260
1187
  });
261
1188
  }
262
1189
  if (name === "screen_memory_recent_context") {
263
- return textResult({
264
- storeDir,
265
- events: recentEvents(storeDir, {
1190
+ const result = await queryScreenMemoryContext(
1191
+ {
266
1192
  query: typeof args.query === "string" ? args.query : undefined,
267
- minutes: typeof args.minutes === "number" ? args.minutes : undefined,
1193
+ sinceMinutes:
1194
+ typeof args.minutes === "number" ? args.minutes : undefined,
268
1195
  limit: typeof args.limit === "number" ? args.limit : undefined,
269
- }),
270
- });
1196
+ },
1197
+ { env: { ...env, AGENT_NATIVE_SCREEN_MEMORY_DIR: storeDir } },
1198
+ );
1199
+ const sanitizedEvidence = result.evidence.map((item) => ({
1200
+ ...item,
1201
+ excerpt: redactCredentialText(item.excerpt),
1202
+ }));
1203
+ const sanitizedItems = result.items.map((item) => ({
1204
+ ...item,
1205
+ text: redactCredentialText(item.text),
1206
+ sourceFile: "local-screen-memory",
1207
+ }));
1208
+ const requestId = `egress-mcp-${Date.now()}-${process.pid}`;
1209
+ const packet = {
1210
+ question:
1211
+ typeof args.query === "string" && args.query.trim()
1212
+ ? redactCredentialText(args.query.trim().slice(0, 4_000))
1213
+ : "Recent Screen Memory context",
1214
+ evidence: sanitizedEvidence.slice(0, 20).map((item) => ({
1215
+ id: item.id,
1216
+ momentId: item.momentId,
1217
+ sourceType: item.sourceType,
1218
+ capturedAt: item.capturedAt,
1219
+ excerpt: item.excerpt.slice(0, 1_200),
1220
+ })),
1221
+ };
1222
+ appendEgressReceipt(
1223
+ storeDir,
1224
+ requestId,
1225
+ "recent-context",
1226
+ packet.evidence,
1227
+ );
1228
+ return textResult(
1229
+ // Keep the old MCP envelope available while adding the typed contract.
1230
+ {
1231
+ events: sanitizedItems,
1232
+ ...result,
1233
+ items: sanitizedItems,
1234
+ evidence: sanitizedEvidence,
1235
+ contextFiles: [],
1236
+ egress: {
1237
+ requestId,
1238
+ packet,
1239
+ note: "A content-free local activity receipt was recorded before this bounded text packet was returned.",
1240
+ },
1241
+ },
1242
+ );
271
1243
  }
272
1244
  if (name === "screen_memory_recent_segments") {
273
1245
  const minutes = typeof args.minutes === "number" ? args.minutes : 30;
1246
+ const segments = recentSegments(storeDir, minutes);
1247
+ const requestId = `egress-mcp-${Date.now()}-${process.pid}`;
1248
+ const packet = {
1249
+ question: `List recent Rewind segment references from the previous ${Math.max(1, Math.min(minutes, 24 * 60))} minutes`,
1250
+ evidence: segments.slice(0, 20).map((segment) => ({
1251
+ id: segment.id,
1252
+ momentId: `segment:${segment.id}`,
1253
+ sourceType: "app-context",
1254
+ capturedAt: segment.startedAt,
1255
+ excerpt: `Local media segment reference from ${segment.startedAt} to ${segment.endedAt}; media path and bytes were not exposed.`,
1256
+ })),
1257
+ };
1258
+ appendEgressReceipt(
1259
+ storeDir,
1260
+ requestId,
1261
+ "recent-segments",
1262
+ packet.evidence,
1263
+ );
1264
+ return textResult({
1265
+ localOnly: true,
1266
+ mediaApprovalRequired: true,
1267
+ segments: segments.map((segment) => ({
1268
+ id: segment.id,
1269
+ startedAt: segment.startedAt,
1270
+ endedAt: segment.endedAt,
1271
+ mimeType: segment.mimeType,
1272
+ durationMs: segment.durationMs,
1273
+ })),
1274
+ egress: {
1275
+ requestId,
1276
+ packet,
1277
+ note: "A content-free local activity receipt was recorded before this bounded metadata packet was returned.",
1278
+ },
1279
+ });
1280
+ }
1281
+ if (name === "screen_memory_search_chapters") {
1282
+ if (typeof args.query !== "string" || !args.query.trim())
1283
+ throw new Error("query is required.");
1284
+ const document = readScreenMemoryChapters(storeDir);
1285
+ if (!document)
1286
+ throw new Error(
1287
+ "No valid local Rewind chapters index is available yet.",
1288
+ );
1289
+ const minutes =
1290
+ typeof args.minutes === "number" && Number.isFinite(args.minutes)
1291
+ ? Math.max(1, Math.min(Math.trunc(args.minutes), 24 * 60))
1292
+ : null;
1293
+ const cutoff = minutes ? Date.now() - minutes * 60_000 : null;
1294
+ const boundedDocument = cutoff
1295
+ ? {
1296
+ ...document,
1297
+ chapters: document.chapters.filter(
1298
+ (chapter) => Date.parse(chapter.endedAt) >= cutoff,
1299
+ ),
1300
+ }
1301
+ : document;
1302
+ const candidates = searchScreenMemoryChapters(
1303
+ boundedDocument,
1304
+ args.query.trim(),
1305
+ typeof args.limit === "number" ? args.limit : 5,
1306
+ typeof args.clientHint === "string" ? args.clientHint : undefined,
1307
+ );
1308
+ const ambiguous =
1309
+ candidates.length > 1 &&
1310
+ Math.abs(candidates[0].score - candidates[1].score) <= 0.05;
1311
+ const packet = {
1312
+ question: redactCredentialText(args.query.trim().slice(0, 4_000)),
1313
+ evidence: candidates.map((chapter) => ({
1314
+ id: chapter.id,
1315
+ momentId: chapter.id,
1316
+ sourceType: "chapter",
1317
+ capturedAt: chapter.startedAt,
1318
+ excerpt: redactCredentialText(
1319
+ `${chapter.label}: ${chapter.summary}`,
1320
+ ).slice(0, 1_200),
1321
+ })),
1322
+ };
1323
+ const requestId = `egress-mcp-${Date.now()}-${process.pid}`;
1324
+ appendEgressReceipt(
1325
+ storeDir,
1326
+ requestId,
1327
+ "search-chapters",
1328
+ packet.evidence,
1329
+ );
274
1330
  return textResult({
1331
+ localOnly: true,
1332
+ generatedAt: document.generatedAt,
1333
+ state: document.state,
1334
+ coverage: document.coverage,
1335
+ gaps:
1336
+ isRecord(document.coverage) && Array.isArray(document.coverage.gaps)
1337
+ ? document.coverage.gaps
1338
+ : [],
1339
+ indexState: candidates.some(
1340
+ (candidate) => candidate.indexState === "pending",
1341
+ )
1342
+ ? "pending"
1343
+ : candidates.some((candidate) => candidate.indexState === "partial")
1344
+ ? "partial"
1345
+ : "ready",
1346
+ ambiguous,
1347
+ candidates: candidates.map(projectScreenMemoryChapterCandidate),
1348
+ egress: {
1349
+ requestId,
1350
+ packet,
1351
+ note: "A content-free local activity receipt was recorded before this bounded chapter packet was returned.",
1352
+ },
1353
+ });
1354
+ }
1355
+ if (name === "screen_memory_frame_at") {
1356
+ if (typeof args.timestamp !== "string")
1357
+ throw new Error("timestamp is required.");
1358
+ const frame = readScreenMemoryFrame(
1359
+ storeDir,
1360
+ args.timestamp,
1361
+ opts.decodeFrame ?? decodeFrameWithFfmpeg,
1362
+ );
1363
+ appendLocalEvidenceReceipt(storeDir, "frame-at", [
1364
+ { timestamp: frame.timestamp, segmentId: frame.segmentId },
1365
+ ]);
1366
+ return {
1367
+ content: [
1368
+ {
1369
+ type: "text" as const,
1370
+ text: JSON.stringify({
1371
+ localOnly: true,
1372
+ timestamp: frame.timestamp,
1373
+ segmentId: frame.segmentId,
1374
+ coverage: "retained clean segment",
1375
+ note: "This is a bounded local image read. Request screen_memory_request_clip for motion or audio.",
1376
+ }),
1377
+ },
1378
+ frame.image,
1379
+ ],
1380
+ };
1381
+ }
1382
+ if (name === "screen_memory_contact_sheet") {
1383
+ const document = readScreenMemoryChapters(storeDir);
1384
+ const chapter =
1385
+ typeof args.chapterId === "string"
1386
+ ? document?.chapters.find(
1387
+ (candidate) => candidate.id === args.chapterId,
1388
+ )
1389
+ : undefined;
1390
+ const startAt =
1391
+ chapter?.startedAt ??
1392
+ (typeof args.startAt === "string" ? args.startAt : null);
1393
+ const endAt =
1394
+ chapter?.endedAt ??
1395
+ (typeof args.endAt === "string" ? args.endAt : null);
1396
+ if (!startAt || !endAt || !isTimestamp(startAt) || !isTimestamp(endAt))
1397
+ throw new Error(
1398
+ "Provide a valid chapterId or RFC3339 startAt and endAt.",
1399
+ );
1400
+ const durationMs = Date.parse(endAt) - Date.parse(startAt);
1401
+ if (!contactSheetRangeIsValid(durationMs, Boolean(chapter)))
1402
+ throw new Error(
1403
+ "A contact sheet range must be between zero and five minutes.",
1404
+ );
1405
+ if (Date.parse(endAt) > Date.now() + 5_000)
1406
+ throw new Error("A Rewind contact sheet cannot include future time.");
1407
+ const count = Math.min(
1408
+ Math.max(
1409
+ Math.trunc(typeof args.count === "number" ? args.count : 4),
1410
+ 1,
1411
+ ),
1412
+ 8,
1413
+ );
1414
+ const representative = (chapter?.representativeMoments ?? [])
1415
+ .filter(
1416
+ (moment) =>
1417
+ Date.parse(moment.capturedAt) >= Date.parse(startAt) &&
1418
+ Date.parse(moment.capturedAt) <= Date.parse(endAt),
1419
+ )
1420
+ .map((moment) => moment.capturedAt);
1421
+ const timestamps = selectContactSheetTimestamps(
1422
+ startAt,
1423
+ endAt,
1424
+ count,
1425
+ representative,
1426
+ );
1427
+ const decoder = opts.decodeFrame ?? decodeFrameWithFfmpeg;
1428
+ const frames = timestamps.flatMap((timestamp) => {
1429
+ try {
1430
+ return [readScreenMemoryFrame(storeDir, timestamp, decoder)];
1431
+ } catch {
1432
+ // A chapter may span a brief coverage gap. Keep the clean retained
1433
+ // frames instead of failing the entire bounded contact sheet.
1434
+ return [];
1435
+ }
1436
+ });
1437
+ if (frames.length === 0) {
1438
+ throw new Error(
1439
+ "No clean retained Rewind frames cover the requested contact-sheet range.",
1440
+ );
1441
+ }
1442
+ appendLocalEvidenceReceipt(
275
1443
  storeDir,
276
- segments: recentSegments(storeDir, minutes),
1444
+ "contact-sheet",
1445
+ frames.map(({ timestamp, segmentId }) => ({ timestamp, segmentId })),
1446
+ );
1447
+ return {
1448
+ content: [
1449
+ {
1450
+ type: "text" as const,
1451
+ text: JSON.stringify({
1452
+ localOnly: true,
1453
+ chapterId: chapter?.id ?? null,
1454
+ startAt: new Date(Date.parse(startAt)).toISOString(),
1455
+ endAt: new Date(Date.parse(endAt)).toISOString(),
1456
+ frameCount: frames.length,
1457
+ selection: representative.length
1458
+ ? "representative moments then even coverage"
1459
+ : "even temporal coverage",
1460
+ frames: frames.map(({ timestamp, segmentId }) => ({
1461
+ timestamp,
1462
+ segmentId,
1463
+ })),
1464
+ }),
1465
+ },
1466
+ ...frames.map((frame) => frame.image),
1467
+ ],
1468
+ };
1469
+ }
1470
+ if (name === "screen_memory_request_clip") {
1471
+ const range = parseHandoffRange(args);
1472
+ const config = readFeatureConfig(storeDir);
1473
+ const requestId = `handoff-${randomUUID()}`;
1474
+ const reason =
1475
+ typeof args.reason === "string"
1476
+ ? redactCredentialText(args.reason.trim().slice(0, 500))
1477
+ : "Visual or audio context requested by the agent";
1478
+ const request = {
1479
+ requestId,
1480
+ status: "pending",
1481
+ requestedAt: new Date().toISOString(),
1482
+ ...range,
1483
+ reason,
1484
+ includeMicrophone: args.includeMicrophone !== false,
1485
+ includeSystemAudio: args.includeSystemAudio !== false,
1486
+ reviewRequired: config.reviewBeforeSending !== false,
1487
+ agentClipRetention: config.agentClipRetention ?? "forever",
1488
+ };
1489
+ writePrivateJson(handoffPath(storeDir, requestId), request);
1490
+ appendEgressEvent(storeDir, {
1491
+ requestId,
1492
+ occurredAt: request.requestedAt,
1493
+ state: "handoff-requested",
1494
+ operation: "request-clip",
1495
+ receipt: {
1496
+ mediaInterval: { startAt: range.startAt, endAt: range.endAt },
1497
+ reviewRequired: request.reviewRequired,
1498
+ },
1499
+ evidenceCount: 0,
1500
+ packetBytes: 0,
1501
+ error: null,
1502
+ });
1503
+ return textResult({
1504
+ requestId,
1505
+ status: "pending",
1506
+ startAt: range.startAt,
1507
+ endAt: range.endAt,
1508
+ reviewRequired: request.reviewRequired,
1509
+ note: request.reviewRequired
1510
+ ? "Clips is waiting for the user to review this bounded range. Poll screen_memory_handoff_status."
1511
+ : "Clips is processing the explicitly requested bounded range. Poll screen_memory_handoff_status.",
1512
+ });
1513
+ }
1514
+ if (name === "screen_memory_handoff_status") {
1515
+ if (typeof args.requestId !== "string") {
1516
+ throw new Error("requestId is required.");
1517
+ }
1518
+ const parsed = JSON.parse(
1519
+ fs.readFileSync(handoffPath(storeDir, args.requestId), "utf-8"),
1520
+ ) as Record<string, unknown>;
1521
+ return textResult({
1522
+ requestId: parsed.requestId,
1523
+ status: parsed.status,
1524
+ startAt: parsed.startAt,
1525
+ endAt: parsed.endAt,
1526
+ recordingId: parsed.recordingId,
1527
+ agentUrl: parsed.agentUrl,
1528
+ contextUrl: parsed.contextUrl,
1529
+ expiresAt: parsed.expiresAt,
1530
+ autoDeleteAt: parsed.autoDeleteAt,
1531
+ autoDeletedAt: parsed.autoDeletedAt,
1532
+ error: parsed.error,
277
1533
  });
278
1534
  }
279
1535
  throw new Error(`Unknown Screen Memory tool: ${name}`);