@arach/lattices 0.2.0 → 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 +172 -86
  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,141 @@
1
+ import { relativeTime } from "./helpers.ts";
2
+ import { withDaemon, type DaemonClient } from "./daemon.ts";
3
+
4
+ export interface SearchResult {
5
+ score: number;
6
+ window: any;
7
+ tabs: { tab: number; cwd: string; title: string; hasClaude: boolean; tmuxSession: string }[];
8
+ reasons: string[];
9
+ }
10
+
11
+ export interface SearchOptions {
12
+ sources?: string[];
13
+ after?: string;
14
+ before?: string;
15
+ recency?: boolean;
16
+ mode?: string;
17
+ }
18
+
19
+ async function searchWithClient(client: DaemonClient, query: string, opts: SearchOptions = {}): Promise<SearchResult[]> {
20
+ const { daemonCall } = client;
21
+ const params: Record<string, any> = { query };
22
+ if (opts.sources) params.sources = opts.sources;
23
+ if (opts.after) params.after = opts.after;
24
+ if (opts.before) params.before = opts.before;
25
+ if (opts.recency !== undefined) params.recency = opts.recency;
26
+ if (opts.mode) params.mode = opts.mode;
27
+ const hits = await daemonCall("lattices.search", params, 10000) as any[];
28
+ return hits.map((w: any) => ({
29
+ score: w.score || 0,
30
+ window: w,
31
+ tabs: (w.terminalTabs || []).map((t: any) => ({
32
+ tab: t.tabIndex, cwd: t.cwd, title: t.tabTitle, hasClaude: t.hasClaude, tmuxSession: t.tmuxSession,
33
+ })),
34
+ reasons: w.matchSources || [],
35
+ }));
36
+ }
37
+
38
+ export async function search(query: string, opts: SearchOptions = {}): Promise<SearchResult[]> {
39
+ return withDaemon(client => searchWithClient(client, query, opts));
40
+ }
41
+
42
+ export async function deepSearch(query: string): Promise<SearchResult[]> {
43
+ return search(query, { sources: ["all"] });
44
+ }
45
+
46
+ export function printResults(ranked: SearchResult[]): void {
47
+ if (!ranked.length) return;
48
+ for (const r of ranked) {
49
+ const w = r.window;
50
+ const age = w.lastInteraction ? ` \x1b[2m${relativeTime(w.lastInteraction)}\x1b[0m` : "";
51
+ console.log(` \x1b[1m${w.app}\x1b[0m "${w.title}" wid:${w.wid} score:${r.score} (${r.reasons.join(", ")})${age}`);
52
+ for (const t of r.tabs) {
53
+ const claude = t.hasClaude ? " \x1b[32m●\x1b[0m" : "";
54
+ const tmux = t.tmuxSession ? ` \x1b[36m[${t.tmuxSession}]\x1b[0m` : "";
55
+ console.log(` tab ${t.tab}: ${t.cwd || t.title}${claude}${tmux}`);
56
+ }
57
+ if (w.ocrSnippet) console.log(` ocr: "${w.ocrSnippet}"`);
58
+ }
59
+ console.log();
60
+ }
61
+
62
+ export async function searchCommand(
63
+ query: string | undefined,
64
+ flags: Set<string>,
65
+ rawArgs: string[] = []
66
+ ): Promise<void> {
67
+ if (!query) {
68
+ console.log("Usage: lattices search <query> [--quick | --terminal | --all | --deep | --sources=... | --after=... | --before=... | --json | --wid]");
69
+ return;
70
+ }
71
+
72
+ const opts: SearchOptions = {};
73
+
74
+ const sourcesFlag = rawArgs.find(a => a.startsWith("--sources="));
75
+ if (sourcesFlag) {
76
+ opts.sources = sourcesFlag.slice("--sources=".length).split(",");
77
+ } else if (flags.has("--all") || flags.has("--deep")) {
78
+ opts.sources = ["all"];
79
+ } else if (flags.has("--quick")) {
80
+ opts.sources = ["titles", "apps", "sessions"];
81
+ } else if (flags.has("--terminal")) {
82
+ opts.sources = ["terminals"];
83
+ }
84
+
85
+ const afterFlag = rawArgs.find(a => a.startsWith("--after="));
86
+ if (afterFlag) opts.after = afterFlag.slice("--after=".length);
87
+ const beforeFlag = rawArgs.find(a => a.startsWith("--before="));
88
+ if (beforeFlag) opts.before = beforeFlag.slice("--before=".length);
89
+
90
+ if (flags.has("--no-recency")) opts.recency = false;
91
+
92
+ const ranked = await search(query, opts);
93
+ const jsonOut = flags.has("--json");
94
+ const widOnly = flags.has("--wid");
95
+
96
+ if (jsonOut) {
97
+ console.log(JSON.stringify(ranked.map(r => ({
98
+ wid: r.window.wid, app: r.window.app, title: r.window.title,
99
+ score: r.score, reasons: r.reasons, tabs: r.tabs, ocrSnippet: r.window.ocrSnippet,
100
+ })), null, 2));
101
+ return;
102
+ }
103
+
104
+ if (widOnly) {
105
+ for (const r of ranked) console.log(r.window.wid);
106
+ return;
107
+ }
108
+
109
+ if (!ranked.length) {
110
+ console.log(`No results for "${query}"`);
111
+ return;
112
+ }
113
+
114
+ printResults(ranked);
115
+ }
116
+
117
+ export async function placeCommand(query?: string, tilePosition?: string): Promise<void> {
118
+ if (!query) {
119
+ console.log("Usage: lattices place <query> [position]");
120
+ return;
121
+ }
122
+
123
+ await withDaemon(async (client) => {
124
+ const { daemonCall } = client;
125
+ const ranked = await searchWithClient(client, query, { sources: ["all"] });
126
+
127
+ if (!ranked.length) {
128
+ console.log(`No window matching "${query}"`);
129
+ return;
130
+ }
131
+
132
+ const pos = tilePosition || "bottom-right";
133
+ const win = ranked[0].window;
134
+ await daemonCall("window.focus", { wid: win.wid });
135
+ await daemonCall("intents.execute", {
136
+ intent: "tile_window",
137
+ slots: { position: pos, wid: win.wid }
138
+ }, 3000);
139
+ console.log(`${win.app} "${win.title}" (wid:${win.wid}) → ${pos}`);
140
+ });
141
+ }
@@ -0,0 +1,32 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename, resolve } from "node:path";
3
+ import { runQuiet } from "./helpers.ts";
4
+
5
+ export function pathHash(dir: string): string {
6
+ return createHash("sha256").update(resolve(dir)).digest("hex").slice(0, 6);
7
+ }
8
+
9
+ export function toSessionName(dir: string): string {
10
+ const base = basename(dir).replace(/[^a-zA-Z0-9_-]/g, "-");
11
+ return `${base}-${pathHash(dir)}`;
12
+ }
13
+
14
+ export function esc(str: string): string {
15
+ return str.replace(/'/g, "'\\''");
16
+ }
17
+
18
+ export function slugify(str: string): string {
19
+ return str
20
+ .toLowerCase()
21
+ .replace(/\.app$/i, "")
22
+ .replace(/[^a-z0-9_-]+/g, "-")
23
+ .replace(/^-+|-+$/g, "") || "app";
24
+ }
25
+
26
+ export function sessionExists(name: string): boolean {
27
+ return runQuiet(`tmux has-session -t "${name}" 2>&1`) !== null;
28
+ }
29
+
30
+ export function toGroupSessionName(groupId: string): string {
31
+ return `lattices-group-${groupId}`;
32
+ }
package/bin/client.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Public API — re-exports from daemon-client for a cleaner import path.
2
+ // Usage: import { daemonCall, isDaemonRunning } from '@arach/lattices'
3
+ // Also published as @lattices/cli for back-compat.
4
+
5
+ export { daemonCall, isDaemonRunning } from "./daemon-client.ts";
6
+ export {
7
+ ProjectTwin,
8
+ createProjectTwin,
9
+ readOpenScoutRelayContext,
10
+ type OpenScoutRelayContext,
11
+ type ProjectTwinEvent,
12
+ type ProjectTwinInvokeRequest,
13
+ type ProjectTwinOptions,
14
+ type ProjectTwinResult,
15
+ type ProjectTwinState,
16
+ type ProjectTwinThinkingLevel,
17
+ } from "./project-twin.ts";
package/bin/cua.ts ADDED
@@ -0,0 +1,26 @@
1
+ export {
2
+ click,
3
+ computerClickParamsSchema,
4
+ computerClickTransportSchema,
5
+ computerMagicCursorParamsSchema,
6
+ computerTreatmentSchema,
7
+ createCuaClient,
8
+ cua,
9
+ cursorEdgeSchema,
10
+ cursorGlowSchema,
11
+ cursorIdleSchema,
12
+ cursorMotionSchema,
13
+ cursorShapeSchema,
14
+ cursorSizeSchema,
15
+ cursorStyleSchema,
16
+ cursorTrailSchema,
17
+ cursorTrajectorySchema,
18
+ magicCursor,
19
+ type ComputerClickParams,
20
+ type ComputerMagicCursorParams,
21
+ type CursorEdge,
22
+ type CursorGlow,
23
+ type CursorIdle,
24
+ type CuaClient,
25
+ type CuaClientOptions,
26
+ } from "../packages/npm/sdk/cua.mjs";
@@ -1,21 +1,26 @@
1
1
  // Lightweight WebSocket client for lattices daemon (ws://127.0.0.1:9399)
2
2
  // Uses Node `net` module with manual HTTP upgrade + minimal WS framing.
3
- // Zero npm dependencies. Requires Node >= 18.
3
+ // Zero npm dependencies.
4
4
 
5
- import { createConnection } from "node:net";
5
+ import { createConnection, type Socket } from "node:net";
6
6
  import { randomBytes } from "node:crypto";
7
7
 
8
8
  const DAEMON_HOST = "127.0.0.1";
9
9
  const DAEMON_PORT = 9399;
10
10
 
11
+ interface ParsedFrame {
12
+ payload: string;
13
+ rest: Buffer<ArrayBuffer>;
14
+ }
15
+
11
16
  /**
12
17
  * Send a JSON-RPC-style request to the daemon and return the response.
13
- * @param {string} method
14
- * @param {object} [params]
15
- * @param {number} [timeoutMs=3000]
16
- * @returns {Promise<object>} The result field from the response
17
18
  */
18
- export async function daemonCall(method, params, timeoutMs = 3000) {
19
+ export async function daemonCall(
20
+ method: string,
21
+ params?: Record<string, unknown> | null,
22
+ timeoutMs = 3000
23
+ ): Promise<unknown> {
19
24
  const id = randomBytes(4).toString("hex");
20
25
  const request = JSON.stringify({ id, method, params: params ?? null });
21
26
 
@@ -62,8 +67,8 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
62
67
  socket.write(upgrade);
63
68
  });
64
69
 
65
- socket.on("data", (chunk) => {
66
- buffer = Buffer.concat([buffer, chunk]);
70
+ socket.on("data", (chunk: Buffer) => {
71
+ buffer = Buffer.concat([buffer, chunk]) as Buffer<ArrayBuffer>;
67
72
 
68
73
  if (!upgraded) {
69
74
  const headerEnd = buffer.indexOf("\r\n\r\n");
@@ -82,23 +87,38 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
82
87
  sendFrame(socket, request);
83
88
  }
84
89
 
85
- // Try to parse a WebSocket frame from the buffer
86
- const result = parseFrame(buffer);
87
- if (result) {
90
+ // The daemon can push broadcast events before the RPC response.
91
+ // Keep consuming frames until we see our matching response id.
92
+ while (true) {
93
+ const result = parseFrame(buffer);
94
+ if (!result) break;
88
95
  buffer = result.rest;
89
- if (!settled) {
90
- settled = true;
91
- cleanup();
92
- try {
93
- const parsed = JSON.parse(result.payload);
96
+
97
+ try {
98
+ const parsed = JSON.parse(result.payload);
99
+ if (parsed.event) {
100
+ continue;
101
+ }
102
+ if (parsed.id !== id) {
103
+ continue;
104
+ }
105
+ if (!settled) {
106
+ settled = true;
107
+ cleanup();
94
108
  if (parsed.error) {
95
109
  reject(new Error(parsed.error));
96
110
  } else {
97
111
  resolve(parsed.result);
98
112
  }
99
- } catch (e) {
113
+ }
114
+ return;
115
+ } catch {
116
+ if (!settled) {
117
+ settled = true;
118
+ cleanup();
100
119
  reject(new Error("Invalid JSON response from daemon"));
101
120
  }
121
+ return;
102
122
  }
103
123
  }
104
124
  });
@@ -107,9 +127,8 @@ export async function daemonCall(method, params, timeoutMs = 3000) {
107
127
 
108
128
  /**
109
129
  * Check if the daemon is reachable.
110
- * @returns {Promise<boolean>}
111
130
  */
112
- export async function isDaemonRunning() {
131
+ export async function isDaemonRunning(): Promise<boolean> {
113
132
  try {
114
133
  await daemonCall("daemon.status", null, 1000);
115
134
  return true;
@@ -120,12 +139,12 @@ export async function isDaemonRunning() {
120
139
 
121
140
  // MARK: - WebSocket framing helpers
122
141
 
123
- function sendFrame(socket, text) {
142
+ function sendFrame(socket: Socket, text: string): void {
124
143
  const payload = Buffer.from(text, "utf8");
125
144
  const mask = randomBytes(4);
126
145
  const len = payload.length;
127
146
 
128
- let header;
147
+ let header: Buffer;
129
148
  if (len < 126) {
130
149
  header = Buffer.alloc(2);
131
150
  header[0] = 0x81; // FIN + text opcode
@@ -145,17 +164,17 @@ function sendFrame(socket, text) {
145
164
  // Mask payload
146
165
  const masked = Buffer.alloc(payload.length);
147
166
  for (let i = 0; i < payload.length; i++) {
148
- masked[i] = payload[i] ^ mask[i % 4];
167
+ masked[i] = payload[i]! ^ mask[i % 4]!;
149
168
  }
150
169
 
151
170
  socket.write(Buffer.concat([header, mask, masked]));
152
171
  }
153
172
 
154
- function parseFrame(buf) {
173
+ function parseFrame(buf: Buffer): ParsedFrame | null {
155
174
  if (buf.length < 2) return null;
156
175
 
157
- const masked = (buf[1] & 0x80) !== 0;
158
- let payloadLen = buf[1] & 0x7f;
176
+ const isMasked = (buf[1]! & 0x80) !== 0;
177
+ let payloadLen = buf[1]! & 0x7f;
159
178
  let offset = 2;
160
179
 
161
180
  if (payloadLen === 126) {
@@ -168,20 +187,20 @@ function parseFrame(buf) {
168
187
  offset = 10;
169
188
  }
170
189
 
171
- if (masked) offset += 4;
190
+ if (isMasked) offset += 4;
172
191
  if (buf.length < offset + payloadLen) return null;
173
192
 
174
193
  let payload = buf.subarray(offset, offset + payloadLen);
175
- if (masked) {
194
+ if (isMasked) {
176
195
  const maskKey = buf.subarray(offset - 4, offset);
177
196
  payload = Buffer.alloc(payloadLen);
178
197
  for (let i = 0; i < payloadLen; i++) {
179
- payload[i] = buf[offset + i] ^ maskKey[i % 4];
198
+ payload[i] = buf[offset + i]! ^ maskKey[i % 4]!;
180
199
  }
181
200
  }
182
201
 
183
202
  return {
184
203
  payload: payload.toString("utf8"),
185
- rest: buf.subarray(offset + payloadLen),
204
+ rest: buf.subarray(offset + payloadLen) as Buffer<ArrayBuffer>,
186
205
  };
187
206
  }
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Hands-off inference script — called by HandsOffSession.swift.
4
+ *
5
+ * Usage: echo '{"transcript":"tile chrome left","snapshot":{...}}' | bun run bin/handsoff-infer.ts
6
+ *
7
+ * Reads JSON from stdin, calls the configured voice inference provider, prints JSON result to stdout.
8
+ * All logging goes to stderr so it doesn't pollute the JSON output.
9
+ */
10
+
11
+ import {
12
+ buildAssistantContextMessage,
13
+ buildAssistantSystemPrompt,
14
+ normalizeAssistantPlan,
15
+ tryLocalAssistantPlan,
16
+ } from "./assistant-intelligence.ts";
17
+ import { inferJSON, resolveVoiceInferenceOptions } from "./infer.ts";
18
+
19
+ const INFER_TIMEOUT_MS = 15_000;
20
+
21
+ // ── Read input from stdin ──────────────────────────────────────────
22
+
23
+ const input = await Bun.stdin.text();
24
+ const req = JSON.parse(input) as {
25
+ transcript: string;
26
+ snapshot: {
27
+ stageManager?: boolean;
28
+ smGrouping?: string;
29
+ activeStage?: Array<{ wid: number; app: string; title: string; frame: string }>;
30
+ stripApps?: string[];
31
+ hiddenApps?: string[];
32
+ currentLayer?: string;
33
+ screen?: string;
34
+ };
35
+ history?: Array<{ role: "user" | "assistant"; content: string }>;
36
+ };
37
+
38
+ const transcript = req.transcript ?? "";
39
+ const systemPrompt = buildAssistantSystemPrompt();
40
+ const userMessage = buildAssistantContextMessage(transcript, req.snapshot ?? {});
41
+ const voiceInference = resolveVoiceInferenceOptions();
42
+
43
+ const localPlan = tryLocalAssistantPlan(transcript, req.snapshot ?? {});
44
+ if (localPlan) {
45
+ console.log(JSON.stringify(localPlan));
46
+ process.exit(0);
47
+ }
48
+
49
+ // ── Call inference ──────────────────────────────────────────────────
50
+
51
+ const messages = (req.history ?? []).map((h) => ({
52
+ role: h.role as "user" | "assistant",
53
+ content: h.content,
54
+ }));
55
+
56
+ const controller = new AbortController();
57
+ const timer = setTimeout(() => controller.abort(), INFER_TIMEOUT_MS);
58
+
59
+ try {
60
+ const { data, raw } = await inferJSON(userMessage, {
61
+ provider: voiceInference.provider,
62
+ model: voiceInference.model,
63
+ system: systemPrompt,
64
+ messages,
65
+ temperature: 0.2,
66
+ maxTokens: 512,
67
+ abortSignal: controller.signal,
68
+ tag: "hands-off",
69
+ });
70
+
71
+ // Output result as JSON to stdout
72
+ const plan = normalizeAssistantPlan(data, transcript);
73
+ const output = {
74
+ ...plan,
75
+ _meta: {
76
+ ...plan._meta,
77
+ provider: raw.provider,
78
+ model: raw.model,
79
+ durationMs: raw.durationMs,
80
+ tokens: raw.usage?.totalTokens,
81
+ },
82
+ };
83
+
84
+ console.log(JSON.stringify(output));
85
+ } catch (err: any) {
86
+ console.log(
87
+ JSON.stringify({
88
+ actions: [],
89
+ spoken: "Sorry, I had trouble processing that.",
90
+ _meta: { error: err.message },
91
+ })
92
+ );
93
+ process.exitCode = 1;
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }