@arach/lattices 0.2.1 → 0.6.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -69
  3. package/apps/mac/Info.plist +43 -0
  4. package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
  5. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  6. package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  7. package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
  8. package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
  9. package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
  10. package/apps/mac/Lattices.entitlements +21 -0
  11. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  12. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  13. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  14. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  15. package/apps/mac/Resources/tap.wav +0 -0
  16. package/assets/AppIcon.icns +0 -0
  17. package/bin/assistant-intelligence.ts +912 -0
  18. package/bin/cli/capture.ts +252 -0
  19. package/bin/cli/daemon.ts +22 -0
  20. package/bin/cli/helpers.ts +105 -0
  21. package/bin/cli/layer.ts +178 -0
  22. package/bin/cli/runs.ts +43 -0
  23. package/bin/cli/search.ts +141 -0
  24. package/bin/cli/session.ts +32 -0
  25. package/bin/client.ts +17 -0
  26. package/bin/cua.ts +26 -0
  27. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  28. package/bin/handsoff-infer.ts +96 -0
  29. package/bin/handsoff-worker.ts +531 -0
  30. package/bin/infer.ts +424 -0
  31. package/bin/keychain.ts +75 -0
  32. package/bin/lattices-app.ts +655 -0
  33. package/bin/lattices-build +125 -0
  34. package/bin/lattices-build-env.ts +77 -0
  35. package/bin/lattices-dev +362 -0
  36. package/bin/lattices.ts +3260 -0
  37. package/bin/project-twin.ts +645 -0
  38. package/docs/agent-execution-plan.md +562 -0
  39. package/docs/agent-layer-guide.md +207 -0
  40. package/docs/agents.md +233 -0
  41. package/docs/ai-chat-ux-review.md +416 -0
  42. package/docs/api.md +1041 -47
  43. package/docs/app.md +96 -13
  44. package/docs/assistant-knowledge.md +130 -0
  45. package/docs/companion-deck.md +209 -0
  46. package/docs/component-extraction-roadmap.md +392 -0
  47. package/docs/concepts.md +13 -12
  48. package/docs/config.md +83 -10
  49. package/docs/gesture-customization-proposal.md +520 -0
  50. package/docs/handsoff-test-scenarios.md +84 -0
  51. package/docs/hyperspace-grid-snappiness.md +210 -0
  52. package/docs/layers.md +176 -28
  53. package/docs/mouse-gestures.md +244 -0
  54. package/docs/ocr.md +21 -9
  55. package/docs/overview.md +42 -23
  56. package/docs/presentation-execution-review.md +491 -0
  57. package/docs/prompts/hands-off-system.md +382 -0
  58. package/docs/prompts/hands-off-turn.md +30 -0
  59. package/docs/prompts/voice-advisor.md +31 -0
  60. package/docs/prompts/voice-fallback.md +23 -0
  61. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  62. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  63. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  64. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  65. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  66. package/docs/proposals/LAT-006-followup-gaps.md +103 -0
  67. package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
  68. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  69. package/docs/quickstart.md +8 -12
  70. package/docs/reference/dewey.config.ts +74 -0
  71. package/docs/reference/install-agent.md +79 -0
  72. package/docs/release.md +172 -0
  73. package/docs/repo-structure.md +100 -0
  74. package/docs/terminal-kit.md +87 -0
  75. package/docs/tiling-reference.md +224 -0
  76. package/docs/twins.md +138 -0
  77. package/docs/voice-command-protocol.md +278 -0
  78. package/docs/voice-error-model.md +73 -0
  79. package/docs/voice.md +221 -0
  80. package/package.json +69 -16
  81. package/packages/npm/sdk/cua.d.mts +1 -0
  82. package/packages/npm/sdk/cua.d.ts +188 -0
  83. package/packages/npm/sdk/cua.mjs +376 -0
  84. package/app/Lattices.app/Contents/Info.plist +0 -24
  85. package/app/Package.swift +0 -13
  86. package/app/Sources/ActionRow.swift +0 -61
  87. package/app/Sources/App.swift +0 -10
  88. package/app/Sources/AppDelegate.swift +0 -234
  89. package/app/Sources/AppShellView.swift +0 -62
  90. package/app/Sources/AppTypeClassifier.swift +0 -70
  91. package/app/Sources/AppWindowShell.swift +0 -63
  92. package/app/Sources/CheatSheetHUD.swift +0 -332
  93. package/app/Sources/CommandModeState.swift +0 -1362
  94. package/app/Sources/CommandModeView.swift +0 -1405
  95. package/app/Sources/CommandModeWindow.swift +0 -192
  96. package/app/Sources/CommandPaletteView.swift +0 -307
  97. package/app/Sources/CommandPaletteWindow.swift +0 -134
  98. package/app/Sources/DaemonProtocol.swift +0 -101
  99. package/app/Sources/DaemonServer.swift +0 -414
  100. package/app/Sources/DesktopModel.swift +0 -121
  101. package/app/Sources/DesktopModelTypes.swift +0 -71
  102. package/app/Sources/DiagnosticLog.swift +0 -271
  103. package/app/Sources/EventBus.swift +0 -30
  104. package/app/Sources/HotkeyManager.swift +0 -250
  105. package/app/Sources/HotkeyStore.swift +0 -338
  106. package/app/Sources/InventoryManager.swift +0 -35
  107. package/app/Sources/InventoryPath.swift +0 -43
  108. package/app/Sources/KeyRecorderView.swift +0 -210
  109. package/app/Sources/LatticesApi.swift +0 -1125
  110. package/app/Sources/MainView.swift +0 -467
  111. package/app/Sources/MainWindow.swift +0 -83
  112. package/app/Sources/OcrModel.swift +0 -309
  113. package/app/Sources/OcrStore.swift +0 -295
  114. package/app/Sources/OmniSearchState.swift +0 -283
  115. package/app/Sources/OmniSearchView.swift +0 -288
  116. package/app/Sources/OmniSearchWindow.swift +0 -105
  117. package/app/Sources/OrphanRow.swift +0 -129
  118. package/app/Sources/PaletteCommand.swift +0 -419
  119. package/app/Sources/PermissionChecker.swift +0 -125
  120. package/app/Sources/Preferences.swift +0 -92
  121. package/app/Sources/ProcessModel.swift +0 -199
  122. package/app/Sources/ProcessQuery.swift +0 -151
  123. package/app/Sources/Project.swift +0 -28
  124. package/app/Sources/ProjectRow.swift +0 -368
  125. package/app/Sources/ProjectScanner.swift +0 -121
  126. package/app/Sources/ScreenMapState.swift +0 -2387
  127. package/app/Sources/ScreenMapView.swift +0 -2820
  128. package/app/Sources/ScreenMapWindowController.swift +0 -89
  129. package/app/Sources/SessionManager.swift +0 -72
  130. package/app/Sources/SettingsView.swift +0 -1053
  131. package/app/Sources/SettingsWindow.swift +0 -20
  132. package/app/Sources/TabGroupRow.swift +0 -178
  133. package/app/Sources/Terminal.swift +0 -259
  134. package/app/Sources/TerminalQuery.swift +0 -156
  135. package/app/Sources/TerminalSynthesizer.swift +0 -200
  136. package/app/Sources/Theme.swift +0 -163
  137. package/app/Sources/TilePickerView.swift +0 -209
  138. package/app/Sources/TmuxModel.swift +0 -53
  139. package/app/Sources/TmuxQuery.swift +0 -81
  140. package/app/Sources/WindowTiler.swift +0 -1755
  141. package/app/Sources/WorkspaceManager.swift +0 -434
  142. package/bin/lattices-app.js +0 -221
  143. package/bin/lattices.js +0 -1418
@@ -0,0 +1,912 @@
1
+ import { readFileSync } from "fs";
2
+ import { dirname, join } from "path";
3
+
4
+ export type AssistantSlotValue = string | number | boolean;
5
+
6
+ export interface AssistantAction {
7
+ intent: string;
8
+ slots?: Record<string, AssistantSlotValue>;
9
+ }
10
+
11
+ export interface AssistantPlan {
12
+ actions: AssistantAction[];
13
+ spoken: string;
14
+ _meta?: Record<string, unknown>;
15
+ }
16
+
17
+ export interface DesktopWindowSnapshot {
18
+ wid?: number;
19
+ app?: string;
20
+ title?: string;
21
+ frame?: string;
22
+ onScreen?: boolean;
23
+ zIndex?: number;
24
+ session?: string;
25
+ }
26
+
27
+ export interface DesktopSnapshot {
28
+ windows?: DesktopWindowSnapshot[];
29
+ activeStage?: DesktopWindowSnapshot[];
30
+ screens?: Array<{ width?: number; height?: number; isMain?: boolean }>;
31
+ stageManager?: boolean;
32
+ smGrouping?: string;
33
+ stripApps?: string[];
34
+ hiddenApps?: string[];
35
+ terminals?: Array<Record<string, unknown>>;
36
+ tmuxSessions?: Array<Record<string, unknown>>;
37
+ currentLayer?: { name?: string; index?: number } | string;
38
+ screen?: string;
39
+ }
40
+
41
+ interface IntentDefinition {
42
+ intent: string;
43
+ description: string;
44
+ slots: Array<{ name: string; required?: boolean; description: string }>;
45
+ examples: string[];
46
+ }
47
+
48
+ const repoRoot = dirname(import.meta.dir);
49
+ export const assistantPromptPath = join(repoRoot, "docs", "prompts", "hands-off-system.md");
50
+
51
+ export const tilePositions = [
52
+ "left",
53
+ "right",
54
+ "top",
55
+ "bottom",
56
+ "top-left",
57
+ "top-right",
58
+ "bottom-left",
59
+ "bottom-right",
60
+ "left-third",
61
+ "center-third",
62
+ "right-third",
63
+ "top-left-third",
64
+ "top-center-third",
65
+ "top-right-third",
66
+ "bottom-left-third",
67
+ "bottom-center-third",
68
+ "bottom-right-third",
69
+ "first-fourth",
70
+ "second-fourth",
71
+ "third-fourth",
72
+ "last-fourth",
73
+ "top-first-fourth",
74
+ "top-second-fourth",
75
+ "top-third-fourth",
76
+ "top-last-fourth",
77
+ "bottom-first-fourth",
78
+ "bottom-second-fourth",
79
+ "bottom-third-fourth",
80
+ "bottom-last-fourth",
81
+ "maximize",
82
+ "center",
83
+ ] as const;
84
+
85
+ export const intentDefinitions: IntentDefinition[] = [
86
+ {
87
+ intent: "tile_window",
88
+ description: "Tile one window to a named position or grid cell.",
89
+ slots: [
90
+ { name: "position", required: true, description: "Named tile position, canonical 0-based grid:CxR:C,R, or compact 1-based CxR:C,R syntax." },
91
+ { name: "app", description: "Loose app name when no window id is known." },
92
+ { name: "wid", description: "Specific macOS window id from the desktop snapshot." },
93
+ { name: "session", description: "Tmux session name." },
94
+ ],
95
+ examples: [
96
+ "tile chrome left",
97
+ "snap this to the top right",
98
+ "maximize the window",
99
+ "put chrome in the top-right cell of a 4x4 grid",
100
+ ],
101
+ },
102
+ {
103
+ intent: "focus",
104
+ description: "Focus a window, app, or session.",
105
+ slots: [
106
+ { name: "app", description: "Loose app name." },
107
+ { name: "wid", description: "Specific window id." },
108
+ { name: "session", description: "Tmux session name." },
109
+ ],
110
+ examples: ["focus Slack", "show me the lattices terminal"],
111
+ },
112
+ {
113
+ intent: "distribute",
114
+ description: "Arrange visible windows in an even grid, optionally filtered by app and region.",
115
+ slots: [
116
+ { name: "app", description: "Optional app filter." },
117
+ { name: "region", description: "Optional screen region such as left, right, top, or bottom." },
118
+ ],
119
+ examples: ["organize my terminals", "grid Chrome on the right"],
120
+ },
121
+ {
122
+ intent: "swap",
123
+ description: "Swap two windows by id.",
124
+ slots: [
125
+ { name: "wid_a", required: true, description: "First window id." },
126
+ { name: "wid_b", required: true, description: "Second window id." },
127
+ ],
128
+ examples: ["swap Chrome and iTerm"],
129
+ },
130
+ {
131
+ intent: "hide",
132
+ description: "Hide an app or minimize a window.",
133
+ slots: [
134
+ { name: "app", description: "App name to hide." },
135
+ { name: "wid", description: "Window id to minimize." },
136
+ ],
137
+ examples: ["hide Slack", "minimize that"],
138
+ },
139
+ {
140
+ intent: "highlight",
141
+ description: "Flash a window border so the user can identify it.",
142
+ slots: [
143
+ { name: "app", description: "App name to find." },
144
+ { name: "wid", description: "Window id to flash." },
145
+ ],
146
+ examples: ["which one is the lattices terminal", "highlight Chrome"],
147
+ },
148
+ {
149
+ intent: "move_to_display",
150
+ description: "Move a window to another display, optionally placing it there.",
151
+ slots: [
152
+ { name: "display", required: true, description: "Display index, where 0 is main." },
153
+ { name: "position", description: "Optional tile position on the target display." },
154
+ { name: "app", description: "App name when no window id is known." },
155
+ { name: "wid", description: "Specific window id." },
156
+ ],
157
+ examples: ["move Chrome to my second monitor"],
158
+ },
159
+ {
160
+ intent: "undo",
161
+ description: "Restore the previous window positions.",
162
+ slots: [],
163
+ examples: ["undo that", "put it back"],
164
+ },
165
+ {
166
+ intent: "search",
167
+ description: "Search windows, terminal context, and OCR content.",
168
+ slots: [{ name: "query", required: true, description: "Search text." }],
169
+ examples: ["find the error message", "search for terminal windows"],
170
+ },
171
+ {
172
+ intent: "list_windows",
173
+ description: "List visible windows.",
174
+ slots: [],
175
+ examples: ["what windows are open"],
176
+ },
177
+ {
178
+ intent: "list_sessions",
179
+ description: "List active terminal sessions.",
180
+ slots: [],
181
+ examples: ["what sessions are running"],
182
+ },
183
+ {
184
+ intent: "switch_layer",
185
+ description: "Switch to a workspace layer.",
186
+ slots: [{ name: "layer", required: true, description: "Layer name or index." }],
187
+ examples: ["switch to the review layer", "go to layer 2"],
188
+ },
189
+ {
190
+ intent: "create_layer",
191
+ description: "Save current arrangement as a named layer.",
192
+ slots: [{ name: "name", required: true, description: "Layer name." }],
193
+ examples: ["save this layout as deploy"],
194
+ },
195
+ {
196
+ intent: "launch",
197
+ description: "Launch a project session.",
198
+ slots: [{ name: "project", required: true, description: "Project name or path." }],
199
+ examples: ["open the frontend project"],
200
+ },
201
+ {
202
+ intent: "kill",
203
+ description: "Kill a terminal session.",
204
+ slots: [{ name: "session", required: true, description: "Session name or project name." }],
205
+ examples: ["kill the API session"],
206
+ },
207
+ {
208
+ intent: "scan",
209
+ description: "Trigger immediate OCR scan.",
210
+ slots: [],
211
+ examples: ["scan the screen", "read what's on screen"],
212
+ },
213
+ {
214
+ intent: "find_mouse",
215
+ description: "Show the cursor location with a pulse.",
216
+ slots: [],
217
+ examples: ["find my mouse"],
218
+ },
219
+ {
220
+ intent: "summon_mouse",
221
+ description: "Move the cursor to the center of the screen.",
222
+ slots: [],
223
+ examples: ["summon mouse"],
224
+ },
225
+ ];
226
+
227
+ const positionAliases: Array<{ position: string; phrases: string[] }> = [
228
+ { position: "top-left", phrases: ["top left", "upper left", "top-left"] },
229
+ { position: "top-right", phrases: ["top right", "upper right", "top-right"] },
230
+ { position: "bottom-left", phrases: ["bottom left", "lower left", "bottom-left"] },
231
+ { position: "bottom-right", phrases: ["bottom right", "lower right", "bottom-right"] },
232
+ { position: "left-third", phrases: ["left third", "first third", "left-third"] },
233
+ { position: "center-third", phrases: ["center third", "middle third", "centre third", "center-third"] },
234
+ { position: "right-third", phrases: ["right third", "last third", "right-third"] },
235
+ { position: "left", phrases: ["left half", "left side", "the left", "left"] },
236
+ { position: "right", phrases: ["right half", "right side", "the right", "right"] },
237
+ { position: "top", phrases: ["top half", "upper half", "the top", "top"] },
238
+ { position: "bottom", phrases: ["bottom half", "lower half", "the bottom", "bottom"] },
239
+ { position: "maximize", phrases: ["maximize", "maximise", "full screen", "fullscreen", "make it big", "max"] },
240
+ { position: "center", phrases: ["center", "centre", "middle"] },
241
+ ];
242
+
243
+ const appAliases: Record<string, string[]> = {
244
+ "Google Chrome": ["chrome", "google chrome"],
245
+ iTerm2: ["iterm", "iterm2", "terminal", "terminals"],
246
+ Terminal: ["terminal app", "terminal"],
247
+ "Visual Studio Code": ["vs code", "vscode", "visual studio code"],
248
+ };
249
+
250
+ const noisePrefixes = [
251
+ "can you",
252
+ "could you",
253
+ "would you",
254
+ "please",
255
+ "just",
256
+ "go ahead and",
257
+ "let's",
258
+ "lets",
259
+ "i want to",
260
+ "i need to",
261
+ "i'd like to",
262
+ "id like to",
263
+ "ok",
264
+ "okay",
265
+ "hey",
266
+ "yo",
267
+ ];
268
+
269
+ const fillerWords = new Set([
270
+ "the",
271
+ "my",
272
+ "a",
273
+ "an",
274
+ "this",
275
+ "that",
276
+ "it",
277
+ "window",
278
+ "windows",
279
+ "app",
280
+ "application",
281
+ "project",
282
+ "session",
283
+ "layer",
284
+ "please",
285
+ "for",
286
+ "me",
287
+ "to",
288
+ "in",
289
+ "on",
290
+ "into",
291
+ "at",
292
+ "side",
293
+ "half",
294
+ "corner",
295
+ ]);
296
+
297
+ export function renderIntentCatalog(): string {
298
+ const parts = intentDefinitions.map((def) => {
299
+ const slots = def.slots.length
300
+ ? def.slots.map((slot) => {
301
+ const marker = slot.required ? " required" : " optional";
302
+ return ` ${slot.name} (${marker}): ${slot.description}`;
303
+ }).join("\n")
304
+ : " none";
305
+ return [
306
+ `${def.intent}: ${def.description}`,
307
+ " Slots:",
308
+ slots,
309
+ ` Examples: ${def.examples.map((x) => `"${x}"`).join(", ")}`,
310
+ ].join("\n");
311
+ });
312
+
313
+ return `${parts.join("\n\n")}
314
+
315
+ TILING PRESETS:
316
+ "split screen" / "side by side" -> tile_window left + right
317
+ "thirds" -> left-third + center-third + right-third
318
+ "quadrants" / "four corners" -> top-left + top-right + bottom-left + bottom-right
319
+ "mosaic" / "grid" / "spread out" -> distribute
320
+
321
+ POSITION RULES:
322
+ "quarter" means a 2x2 cell, not a 4x1 fourth.
323
+ Use wid from the snapshot when a target window is clear.
324
+ Use app only when no specific wid is available.`;
325
+ }
326
+
327
+ export function buildAssistantSystemPrompt(): string {
328
+ let prompt: string;
329
+ try {
330
+ prompt = readFileSync(assistantPromptPath, "utf-8")
331
+ .split("\n")
332
+ .filter((line) => !line.startsWith("# "))
333
+ .join("\n")
334
+ .trim();
335
+ } catch {
336
+ prompt = "You are a workspace assistant. Respond with JSON: {actions, spoken}.";
337
+ }
338
+
339
+ return prompt.replace("{{intent_catalog}}", renderIntentCatalog());
340
+ }
341
+
342
+ export function buildAssistantContextMessage(transcript: string, snapshot: DesktopSnapshot = {}): string {
343
+ let msg = `USER: "${transcript}"\n\n`;
344
+ msg += "--- DESKTOP SNAPSHOT ---\n";
345
+
346
+ const screens = snapshot.screens ?? [];
347
+ if (screens.length > 1) {
348
+ msg += `Displays: ${screens.map((s) => `${s.width}x${s.height}${s.isMain ? " (main)" : ""}`).join(", ")}\n`;
349
+ } else if (screens.length === 1) {
350
+ msg += `Screen: ${screens[0].width}x${screens[0].height}\n`;
351
+ } else if (snapshot.screen) {
352
+ msg += `Screen: ${snapshot.screen}\n`;
353
+ }
354
+
355
+ msg += `Stage Manager: ${snapshot.stageManager ? `ON (${snapshot.smGrouping ?? "all-at-once"})` : "OFF"}\n`;
356
+
357
+ const windows = listWindows(snapshot);
358
+ const onScreen = windows.filter((w) => w.onScreen !== false);
359
+ const offScreen = windows.filter((w) => w.onScreen === false);
360
+
361
+ msg += `\nVisible windows (${onScreen.length}, front-to-back order):\n`;
362
+ for (const w of onScreen) {
363
+ const flags: string[] = [];
364
+ if (w.zIndex === 0) flags.push("FRONTMOST");
365
+ if (w.session) flags.push(`session:${w.session}`);
366
+ const flagStr = flags.length ? ` [${flags.join(", ")}]` : "";
367
+ msg += ` wid:${w.wid ?? "?"} ${w.app ?? "Unknown"}: "${w.title ?? ""}"`;
368
+ if (w.frame) msg += ` - ${w.frame}`;
369
+ msg += `${flagStr}\n`;
370
+ }
371
+
372
+ if (offScreen.length > 0) {
373
+ const hiddenByApp = new Map<string, number>();
374
+ for (const w of offScreen) {
375
+ if (!w.app) continue;
376
+ hiddenByApp.set(w.app, (hiddenByApp.get(w.app) ?? 0) + 1);
377
+ }
378
+ const summary = [...hiddenByApp.entries()].map(([app, count]) => `${app}(${count})`).join(", ");
379
+ if (summary) msg += `\nHidden windows: ${summary}\n`;
380
+ }
381
+
382
+ const terminals = snapshot.terminals ?? [];
383
+ if (terminals.length > 0) {
384
+ msg += `\nTerminal tabs (${terminals.length}):\n`;
385
+ for (const tab of terminals) {
386
+ const displayName = String(tab.displayName ?? tab.app ?? "Terminal");
387
+ const cwd = typeof tab.cwd === "string" ? ` cwd:${tab.cwd.replace(/^\/Users\/[^/]+\//, "~/")}` : "";
388
+ const tmux = tab.tmuxSession ? ` tmux:${String(tab.tmuxSession)}` : "";
389
+ const claude = tab.hasClaude ? " Claude Code" : "";
390
+ const wid = tab.windowId ? ` wid:${String(tab.windowId)}` : "";
391
+ msg += ` ${displayName}${cwd}${tmux}${claude}${wid}\n`;
392
+ }
393
+ }
394
+
395
+ const sessions = snapshot.tmuxSessions ?? [];
396
+ if (sessions.length > 0) {
397
+ msg += `\nTmux sessions: ${sessions.map((s) => String(s.name ?? "unknown")).join(", ")}\n`;
398
+ }
399
+
400
+ if (snapshot.currentLayer) {
401
+ const layer = typeof snapshot.currentLayer === "string"
402
+ ? snapshot.currentLayer
403
+ : `${snapshot.currentLayer.name ?? "unknown"} (index: ${snapshot.currentLayer.index ?? "?"})`;
404
+ msg += `\nCurrent layer: ${layer}\n`;
405
+ }
406
+
407
+ msg += "--- END SNAPSHOT ---\n";
408
+ return msg;
409
+ }
410
+
411
+ export function tryLocalAssistantPlan(transcript: string, snapshot: DesktopSnapshot = {}): AssistantPlan | null {
412
+ const text = normalizeTranscript(transcript);
413
+ if (!text) return null;
414
+
415
+ if (/^(undo|put it back|restore|restore that|that was wrong)/.test(text)) {
416
+ return plan([{ intent: "undo", slots: {} }], "Restoring the previous positions.", "local-rule");
417
+ }
418
+
419
+ if (/(find|show|where).*(mouse|cursor)/.test(text)) {
420
+ return plan([{ intent: "find_mouse", slots: {} }], "Showing your cursor.", "local-rule");
421
+ }
422
+
423
+ if (/(summon|center|bring).*(mouse|cursor)/.test(text)) {
424
+ return plan([{ intent: "summon_mouse", slots: {} }], "Moving the cursor to the center.", "local-rule");
425
+ }
426
+
427
+ if (/^(scan|read|ocr|rescan)/.test(text) && /(screen|window|text|ocr)/.test(text)) {
428
+ return plan([{ intent: "scan", slots: {} }], "Scanning the screen.", "local-rule");
429
+ }
430
+
431
+ const split = parseSplitPlan(text, snapshot);
432
+ if (split) return split;
433
+
434
+ const layout = parseLayoutPlan(text, snapshot);
435
+ if (layout) return layout;
436
+
437
+ const tile = parseTilePlan(text, snapshot);
438
+ if (tile) return tile;
439
+
440
+ const distribute = parseDistributePlan(text);
441
+ if (distribute) return distribute;
442
+
443
+ const focus = parsePrefixedEntity(text, [
444
+ "focus on",
445
+ "focus",
446
+ "switch to",
447
+ "go to",
448
+ "show me",
449
+ "show",
450
+ "bring up",
451
+ "pull up",
452
+ ]);
453
+ if (focus && !/(layer|screen|windows|sessions)/.test(focus)) {
454
+ const target = resolveWindowTarget(focus, snapshot);
455
+ const slots = targetToSlots(target, focus);
456
+ return plan([{ intent: "focus", slots }], `Focusing ${target.label}.`, "local-rule");
457
+ }
458
+
459
+ const search = parseSearchQuery(text);
460
+ if (search) {
461
+ return plan([{ intent: "search", slots: { query: search } }], `Searching for ${search}.`, "local-rule");
462
+ }
463
+
464
+ if (/what.*windows|list windows|show.*windows|what.*open|what.*on screen/.test(text)) {
465
+ return plan([{ intent: "list_windows", slots: {} }], summarizeWindows(snapshot), "local-rule");
466
+ }
467
+
468
+ if (/sessions|projects.*running|what.*running/.test(text) && !/kill|stop/.test(text)) {
469
+ return plan([{ intent: "list_sessions", slots: {} }], "Listing your sessions.", "local-rule");
470
+ }
471
+
472
+ const layer = parseLayerSwitch(text);
473
+ if (layer) {
474
+ return plan([{ intent: "switch_layer", slots: { layer } }], `Switching to ${layer}.`, "local-rule");
475
+ }
476
+
477
+ const layerName = parsePrefixedEntity(text, [
478
+ "save this layout as",
479
+ "save layout as",
480
+ "create a layer called",
481
+ "create layer called",
482
+ "make a layer called",
483
+ "name this layer",
484
+ ]);
485
+ if (layerName) {
486
+ return plan([{ intent: "create_layer", slots: { name: cleanEntity(layerName) } }], `Saving this layout as ${cleanEntity(layerName)}.`, "local-rule");
487
+ }
488
+
489
+ const launch = parsePrefixedEntity(text, [
490
+ "open project",
491
+ "open the project",
492
+ "open",
493
+ "launch",
494
+ "start working on",
495
+ "work on",
496
+ ]);
497
+ if (launch && !looksLikeAppCommand(launch)) {
498
+ const project = cleanEntity(launch);
499
+ return plan([{ intent: "launch", slots: { project } }], `Launching ${project}.`, "local-rule");
500
+ }
501
+
502
+ const kill = parsePrefixedEntity(text, ["kill", "stop", "shut down", "terminate"]);
503
+ if (kill) {
504
+ const session = cleanEntity(kill);
505
+ return plan([{ intent: "kill", slots: { session } }], `Killing ${session}.`, "local-rule");
506
+ }
507
+
508
+ return null;
509
+ }
510
+
511
+ export function normalizeAssistantPlan(raw: unknown, fallbackTranscript = ""): AssistantPlan {
512
+ const obj = isRecord(raw) ? raw : {};
513
+ const rawActions = Array.isArray(obj.actions) ? obj.actions : [];
514
+ const actions = rawActions
515
+ .map(normalizeAction)
516
+ .filter((action): action is AssistantAction => action !== null);
517
+
518
+ const spokenRaw = typeof obj.spoken === "string" ? obj.spoken.trim() : "";
519
+ const spoken = spokenRaw || fallbackSpoken(actions, fallbackTranscript);
520
+
521
+ return {
522
+ actions,
523
+ spoken,
524
+ _meta: isRecord(obj._meta) ? obj._meta : undefined,
525
+ };
526
+ }
527
+
528
+ function normalizeAction(raw: unknown): AssistantAction | null {
529
+ if (!isRecord(raw)) return null;
530
+
531
+ const intent = normalizeIntentName(String(raw.intent ?? raw.action ?? ""));
532
+ if (!intent) return null;
533
+
534
+ const slotsRaw = isRecord(raw.slots) ? raw.slots : {};
535
+ const slots: Record<string, AssistantSlotValue> = {};
536
+ for (const [key, value] of Object.entries(slotsRaw)) {
537
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
538
+ slots[key] = value;
539
+ }
540
+ }
541
+
542
+ return { intent, slots };
543
+ }
544
+
545
+ function normalizeIntentName(intent: string): string {
546
+ const lower = intent.trim().toLowerCase().replace(/[.\s-]+/g, "_");
547
+ const aliases: Record<string, string> = {
548
+ window_place: "tile_window",
549
+ window_tile: "tile_window",
550
+ layer_activate: "switch_layer",
551
+ layer_switch: "switch_layer",
552
+ space_optimize: "distribute",
553
+ layout_distribute: "distribute",
554
+ };
555
+ return aliases[lower] ?? lower;
556
+ }
557
+
558
+ function parseTilePlan(text: string, snapshot: DesktopSnapshot): AssistantPlan | null {
559
+ if (!/(tile|snap|put|move|throw|maximize|maximise|center|centre|full screen|fullscreen)/.test(text)) {
560
+ return null;
561
+ }
562
+
563
+ const hit = findPosition(text);
564
+ if (!hit) return null;
565
+
566
+ let targetText = text
567
+ .replace(/^(tile|snap|put|move|throw)\s+/, "")
568
+ .replace(hit.phrase, " ")
569
+ .replace(/\b(to|in|on|into|at|the|half|side|corner)\b/g, " ");
570
+
571
+ if (/^(maximize|maximise|full screen|fullscreen|center|centre)/.test(text)) {
572
+ targetText = "";
573
+ }
574
+
575
+ const target = resolveWindowTarget(cleanEntity(targetText), snapshot);
576
+ const slots = { ...targetToSlots(target, cleanEntity(targetText)), position: hit.position };
577
+ return plan([{ intent: "tile_window", slots }], tileSpoken(target.label, hit.position), "local-rule");
578
+ }
579
+
580
+ function parseSplitPlan(text: string, snapshot: DesktopSnapshot): AssistantPlan | null {
581
+ const match = text.match(/split\s+(.+?)\s+(?:and|with|&)\s+(.+)/);
582
+ if (match) {
583
+ const left = resolveWindowTarget(match[1], snapshot);
584
+ const right = resolveWindowTarget(match[2], snapshot);
585
+ return plan([
586
+ { intent: "tile_window", slots: { ...targetToSlots(left, match[1]), position: "left" } },
587
+ { intent: "tile_window", slots: { ...targetToSlots(right, match[2]), position: "right" } },
588
+ ], `${left.label} left, ${right.label} right.`, "local-rule");
589
+ }
590
+
591
+ const explicit = text.match(/(.+?)\s+left\s+(.+?)\s+right$/);
592
+ if (explicit && /(chrome|safari|iterm|terminal|slack|code|cursor|finder)/.test(text)) {
593
+ const left = resolveWindowTarget(explicit[1], snapshot);
594
+ const right = resolveWindowTarget(explicit[2], snapshot);
595
+ return plan([
596
+ { intent: "tile_window", slots: { ...targetToSlots(left, explicit[1]), position: "left" } },
597
+ { intent: "tile_window", slots: { ...targetToSlots(right, explicit[2]), position: "right" } },
598
+ ], `${left.label} left, ${right.label} right.`, "local-rule");
599
+ }
600
+
601
+ return null;
602
+ }
603
+
604
+ function parseLayoutPlan(text: string, snapshot: DesktopSnapshot): AssistantPlan | null {
605
+ const windows = listWindows(snapshot).filter((w) => w.onScreen !== false);
606
+
607
+ if (/(quadrants?|four corners?|corners)/.test(text) && windows.length >= 4) {
608
+ const positions = ["top-left", "top-right", "bottom-left", "bottom-right"];
609
+ const actions = windows.slice(0, 4).map((w, index) => ({
610
+ intent: "tile_window",
611
+ slots: { wid: w.wid ?? 0, position: positions[index] },
612
+ }));
613
+ return plan(actions, "Putting four windows in quadrants.", "local-rule");
614
+ }
615
+
616
+ if (/\bthirds\b/.test(text) && windows.length >= 3) {
617
+ const positions = ["left-third", "center-third", "right-third"];
618
+ const actions = windows.slice(0, 3).map((w, index) => ({
619
+ intent: "tile_window",
620
+ slots: { wid: w.wid ?? 0, position: positions[index] },
621
+ }));
622
+ return plan(actions, "Arranging three windows in thirds.", "local-rule");
623
+ }
624
+
625
+ if (/(split screen|side by side)/.test(text) && windows.length >= 2) {
626
+ return plan([
627
+ { intent: "tile_window", slots: { wid: windows[0].wid ?? 0, position: "left" } },
628
+ { intent: "tile_window", slots: { wid: windows[1].wid ?? 0, position: "right" } },
629
+ ], "Splitting the front two windows.", "local-rule");
630
+ }
631
+
632
+ return null;
633
+ }
634
+
635
+ function parseDistributePlan(text: string): AssistantPlan | null {
636
+ if (!/(grid|mosaic|distribute|spread|organize|organise|arrange|tidy|clean up)/.test(text)) {
637
+ return null;
638
+ }
639
+
640
+ const slots: Record<string, AssistantSlotValue> = {};
641
+ const app = appFromText(text);
642
+ const region = regionFromText(text);
643
+ if (app) slots.app = app;
644
+ if (region) slots.region = region;
645
+
646
+ const scope = app ? `${app} windows` : "your windows";
647
+ const where = region ? ` on the ${region}` : "";
648
+ return plan([{ intent: "distribute", slots }], `Gridding ${scope}${where}.`, "local-rule");
649
+ }
650
+
651
+ function parseSearchQuery(text: string): string | null {
652
+ const query = parsePrefixedEntity(text, [
653
+ "find all",
654
+ "find",
655
+ "search for",
656
+ "search",
657
+ "look for",
658
+ "locate",
659
+ "where is",
660
+ "where does it say",
661
+ "which window has",
662
+ ]);
663
+ if (!query) return null;
664
+ return cleanQuery(query);
665
+ }
666
+
667
+ function parseLayerSwitch(text: string): string | null {
668
+ if (text === "next layer" || text === "previous layer") return text;
669
+ const literal: Record<string, string> = {
670
+ "layer one": "1",
671
+ "layer two": "2",
672
+ "layer three": "3",
673
+ "first layer": "1",
674
+ "second layer": "2",
675
+ "third layer": "3",
676
+ };
677
+ if (literal[text]) return literal[text];
678
+ const entity = parsePrefixedEntity(text, [
679
+ "switch to layer",
680
+ "switch to the",
681
+ "switch to",
682
+ "go to layer",
683
+ "go to the",
684
+ "activate layer",
685
+ "layer",
686
+ ]);
687
+ return entity ? cleanEntity(entity).replace(/\s+layer$/, "") : null;
688
+ }
689
+
690
+ function findPosition(text: string): { position: string; phrase: string } | null {
691
+ const grid = text.match(/(?:grid:)?\d+x\d+:\d+,\d+(?:-\d+,\d+)?/);
692
+ if (grid) {
693
+ const position = canonicalGridPosition(grid[0]);
694
+ if (position) return { position, phrase: grid[0] };
695
+ }
696
+
697
+ for (const entry of positionAliases) {
698
+ for (const phrase of entry.phrases) {
699
+ if (text.includes(phrase)) {
700
+ return { position: entry.position, phrase };
701
+ }
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+
707
+ function canonicalGridPosition(raw: string): string | null {
708
+ const match = raw.toLowerCase().match(/^(grid:)?(\d+)x(\d+):(\d+),(\d+)(?:-(\d+),(\d+))?$/);
709
+ if (!match) return null;
710
+
711
+ const oneBased = !match[1];
712
+ const columns = Number(match[2]);
713
+ const rows = Number(match[3]);
714
+ let c0 = Number(match[4]);
715
+ let r0 = Number(match[5]);
716
+ let c1 = match[6] === undefined ? c0 : Number(match[6]);
717
+ let r1 = match[7] === undefined ? r0 : Number(match[7]);
718
+ if (oneBased) {
719
+ c0 -= 1;
720
+ r0 -= 1;
721
+ c1 -= 1;
722
+ r1 -= 1;
723
+ }
724
+
725
+ const left = Math.min(c0, c1);
726
+ const right = Math.max(c0, c1);
727
+ const top = Math.min(r0, r1);
728
+ const bottom = Math.max(r0, r1);
729
+ if (
730
+ columns <= 0 || rows <= 0 ||
731
+ left < 0 || top < 0 ||
732
+ right >= columns || bottom >= rows
733
+ ) {
734
+ return null;
735
+ }
736
+
737
+ if (left === right && top === bottom) return `grid:${columns}x${rows}:${left},${top}`;
738
+ return `grid:${columns}x${rows}:${left},${top}-${right},${bottom}`;
739
+ }
740
+
741
+ function regionFromText(text: string): string | null {
742
+ const hit = findPosition(text);
743
+ if (!hit) return null;
744
+ return ["left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "left-third", "center-third", "right-third"].includes(hit.position)
745
+ ? hit.position
746
+ : null;
747
+ }
748
+
749
+ function appFromText(text: string): string | null {
750
+ for (const [app, aliases] of Object.entries(appAliases)) {
751
+ if (aliases.some((alias) => text.includes(alias))) return app;
752
+ }
753
+
754
+ const entity = parsePrefixedEntity(text, ["grid", "organize", "organise", "arrange", "distribute"]);
755
+ return entity ? titleCase(cleanEntity(entity).replace(/\bon (left|right|top|bottom).*$/, "")) : null;
756
+ }
757
+
758
+ function parsePrefixedEntity(text: string, prefixes: string[]): string | null {
759
+ const sorted = [...prefixes].sort((a, b) => b.length - a.length);
760
+ for (const prefix of sorted) {
761
+ if (text === prefix) return "";
762
+ if (text.startsWith(`${prefix} `)) {
763
+ return text.slice(prefix.length + 1).trim();
764
+ }
765
+ }
766
+ return null;
767
+ }
768
+
769
+ function normalizeTranscript(input: string): string {
770
+ let text = String(input ?? "")
771
+ .toLowerCase()
772
+ .replace(/[^a-z0-9\s:-]/g, " ")
773
+ .split(/\s+/)
774
+ .join(" ")
775
+ .trim();
776
+
777
+ let changed = true;
778
+ while (changed) {
779
+ changed = false;
780
+ for (const prefix of noisePrefixes) {
781
+ if (text.startsWith(`${prefix} `)) {
782
+ text = text.slice(prefix.length + 1).trim();
783
+ changed = true;
784
+ }
785
+ }
786
+ }
787
+
788
+ return text;
789
+ }
790
+
791
+ function cleanEntity(input: string): string {
792
+ const words = input
793
+ .toLowerCase()
794
+ .replace(/[^a-z0-9\s-]/g, " ")
795
+ .split(/\s+/)
796
+ .filter((word) => word && !fillerWords.has(word));
797
+ return words.join(" ").trim();
798
+ }
799
+
800
+ function cleanQuery(input: string): string {
801
+ return cleanEntity(input)
802
+ .replace(/\ball\b/g, " ")
803
+ .replace(/\bwith\b/g, " ")
804
+ .split(/\s+/)
805
+ .filter(Boolean)
806
+ .join(" ");
807
+ }
808
+
809
+ function listWindows(snapshot: DesktopSnapshot): DesktopWindowSnapshot[] {
810
+ const raw = snapshot.windows ?? snapshot.activeStage ?? [];
811
+ return raw
812
+ .filter((w) => w && (w.app || w.title || w.wid))
813
+ .sort((a, b) => (a.zIndex ?? 999) - (b.zIndex ?? 999));
814
+ }
815
+
816
+ function resolveWindowTarget(raw: string, snapshot: DesktopSnapshot): { wid?: number; app?: string; label: string } {
817
+ const cleaned = cleanEntity(raw);
818
+ const windows = listWindows(snapshot).filter((w) => w.onScreen !== false);
819
+
820
+ if (!cleaned || ["this", "that", "it"].includes(cleaned)) {
821
+ const front = windows.find((w) => w.zIndex === 0) ?? windows[0];
822
+ if (front?.wid) return { wid: front.wid, app: front.app, label: front.app ?? "this window" };
823
+ return { label: "this window" };
824
+ }
825
+
826
+ for (const [app, aliases] of Object.entries(appAliases)) {
827
+ if (aliases.includes(cleaned)) {
828
+ const win = windows.find((w) => w.app?.toLowerCase().includes(app.toLowerCase()));
829
+ if (win?.wid) return { wid: win.wid, app: win.app, label: win.app ?? app };
830
+ return { app, label: app };
831
+ }
832
+ }
833
+
834
+ const win = windows.find((w) => {
835
+ const app = w.app?.toLowerCase() ?? "";
836
+ const title = w.title?.toLowerCase() ?? "";
837
+ return app.includes(cleaned) || cleaned.includes(app) || title.includes(cleaned);
838
+ });
839
+ if (win?.wid) return { wid: win.wid, app: win.app, label: win.app ?? cleaned };
840
+
841
+ return { app: titleCase(cleaned), label: titleCase(cleaned) };
842
+ }
843
+
844
+ function targetToSlots(target: { wid?: number; app?: string }, fallback: string): Record<string, AssistantSlotValue> {
845
+ if (target.wid) return { wid: target.wid };
846
+ if (target.app) return { app: target.app };
847
+ const cleaned = cleanEntity(fallback);
848
+ return cleaned ? { app: titleCase(cleaned) } : {};
849
+ }
850
+
851
+ function tileSpoken(label: string, position: string): string {
852
+ if (position === "maximize") return `Maximizing ${label}.`;
853
+ if (position === "center") return `Centering ${label}.`;
854
+ return `Tiling ${label} to the ${position}.`;
855
+ }
856
+
857
+ function summarizeWindows(snapshot: DesktopSnapshot): string {
858
+ const windows = listWindows(snapshot).filter((w) => w.onScreen !== false);
859
+ if (!windows.length) return "I do not see any windows in the snapshot.";
860
+ const counts = new Map<string, number>();
861
+ for (const w of windows) {
862
+ const app = w.app ?? "Unknown";
863
+ counts.set(app, (counts.get(app) ?? 0) + 1);
864
+ }
865
+ const summary = [...counts.entries()]
866
+ .slice(0, 5)
867
+ .map(([app, count]) => count === 1 ? app : `${count} ${app}`)
868
+ .join(", ");
869
+ return `You've got ${windows.length} windows: ${summary}.`;
870
+ }
871
+
872
+ function fallbackSpoken(actions: AssistantAction[], transcript: string): string {
873
+ if (actions.length === 0) return transcript ? `I heard ${transcript}, but I do not have an action for it.` : "No action planned.";
874
+ if (actions.length === 1) {
875
+ const action = actions[0];
876
+ if (action.intent === "tile_window") return tileSpoken(String(action.slots?.app ?? "this window"), String(action.slots?.position ?? "there"));
877
+ if (action.intent === "focus") return `Focusing ${String(action.slots?.app ?? "that window")}.`;
878
+ if (action.intent === "distribute") return "Arranging the windows.";
879
+ }
880
+ return "I'll handle that.";
881
+ }
882
+
883
+ function plan(actions: AssistantAction[], spoken: string, source: string): AssistantPlan {
884
+ return { actions, spoken, _meta: { source } };
885
+ }
886
+
887
+ function titleCase(input: string): string {
888
+ return input
889
+ .split(/\s+/)
890
+ .filter(Boolean)
891
+ .map((word) => word.slice(0, 1).toUpperCase() + word.slice(1))
892
+ .join(" ");
893
+ }
894
+
895
+ function looksLikeAppCommand(input: string): boolean {
896
+ return /(chrome|safari|slack|finder|iterm|terminal|code|cursor|xcode)/.test(input);
897
+ }
898
+
899
+ function isRecord(value: unknown): value is Record<string, unknown> {
900
+ return typeof value === "object" && value !== null && !Array.isArray(value);
901
+ }
902
+
903
+ if (import.meta.main) {
904
+ const args = process.argv.slice(2);
905
+ const json = args.includes("--json");
906
+ const text = args.filter((arg) => arg !== "--json").join(" ");
907
+ const stdin = await Bun.stdin.text();
908
+ const snapshot = stdin.trim() ? JSON.parse(stdin) as DesktopSnapshot : {};
909
+ const local = tryLocalAssistantPlan(text, snapshot);
910
+ const result = local ?? { actions: [], spoken: "No local plan matched.", _meta: { source: "local-rule", matched: false } };
911
+ console.log(json ? JSON.stringify(result, null, 2) : result.spoken);
912
+ }