@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
package/bin/infer.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Lattices inference wrapper — thin layer over Vercel AI SDK.
3
+ *
4
+ * Features:
5
+ * - Multi-provider: groq, openai, anthropic, google, xai
6
+ * - Credential loading: env vars → .env.local/.env → ~/.lattices/inference.json → ~/.config/speakeasy/settings.json
7
+ * - Instrumented: every call logged with timing, model, token usage
8
+ * - Simple API: `await infer("do something", { provider: "groq" })`
9
+ */
10
+
11
+ import { generateText, type ModelMessage } from "ai";
12
+ import { createOpenAI } from "@ai-sdk/openai";
13
+ import { createAnthropic } from "@ai-sdk/anthropic";
14
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
15
+ import { createXai } from "@ai-sdk/xai";
16
+ import { readFileSync, existsSync } from "fs";
17
+ import { homedir } from "os";
18
+ import { join } from "path";
19
+ import { getKeychainSecret } from "./keychain";
20
+
21
+ // ── Types ──────────────────────────────────────────────────────────
22
+
23
+ export type ProviderName = "groq" | "openai" | "anthropic" | "google" | "xai" | "minimax";
24
+
25
+ export interface InferOptions {
26
+ provider?: ProviderName;
27
+ model?: string;
28
+ system?: string;
29
+ messages?: ModelMessage[];
30
+ temperature?: number;
31
+ maxTokens?: number;
32
+ /** Tag for logging — e.g. "hands-off", "voice-fallback" */
33
+ tag?: string;
34
+ /** Abort signal for cancellation/timeout */
35
+ abortSignal?: AbortSignal;
36
+ }
37
+
38
+ export interface InferResult {
39
+ text: string;
40
+ provider: ProviderName;
41
+ model: string;
42
+ durationMs: number;
43
+ usage?: {
44
+ promptTokens?: number;
45
+ completionTokens?: number;
46
+ totalTokens?: number;
47
+ };
48
+ }
49
+
50
+ // ── Default models per provider ────────────────────────────────────
51
+
52
+ const PROVIDER_NAMES: ProviderName[] = ["groq", "openai", "anthropic", "google", "xai", "minimax"];
53
+ const VOICE_PROVIDER_PRIORITY: ProviderName[] = ["xai", "groq", "openai", "google", "anthropic", "minimax"];
54
+
55
+ const DEFAULT_MODELS: Record<ProviderName, string> = {
56
+ groq: "llama-3.3-70b-versatile",
57
+ openai: "gpt-4o-mini",
58
+ anthropic: "claude-sonnet-4-6",
59
+ google: "gemini-2.0-flash",
60
+ xai: "grok-4.20-reasoning",
61
+ minimax: "MiniMax-M2.5-highspeed",
62
+ };
63
+
64
+ // Voice paths use the same models as default — earlier we forced groq to
65
+ // llama-3.1-8b-instant for latency, but its 6k TPM cap couldn't fit a real
66
+ // desktop snapshot (saw 7174-token requests rejected). 70B versatile fits
67
+ // 128k context and Groq still serves it fast.
68
+ const VOICE_DEFAULT_MODELS: Record<ProviderName, string> = {
69
+ ...DEFAULT_MODELS,
70
+ };
71
+
72
+ // ── Credential loading ─────────────────────────────────────────────
73
+
74
+ interface CredentialStore {
75
+ groq?: string;
76
+ openai?: string;
77
+ anthropic?: string;
78
+ google?: string;
79
+ xai?: string;
80
+ minimax?: string;
81
+ }
82
+
83
+ let _cachedCreds: CredentialStore | null = null;
84
+ let _cachedLocalEnv: Record<string, string> | null = null;
85
+
86
+ function parseDotEnv(content: string): Record<string, string> {
87
+ const env: Record<string, string> = {};
88
+
89
+ for (const rawLine of content.split(/\r?\n/)) {
90
+ const line = rawLine.trim();
91
+ if (!line || line.startsWith("#")) continue;
92
+
93
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
94
+ if (!match) continue;
95
+
96
+ const [, key, rawValue] = match;
97
+ let value = rawValue.trim();
98
+ const quote = value[0];
99
+ if ((quote === `"` || quote === `'`) && value.endsWith(quote)) {
100
+ value = value.slice(1, -1);
101
+ } else {
102
+ value = value.replace(/\s+#.*$/, "").trim();
103
+ }
104
+
105
+ env[key] = value;
106
+ }
107
+
108
+ return env;
109
+ }
110
+
111
+ function loadLocalEnv(): Record<string, string> {
112
+ if (_cachedLocalEnv) return _cachedLocalEnv;
113
+
114
+ const repoRoot = join(import.meta.dir, "..");
115
+ const candidates = [
116
+ join(repoRoot, ".env"),
117
+ join(repoRoot, ".env.local"),
118
+ join(process.cwd(), ".env"),
119
+ join(process.cwd(), ".env.local"),
120
+ ];
121
+
122
+ const env: Record<string, string> = {};
123
+ for (const file of Array.from(new Set(candidates))) {
124
+ if (!existsSync(file)) continue;
125
+ try {
126
+ Object.assign(env, parseDotEnv(readFileSync(file, "utf-8")));
127
+ } catch {}
128
+ }
129
+
130
+ _cachedLocalEnv = env;
131
+ return env;
132
+ }
133
+
134
+ export function getInferenceEnv(name: string): string | undefined {
135
+ return process.env[name] || loadLocalEnv()[name];
136
+ }
137
+
138
+ function firstInferenceEnv(names: string[]): string | undefined {
139
+ for (const name of names) {
140
+ const value = getInferenceEnv(name);
141
+ if (value) return value;
142
+ }
143
+ }
144
+
145
+ function normalizeProvider(value: string | undefined): ProviderName | undefined {
146
+ const provider = value?.trim().toLowerCase();
147
+ return PROVIDER_NAMES.includes(provider as ProviderName) ? (provider as ProviderName) : undefined;
148
+ }
149
+
150
+ function assignGrokAlias(creds: CredentialStore) {
151
+ const key = getInferenceEnv("GROK_API_KEY");
152
+ if (!key) return;
153
+
154
+ // People often say/type "Grok" when they mean Groq. Use the key shape to
155
+ // route the alias without making xAI and Groq credentials interchangeable.
156
+ if (!creds.groq && key.startsWith("gsk_")) creds.groq = key;
157
+ if (!creds.xai && key.startsWith("xai-")) creds.xai = key;
158
+ }
159
+
160
+ function loadCredentials(): CredentialStore {
161
+ if (_cachedCreds) return _cachedCreds;
162
+
163
+ const creds: CredentialStore = {};
164
+
165
+ // Layer 1: env vars (highest priority)
166
+ const groqKey = getInferenceEnv("GROQ_API_KEY");
167
+ const openaiKey = getInferenceEnv("OPENAI_API_KEY");
168
+ const anthropicKey = getInferenceEnv("ANTHROPIC_API_KEY");
169
+ const googleKey = getInferenceEnv("GOOGLE_GENERATIVE_AI_API_KEY");
170
+ // SUPERGROK_API_KEY (SuperGrok Heavy tier) takes precedence over the
171
+ // standard XAI_API_KEY when both are present.
172
+ const xaiKey =
173
+ getInferenceEnv("SUPERGROK_API_KEY") || getInferenceEnv("XAI_API_KEY");
174
+ const minimaxKey = getInferenceEnv("MINIMAX_API_KEY");
175
+ if (groqKey) creds.groq = groqKey;
176
+ if (openaiKey) creds.openai = openaiKey;
177
+ if (anthropicKey) creds.anthropic = anthropicKey;
178
+ if (googleKey) creds.google = googleKey;
179
+ if (xaiKey) creds.xai = xaiKey;
180
+ if (minimaxKey) creds.minimax = minimaxKey;
181
+ assignGrokAlias(creds);
182
+
183
+ // Layer 2: ~/.lattices/inference.json
184
+ const latticesConfig = join(homedir(), ".lattices", "inference.json");
185
+ if (existsSync(latticesConfig)) {
186
+ try {
187
+ const cfg = JSON.parse(readFileSync(latticesConfig, "utf-8"));
188
+ if (cfg.keys) {
189
+ if (!creds.groq && cfg.keys.groq) creds.groq = cfg.keys.groq;
190
+ if (!creds.openai && cfg.keys.openai) creds.openai = cfg.keys.openai;
191
+ if (!creds.anthropic && cfg.keys.anthropic) creds.anthropic = cfg.keys.anthropic;
192
+ if (!creds.google && cfg.keys.google) creds.google = cfg.keys.google;
193
+ if (!creds.xai && cfg.keys.xai) creds.xai = cfg.keys.xai;
194
+ if (!creds.minimax && cfg.keys.minimax) creds.minimax = cfg.keys.minimax;
195
+ }
196
+ } catch {}
197
+ }
198
+
199
+ // Layer 3: ~/.config/speakeasy/settings.json (fallback)
200
+ const speakeasyConfig = join(homedir(), ".config", "speakeasy", "settings.json");
201
+ if (existsSync(speakeasyConfig)) {
202
+ try {
203
+ const cfg = JSON.parse(readFileSync(speakeasyConfig, "utf-8"));
204
+ const p = cfg.providers || {};
205
+ if (!creds.groq && p.groq?.apiKey) creds.groq = p.groq.apiKey;
206
+ if (!creds.openai && p.openai?.apiKey) creds.openai = p.openai.apiKey;
207
+ if (!creds.anthropic && p.anthropic?.apiKey) creds.anthropic = p.anthropic.apiKey;
208
+ if (!creds.google && p.gemini?.apiKey) creds.google = p.gemini.apiKey;
209
+ if (!creds.xai && p.xai?.apiKey) creds.xai = p.xai.apiKey;
210
+ if (!creds.minimax && p.minimax?.apiKey) creds.minimax = p.minimax.apiKey;
211
+ } catch {}
212
+ }
213
+
214
+ // Layer 4 — macOS keychain via built-in `/usr/bin/security` under the
215
+ // `lattices.inference` service. One read per missing provider, cached
216
+ // in `_cachedCreds` for the process lifetime. Keys never touch disk.
217
+ // Portable across machines (no external CLI dep).
218
+ if (!creds.xai) creds.xai = getKeychainSecret("xai");
219
+ if (!creds.groq) creds.groq = getKeychainSecret("groq");
220
+ if (!creds.openai) creds.openai = getKeychainSecret("openai");
221
+ if (!creds.anthropic) creds.anthropic = getKeychainSecret("anthropic");
222
+ if (!creds.google) creds.google = getKeychainSecret("google");
223
+ if (!creds.minimax) creds.minimax = getKeychainSecret("minimax");
224
+
225
+ _cachedCreds = creds;
226
+ return creds;
227
+ }
228
+
229
+ /** Clear cached credentials (call if config changes at runtime) */
230
+ export function clearCredentialCache() {
231
+ _cachedCreds = null;
232
+ _cachedLocalEnv = null;
233
+ }
234
+
235
+ /** List which providers have credentials available */
236
+ export function availableProviders(): ProviderName[] {
237
+ const creds = loadCredentials();
238
+ return (Object.keys(creds) as ProviderName[]).filter((k) => !!creds[k]);
239
+ }
240
+
241
+ /** Voice/hands-off defaults favor the lowest-latency configured provider. */
242
+ export function resolveVoiceInferenceOptions(): { provider: ProviderName; model: string } {
243
+ const configuredProvider = normalizeProvider(firstInferenceEnv([
244
+ "LATTICES_VOICE_PROVIDER",
245
+ "LATTICES_HANDSOFF_PROVIDER",
246
+ "LATTICES_INFER_PROVIDER",
247
+ ]));
248
+
249
+ const creds = loadCredentials();
250
+ const provider = configuredProvider
251
+ ?? VOICE_PROVIDER_PRIORITY.find((name) => !!creds[name])
252
+ ?? "groq";
253
+
254
+ const model = firstInferenceEnv([
255
+ "LATTICES_VOICE_MODEL",
256
+ "LATTICES_HANDSOFF_MODEL",
257
+ "LATTICES_INFER_MODEL",
258
+ ]) ?? VOICE_DEFAULT_MODELS[provider];
259
+
260
+ return { provider, model };
261
+ }
262
+
263
+ // ── Provider factory ───────────────────────────────────────────────
264
+
265
+ function getModel(provider: ProviderName, modelId: string) {
266
+ const creds = loadCredentials();
267
+
268
+ switch (provider) {
269
+ case "groq": {
270
+ const groq = createOpenAI({
271
+ baseURL: "https://api.groq.com/openai/v1",
272
+ apiKey: creds.groq,
273
+ });
274
+ return groq(modelId);
275
+ }
276
+ case "openai": {
277
+ const openai = createOpenAI({ apiKey: creds.openai });
278
+ return openai(modelId);
279
+ }
280
+ case "anthropic": {
281
+ const anthropic = createAnthropic({ apiKey: creds.anthropic });
282
+ return anthropic(modelId);
283
+ }
284
+ case "google": {
285
+ const google = createGoogleGenerativeAI({ apiKey: creds.google });
286
+ return google(modelId);
287
+ }
288
+ case "xai": {
289
+ const xai = createXai({ apiKey: creds.xai });
290
+ return xai(modelId);
291
+ }
292
+ case "minimax": {
293
+ // MiniMax uses OpenAI-compatible chat completions API
294
+ const minimax = createOpenAI({
295
+ baseURL: "https://api.minimax.io/v1",
296
+ apiKey: creds.minimax,
297
+ });
298
+ return minimax.chat(modelId);
299
+ }
300
+ }
301
+ }
302
+
303
+ // ── Logging ────────────────────────────────────────────────────────
304
+
305
+ function log(tag: string, msg: string) {
306
+ const ts = new Date().toISOString().slice(11, 23);
307
+ console.error(`[${ts}] infer${tag ? `/${tag}` : ""}: ${msg}`);
308
+ }
309
+
310
+ // ── Main inference function ────────────────────────────────────────
311
+
312
+ /**
313
+ * Run inference against any supported provider.
314
+ *
315
+ * @example
316
+ * // Simple
317
+ * const { text } = await infer("What windows do I have?", { provider: "groq" })
318
+ *
319
+ * // With system prompt and messages
320
+ * const { text } = await infer("tile chrome left", {
321
+ * provider: "groq",
322
+ * system: "You are a workspace assistant...",
323
+ * tag: "hands-off",
324
+ * })
325
+ *
326
+ * // With conversation history
327
+ * const { text } = await infer("now the other one right", {
328
+ * provider: "groq",
329
+ * messages: [
330
+ * { role: "user", content: "tile chrome left" },
331
+ * { role: "assistant", content: '{"actions":[...]}' },
332
+ * ],
333
+ * })
334
+ */
335
+ export async function infer(
336
+ prompt: string,
337
+ options: InferOptions = {}
338
+ ): Promise<InferResult> {
339
+ const provider = options.provider ?? "groq";
340
+ const modelId = options.model ?? DEFAULT_MODELS[provider];
341
+ const tag = options.tag ?? "";
342
+
343
+ // Check credentials
344
+ const creds = loadCredentials();
345
+ if (!creds[provider]) {
346
+ throw new Error(
347
+ `No API key for provider "${provider}". Set it in env, .env.local, ~/.lattices/inference.json, or ~/.config/speakeasy/settings.json`
348
+ );
349
+ }
350
+
351
+ const model = getModel(provider, modelId);
352
+
353
+ // Build messages
354
+ const messages: ModelMessage[] = [
355
+ ...(options.messages ?? []),
356
+ { role: "user", content: prompt },
357
+ ];
358
+
359
+ log(tag, `→ ${provider}/${modelId} (${prompt.length} chars)`);
360
+ const start = performance.now();
361
+
362
+ try {
363
+ const result = await generateText({
364
+ model,
365
+ system: options.system,
366
+ messages,
367
+ temperature: options.temperature ?? 0.3,
368
+ maxOutputTokens: options.maxTokens ?? 1024,
369
+ abortSignal: options.abortSignal,
370
+ });
371
+
372
+ const durationMs = Math.round(performance.now() - start);
373
+
374
+ const usage = result.usage
375
+ ? {
376
+ promptTokens: result.usage.inputTokens,
377
+ completionTokens: result.usage.outputTokens,
378
+ totalTokens: result.usage.totalTokens,
379
+ }
380
+ : undefined;
381
+
382
+ log(
383
+ tag,
384
+ `← ${durationMs}ms | ${usage?.totalTokens ?? "?"} tokens | ${result.text.length} chars`
385
+ );
386
+
387
+ return {
388
+ text: result.text,
389
+ provider,
390
+ model: modelId,
391
+ durationMs,
392
+ usage,
393
+ };
394
+ } catch (err: any) {
395
+ const durationMs = Math.round(performance.now() - start);
396
+ log(tag, `✗ ${durationMs}ms | ${err.message ?? err}`);
397
+ throw err;
398
+ }
399
+ }
400
+
401
+ // ── Convenience: infer with automatic JSON parsing ─────────────────
402
+
403
+ export async function inferJSON<T = any>(
404
+ prompt: string,
405
+ options: InferOptions = {}
406
+ ): Promise<{ data: T; raw: InferResult }> {
407
+ const result = await infer(prompt, options);
408
+
409
+ // Extract JSON from response (handle markdown fences)
410
+ let cleaned = result.text
411
+ .replace(/```json\s*/g, "")
412
+ .replace(/```\s*/g, "")
413
+ .trim();
414
+
415
+ const start = cleaned.indexOf("{");
416
+ const end = cleaned.lastIndexOf("}");
417
+ if (start === -1 || end === -1) {
418
+ throw new Error(`No JSON found in response: ${result.text.slice(0, 200)}`);
419
+ }
420
+ cleaned = cleaned.slice(start, end + 1);
421
+
422
+ const data = JSON.parse(cleaned) as T;
423
+ return { data, raw: result };
424
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Tiny Lattices keychain helper.
3
+ *
4
+ * Reads and writes generic passwords under the `lattices.inference` service
5
+ * via the built-in macOS `/usr/bin/security` CLI. No external dependencies,
6
+ * universally available on macOS (so portable across user machines without
7
+ * the user installing anything personal).
8
+ *
9
+ * Items are stored as a single keychain entry per provider — account = provider
10
+ * name (xai, groq, openai, anthropic, google, minimax). The macOS keychain
11
+ * does the encrypt-at-rest + ACL work; this file is only a thin shell-out.
12
+ *
13
+ * Usage in code:
14
+ * const key = getKeychainSecret("xai");
15
+ * setKeychainSecret("xai", "xai-foo...");
16
+ * deleteKeychainSecret("xai");
17
+ *
18
+ * Usage from a terminal (no Lattices wrapper needed — pure macOS):
19
+ * security add-generic-password -s lattices.inference -a xai -w <key> -U
20
+ * security find-generic-password -s lattices.inference -a xai -w
21
+ * security delete-generic-password -s lattices.inference -a xai
22
+ */
23
+
24
+ import { execFileSync } from "child_process";
25
+
26
+ export const KEYCHAIN_SERVICE = "lattices.inference";
27
+ const SECURITY_BIN = "/usr/bin/security";
28
+ const TIMEOUT_MS = 1500;
29
+
30
+ export function getKeychainSecret(account: string): string | undefined {
31
+ try {
32
+ const value = execFileSync(
33
+ SECURITY_BIN,
34
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"],
35
+ { encoding: "utf-8", timeout: TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] },
36
+ ).trim();
37
+ return value || undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ export function setKeychainSecret(account: string, value: string): boolean {
44
+ try {
45
+ // -U updates if the item already exists; otherwise adds. The value is
46
+ // passed via env to keep it out of `ps`/argv.
47
+ execFileSync(
48
+ SECURITY_BIN,
49
+ ["add-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w", value, "-U"],
50
+ { timeout: TIMEOUT_MS, stdio: ["ignore", "ignore", "ignore"] },
51
+ );
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export function deleteKeychainSecret(account: string): boolean {
59
+ try {
60
+ execFileSync(
61
+ SECURITY_BIN,
62
+ ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account],
63
+ { timeout: TIMEOUT_MS, stdio: ["ignore", "ignore", "ignore"] },
64
+ );
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ export function listKeychainAccounts(): string[] {
72
+ // `security dump-keychain` is heavy; instead probe each known account.
73
+ // Callers pass the candidate list explicitly to keep this stateless.
74
+ return [];
75
+ }